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">{markdownHtml as "safe"}</div>
128 ) : view.type === "inline" ? (
129 <div class="shiki-wrapper">{view.html as "safe"}</div>
130 ) : view.type === "media" ? (
131 <div class="file-media">
132 {view.mimeType.startsWith("image/") ? (
133 <img
134 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
135 alt={filename}
136 class="file-media-img"
137 />
138 ) : view.mimeType.startsWith("audio/") ? (
139 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
140 <audio
141 controls=""
142 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
143 class="file-media-audio"
144 />
145 ) : (
146 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
147 <video
148 controls
149 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
150 class="file-media-video"
151 />
152 )}
153 </div>
154 ) : view.type === "binary" ? (
155 <div class="file-download-notice">
156 <p safe>Binary file ({formatSize(view.size)})</p>
157 <a
158 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
159 class="btn btn-primary"
160 >
161 Download
162 </a>
163 </div>
164 ) : (
165 <div class="file-download-notice">
166 <p safe>
167 File too large to display inline (
168 {formatSize(view.size)})
169 </p>
170 <a
171 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
172 class="btn btn-primary"
173 >
174 Download
175 </a>
176 </div>
177 )}
178 </div>
179 </div>
180 </Layout>
181 );
182}
183
184function formatSize(bytes: number): string {
185 if (bytes < 1024) return `${bytes} B`;
186 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
187 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
188}
189