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 {view.type === "inline" &&
77 branches.includes(blobRef) &&
78 user?.isAdmin && (
79 <a
80 href={`/${repo.name}/edit/${blobRef}/${filePath}`}
81 class="btn btn-sm btn-primary"
82 >
83 Edit
84 </a>
85 )}
86 </div>
87 </div>
88 <div class="file-blob-body">
89 {view.type === "inline" ? (
90 <div class="shiki-wrapper">{view.html}</div>
91 ) : view.type === "media" ? (
92 <div class="file-media">
93 {view.mimeType.startsWith("image/") ? (
94 <img
95 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
96 alt={filename}
97 class="file-media-img"
98 />
99 ) : view.mimeType.startsWith("audio/") ? (
100 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
101 <audio
102 controls="true"
103 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
104 class="file-media-audio"
105 />
106 ) : (
107 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
108 <video
109 controls
110 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
111 class="file-media-video"
112 />
113 )}
114 </div>
115 ) : view.type === "binary" ? (
116 <div class="file-download-notice">
117 <p>Binary file ({formatSize(view.size)})</p>
118 <a
119 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
120 class="btn btn-primary"
121 >
122 Download
123 </a>
124 </div>
125 ) : (
126 <div class="file-download-notice">
127 <p>
128 File too large to display inline (
129 {formatSize(view.size)})
130 </p>
131 <a
132 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
133 class="btn btn-primary"
134 >
135 Download
136 </a>
137 </div>
138 )}
139 </div>
140 </div>
141 </Layout>
142 );
143}
144
145function formatSize(bytes: number): string {
146 if (bytes < 1024) return `${bytes} B`;
147 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
148 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
149}
150