FileBlob.tsx
Raw
1import type { RepositoryRow } from "../../db/index.ts";
2import type { SessionUser } from "../../middleware/session.ts";
3import type { FileView } from "../../services/highlight.ts";
4import { Layout } from "../layout.tsx";
5import { RepoHeader } from "../repos/RepoHeader.tsx";
6import { BranchSelector } from "./BranchSelector.tsx";
7import { RepoNav } from "./RepoNav.tsx";
8
9interface FileBlobProps {
10 user: SessionUser | null;
11 repo: RepositoryRow;
12 ref: string;
13 filePath: string;
14 view: FileView;
15 branches: string[];
16}
17
18export function FileBlob({
19 user,
20 repo,
21 ref: blobRef,
22 filePath,
23 view,
24 branches,
25}: FileBlobProps) {
26 const parts = filePath.split("/");
27 const filename = parts[parts.length - 1] ?? filePath;
28 const dir = parts.slice(0, -1).join("/");
29 const _backHref = dir
30 ? `/${repo.name}/tree/${blobRef}/${dir}`
31 : `/${repo.name}/tree/${blobRef}`;
32 return (
33 <Layout user={user} title={`${repo.name}/${filePath}`}>
34 <div class="container">
35 <RepoHeader repo={repo} />
36 <RepoNav repo={repo} active="code" user={user} />
37 <div class="breadcrumb">
38 <a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a>
39 {parts.map((part, i) => {
40 const partPath = parts.slice(0, i + 1).join("/");
41 const isLast = i === parts.length - 1;
42 return (
43 <>
44 <span class="breadcrumb-sep">/</span>
45 {isLast ? (
46 <span class="breadcrumb-current">
47 {part}
48 </span>
49 ) : (
50 <a
51 href={`/${repo.name}/tree/${blobRef}/${partPath}`}
52 >
53 {part}
54 </a>
55 )}
56 </>
57 );
58 })}
59 </div>
60 <div class="file-blob-header">
61 <span class="file-blob-name">{filename}</span>
62 <div class="file-blob-actions">
63 <BranchSelector
64 repoName={repo.name}
65 branches={branches}
66 currentRef={blobRef}
67 view="blob"
68 path={filePath}
69 />
70 <a
71 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
72 class="btn btn-sm btn-ghost"
73 >
74 Raw
75 </a>
76 </div>
77 </div>
78 <div class="file-blob-body">
79 {view.type === "inline" ? (
80 <div class="shiki-wrapper">{view.html}</div>
81 ) : view.type === "media" ? (
82 <div class="file-media">
83 {view.mimeType.startsWith("image/") ? (
84 <img
85 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
86 alt={filename}
87 class="file-media-img"
88 />
89 ) : view.mimeType.startsWith("audio/") ? (
90 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
91 <audio
92 controls="true"
93 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
94 class="file-media-audio"
95 />
96 ) : (
97 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
98 <video
99 controls
100 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
101 class="file-media-video"
102 />
103 )}
104 </div>
105 ) : view.type === "binary" ? (
106 <div class="file-download-notice">
107 <p>Binary file ({formatSize(view.size)})</p>
108 <a
109 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
110 class="btn btn-primary"
111 >
112 Download
113 </a>
114 </div>
115 ) : (
116 <div class="file-download-notice">
117 <p>
118 File too large to display inline (
119 {formatSize(view.size)})
120 </p>
121 <a
122 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
123 class="btn btn-primary"
124 >
125 Download
126 </a>
127 </div>
128 )}
129 </div>
130 </div>
131 </Layout>
132 );
133}
134
135function formatSize(bytes: number): string {
136 if (bytes < 1024) return `${bytes} B`;
137 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
138 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
139}
140