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