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