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 tags?: string[];
17 markdownHtml?: string;
18}
19
20export function FileBlob({
21 user,
22 repo,
23 ref: blobRef,
24 filePath,
25 view,
26 branches,
27 tags,
28 markdownHtml,
29}: FileBlobProps) {
30 const parts = filePath.split("/");
31 const filename = parts[parts.length - 1] ?? filePath;
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}`} safe>
39 {repo.name}
40 </a>
41 {parts.map((part, i) => {
42 const partPath = parts.slice(0, i + 1).join("/");
43 const isLast = i === parts.length - 1;
44 return (
45 <>
46 <span class="breadcrumb-sep">/</span>
47 {isLast ? (
48 <span class="breadcrumb-current" safe>
49 {part}
50 </span>
51 ) : (
52 <a
53 href={`/${repo.name}/tree/${blobRef}/${partPath}`}
54 safe
55 >
56 {part}
57 </a>
58 )}
59 </>
60 );
61 })}
62 </div>
63 <div class="file-blob-header">
64 <span class="file-blob-name" safe>
65 {filename}
66 </span>
67 <div class="file-blob-actions file-blob-actions-wrap">
68 <BranchSelector
69 repoName={repo.name}
70 branches={branches}
71 tags={tags}
72 currentRef={blobRef}
73 view="blob"
74 path={filePath}
75 />
76 <a
77 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
78 class="btn btn-sm"
79 >
80 Raw
81 </a>
82 {view.type === "inline" &&
83 branches.includes(blobRef) &&
84 user?.isAdmin && (
85 <a
86 href={`/${repo.name}/edit/${blobRef}/${filePath}`}
87 class="btn btn-sm btn-primary"
88 >
89 Edit
90 </a>
91 )}
92 {branches.includes(blobRef) && user?.isAdmin && (
93 <details class="confirm-details">
94 <summary class="btn btn-sm btn-danger">
95 Delete
96 </summary>
97 <div class="confirm-popup">
98 Delete <strong safe>{filename}</strong>?
99 <form
100 method="POST"
101 action={`/${repo.name}/delete-file/${blobRef}/${filePath}`}
102 class="popup-form"
103 style="margin-top: var(--space-2);"
104 >
105 <div class="form-group">
106 <input
107 name="message"
108 type="text"
109 placeholder={`Delete ${filename}`}
110 maxlength="500"
111 />
112 </div>
113 <button
114 type="submit"
115 class="btn btn-sm btn-danger"
116 >
117 Yes, delete
118 </button>
119 </form>
120 </div>
121 </details>
122 )}
123 </div>
124 </div>
125 <div class="file-blob-body">
126 {markdownHtml ? (
127 <div class="markdown-body">
128 {markdownHtml as "safe"}
129 </div>
130 ) : view.type === "inline" ? (
131 <div class="shiki-wrapper">{view.html as "safe"}</div>
132 ) : view.type === "media" ? (
133 <div class="file-media">
134 {view.mimeType.startsWith("image/") ? (
135 <img
136 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
137 alt={filename}
138 class="file-media-img"
139 />
140 ) : view.mimeType.startsWith("audio/") ? (
141 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
142 <audio
143 controls=""
144 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
145 class="file-media-audio"
146 />
147 ) : (
148 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
149 <video
150 controls
151 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
152 class="file-media-video"
153 />
154 )}
155 </div>
156 ) : view.type === "binary" ? (
157 <div class="file-download-notice">
158 <p safe>Binary file ({formatSize(view.size)})</p>
159 <a
160 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
161 class="btn btn-primary"
162 >
163 Download
164 </a>
165 </div>
166 ) : (
167 <div class="file-download-notice">
168 <p safe>
169 File too large to display inline (
170 {formatSize(view.size)})
171 </p>
172 <a
173 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
174 class="btn btn-primary"
175 >
176 Download
177 </a>
178 </div>
179 )}
180 </div>
181 </div>
182 </Layout>
183 );
184}
185
186function formatSize(bytes: number): string {
187 if (bytes < 1024) return `${bytes} B`;
188 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
189 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
190}
191