add media rendering, binary diff sizes, title editing, and security hardening

- render image/audio/video files inline in the file browser
- show binary file sizes in diffs via blob lookup or GIT binary patch literals
- skip syntax highlighting for diffs exceeding INLINE_MAX_BYTES threshold
- add inline title editing for issues and patches
- restrict comment/issue/patch edit and delete to open items (admins exempt)
- support HTTP Range requests on the raw file endpoint; fix URL decode
- always run git config core.bare before DB lookup in repoSync
- remove seed.ts

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit9e3cb42dd8afd15eb81594131dc67a14b3d33673
Parente5e41ae
16 files changed, 463 insertions(+), 408 deletions(-)
Dsrc/db/seed.ts
-256
@@ -1,256 +0,0 @@
1-/**
2- * Seed script — populates the live database with enough data to test pagination.
3- * Run with: bun run db:seed
4- *
5- * Safe to run multiple times — skips existing items.
6- */
7-import { Database } from "bun:sqlite";
8-import { mkdirSync } from "node:fs";
9-import path from "node:path";
10-import * as argon2 from "argon2";
11-import { ADMIN_USERNAME, DB_PATH, REPOS_DIR } from "../constants.ts";
12-
13-const db = new Database(DB_PATH);
14-db.run("PRAGMA journal_mode=WAL");
15-db.run("PRAGMA foreign_keys=ON");
16-
17-// ── Resolve admin user ────────────────────────────────────────────────────────
18-const adminRow = db
19- .query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
20- .get(ADMIN_USERNAME);
21-if (!adminRow) {
22- console.error("Admin user not found. Run `bun run db:init` first.");
23- process.exit(1);
24-}
25-const adminId = adminRow.id;
26-
27-// Ensure a second user "alice" exists for variety
28-let aliceId: number;
29-const aliceRow = db
30- .query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
31- .get("alice");
32-if (aliceRow) {
33- aliceId = aliceRow.id;
34-} else {
35- const hash = await argon2.hash("password123");
36- const now = new Date().toISOString();
37- db.run(
38- "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
39- ["alice", hash, now],
40- );
41- aliceId = db
42- .query<{ id: number }, [string]>(
43- "SELECT id FROM users WHERE username = ?",
44- )
45- .get("alice")!.id;
46- console.log("Created user alice (password: password123)");
47-}
48-
49-// ── Helper ────────────────────────────────────────────────────────────────────
50-function getOrCreateRepo(name: string, description: string): number {
51- const existing = db
52- .query<{ id: number }, [string]>(
53- "SELECT id FROM repositories WHERE name = ?",
54- )
55- .get(name);
56- if (existing) return existing.id;
57-
58- const now = new Date().toISOString();
59- db.run(
60- "INSERT INTO repositories (name, description, is_private, default_branch, created_at) VALUES (?, ?, 0, 'main', ?)",
61- [name, description, now],
62- );
63- const repoPath = path.join(REPOS_DIR, `${name}.git`);
64- mkdirSync(repoPath, { recursive: true });
65- // init bare repo (sync-ish via Bun.spawnSync)
66- Bun.spawnSync(["git", "init", "--bare", "--initial-branch=main", repoPath]);
67- console.log(`Created repo: ${name}`);
68- return db
69- .query<{ id: number }, [string]>(
70- "SELECT id FROM repositories WHERE name = ?",
71- )
72- .get(name)!.id;
73-}
74-
75-// ── 25 extra repositories (for repo list pagination) ─────────────────────────
76-const topics = [
77- "A web framework",
78- "CLI toolkit",
79- "Database driver",
80- "Auth library",
81- "Test runner",
82- "Build system",
83- "Linter plugin",
84- "ORM layer",
85- "Cache client",
86- "Queue worker",
87- "API gateway",
88- "Graph engine",
89- "ML utilities",
90- "Crypto helpers",
91- "File watcher",
92- "Schema validator",
93- "Logger library",
94- "Rate limiter",
95- "Metrics exporter",
96- "Job scheduler",
97- "Config manager",
98- "Template engine",
99- "Markdown parser",
100- "Image resizer",
101- "Email sender",
102-];
103-
104-for (let i = 1; i <= 25; i++) {
105- const n = String(i).padStart(2, "0");
106- getOrCreateRepo(`seed-repo-${n}`, topics[i - 1]!);
107-}
108-
109-// ── "demo" repo — seed issues and patches ────────────────────────────────────
110-const demoRepoId = getOrCreateRepo(
111- "demo",
112- "Demo repository for pagination testing",
113-);
114-
115-// Seed 35 issues (mix of open and closed)
116-const issueCount =
117- db
118- .query<{ n: number }, [number]>(
119- "SELECT COUNT(*) as n FROM issues WHERE repo_id = ?",
120- )
121- .get(demoRepoId)?.n ?? 0;
122-if (issueCount < 35) {
123- const start = issueCount + 1;
124- const issueTitles = [
125- "Fix null pointer dereference in parser",
126- "Add dark mode support",
127- "Improve error messages",
128- "Upgrade dependencies to latest",
129- "Memory leak in connection pool",
130- "Race condition in event loop",
131- "Add pagination to commit log",
132- "Slow query on large datasets",
133- "Missing CORS headers",
134- "Typo in README",
135- "Refactor authentication middleware",
136- "Support IPv6 addresses",
137- "Add unit tests for utils",
138- "Broken link in documentation",
139- "Config file not loaded on Windows",
140- "Infinite loop when input is empty",
141- "Add rate limiting",
142- "Log rotation broken",
143- "Session cookie not cleared on logout",
144- "Crash on malformed JSON input",
145- "Add export to CSV feature",
146- "Support custom themes",
147- "API returns 500 on edge case",
148- "Update license to MIT",
149- "Improve startup time",
150- "Handle timeout errors gracefully",
151- "Add health check endpoint",
152- "Support environment variables in config",
153- "Fix XSS in search",
154- "Stale cache after update",
155- "Add OpenAPI spec",
156- "Sort order wrong in list view",
157- "Binary file detection false positive",
158- "Column alignment off in table view",
159- "Wrong timezone in timestamps",
160- ];
161-
162- for (let i = start; i <= 35; i++) {
163- const titleIndex = (i - 1) % issueTitles.length;
164- const status = i <= 25 ? "open" : "closed";
165- const authorId = i % 3 === 0 ? aliceId : adminId;
166- const now = new Date(Date.now() - (35 - i) * 3600_000).toISOString();
167- db.run(
168- "INSERT OR IGNORE INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
169- [
170- demoRepoId,
171- authorId,
172- i,
173- issueTitles[titleIndex]!,
174- `Details for issue #${i}.`,
175- status,
176- now,
177- now,
178- ],
179- );
180- }
181- console.log(`Seeded issues up to #35 in demo repo`);
182-}
183-
184-// Seed 25 patches
185-const patchCount =
186- db
187- .query<{ n: number }, [number]>(
188- "SELECT COUNT(*) as n FROM patches WHERE repo_id = ?",
189- )
190- .get(demoRepoId)?.n ?? 0;
191-if (patchCount < 25) {
192- const start = patchCount + 1;
193- const SAMPLE_PATCH = [
194- "diff --git a/placeholder.txt b/placeholder.txt",
195- "new file mode 100644",
196- "index 0000000..e69de29",
197- "--- /dev/null",
198- "+++ b/placeholder.txt",
199- "@@ -0,0 +1 @@",
200- "+placeholder",
201- "",
202- ].join("\n");
203-
204- const patchTitles = [
205- "Fix null deref",
206- "Add dark mode",
207- "Improve errors",
208- "Upgrade deps",
209- "Fix memory leak",
210- "Fix race condition",
211- "Add pagination",
212- "Optimise query",
213- "Add CORS headers",
214- "Fix typo",
215- "Refactor auth",
216- "Support IPv6",
217- "Add unit tests",
218- "Fix broken link",
219- "Fix Windows config",
220- "Fix infinite loop",
221- "Add rate limiting",
222- "Fix log rotation",
223- "Fix logout cookie",
224- "Handle bad JSON",
225- "Add CSV export",
226- "Custom themes",
227- "Fix 500 error",
228- "Update license",
229- "Improve startup",
230- ];
231-
232- for (let i = start; i <= 25; i++) {
233- const titleIndex = (i - 1) % patchTitles.length;
234- const status = i <= 20 ? "open" : "closed";
235- const authorId = i % 3 === 0 ? aliceId : adminId;
236- const now = new Date(Date.now() - (25 - i) * 3600_000).toISOString();
237- db.run(
238- "INSERT OR IGNORE INTO patches (repo_id, author_id, number, title, description, patch_content, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
239- [
240- demoRepoId,
241- authorId,
242- i,
243- patchTitles[titleIndex]!,
244- `Description for patch #${i}.`,
245- SAMPLE_PATCH,
246- status,
247- now,
248- now,
249- ],
250- );
251- }
252- console.log(`Seeded patches up to #25 in demo repo`);
253-}
254-
255-db.close();
256-console.log("Seed complete.");
Msrc/routes/issues.tsx
@@ -495,13 +495,15 @@ export const issueRoutes = new Elysia()
495495 const issueNum = parseInt(params.number, 10);
496496 const issue = await db
497497 .selectFrom("issues")
498- .select(["id", "author_id"])
498+ .select(["id", "author_id", "status"])
499499 .where("repo_id", "=", repo.id)
500500 .where("number", "=", issueNum)
501501 .executeTakeFirst();
502502 if (!issue) return new Response("Not found", { status: 404 });
503503 if (issue.author_id !== user?.id && !user?.isAdmin)
504504 return new Response("Forbidden", { status: 403 });
505+ if (issue.status !== "open" && !user?.isAdmin)
506+ return new Response("Forbidden", { status: 403 });
505507
506508 await db
507509 .updateTable("issues")
@@ -544,6 +546,13 @@ export const issueRoutes = new Elysia()
544546 if (!comment) return new Response("Not found", { status: 404 });
545547 if (comment.author_id !== user?.id && !user?.isAdmin)
546548 return new Response("Forbidden", { status: 403 });
549+ const parentIssue = await db
550+ .selectFrom("issues")
551+ .select("status")
552+ .where("id", "=", comment.issue_id)
553+ .executeTakeFirst();
554+ if (parentIssue?.status !== "open" && !user?.isAdmin)
555+ return new Response("Forbidden", { status: 403 });
547556
548557 const issueNum = parseInt(params.number, 10);
549558 await db
Msrc/routes/patches.tsx
@@ -623,12 +623,19 @@ export const patchRoutes = new Elysia()
623623
624624 const comment = await db
625625 .selectFrom("patch_comments")
626- .select(["id", "author_id"])
626+ .select(["id", "author_id", "patch_id"])
627627 .where("id", "=", params.id)
628628 .executeTakeFirst();
629629 if (!comment) return new Response("Not found", { status: 404 });
630630 if (comment.author_id !== user?.id && !user?.isAdmin)
631631 return new Response("Forbidden", { status: 403 });
632+ const parentPatch = await db
633+ .selectFrom("patches")
634+ .select("status")
635+ .where("id", "=", comment.patch_id)
636+ .executeTakeFirst();
637+ if (parentPatch?.status !== "open" && !user?.isAdmin)
638+ return new Response("Forbidden", { status: 403 });
632639
633640 const patchNum = parseInt(params.number, 10);
634641 await db
@@ -667,13 +674,15 @@ export const patchRoutes = new Elysia()
667674 const patchNum = parseInt(params.number, 10);
668675 const patch = await db
669676 .selectFrom("patches")
670- .select(["id", "author_id"])
677+ .select(["id", "author_id", "status"])
671678 .where("repo_id", "=", repo.id)
672679 .where("number", "=", patchNum)
673680 .executeTakeFirst();
674681 if (!patch) return new Response("Not found", { status: 404 });
675682 if (patch.author_id !== user?.id && !user?.isAdmin)
676683 return new Response("Forbidden", { status: 403 });
684+ if (patch.status !== "open" && !user?.isAdmin)
685+ return new Response("Forbidden", { status: 403 });
677686
678687 await db
679688 .updateTable("patches")
Msrc/routes/repos.tsx
@@ -354,7 +354,7 @@ export const repoRoutes = new Elysia()
354354 const resolved = await git.resolveRef(repo.name, params.ref);
355355 if (!resolved) return new Response("Not found", { status: 404 });
356356
357- const subpath = params["*"];
357+ const subpath = decodeURIComponent(params["*"]);
358358 const [entries, branches] = await Promise.all([
359359 git.lsTree(repo.name, params.ref, subpath),
360360 git.branches(repo.name),
@@ -393,7 +393,7 @@ export const repoRoutes = new Elysia()
393393 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
394394 if (!repo) return new Response("Not found", { status: 404 });
395395
396- const filePath = params["*"];
396+ const filePath = decodeURIComponent(params["*"]);
397397 const [content, branches, commitSHA] = await Promise.all([
398398 git.show(repo.name, params.ref, filePath),
399399 git.branches(repo.name),
@@ -420,22 +420,44 @@ export const repoRoutes = new Elysia()
420420 );
421421 })
422422
423- .get("/:repo/raw/:ref/*", async ({ params, cookie }) => {
423+ .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
424424 const user = await resolveSession(cookie.session.value);
425425 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
426426 if (!repo) return new Response("Not found", { status: 404 });
427427
428- const filePath = params["*"];
428+ const filePath = decodeURIComponent(params["*"]);
429429 const content = await git.show(repo.name, params.ref, filePath);
430430 if (!content) return new Response("Not found", { status: 404 });
431431
432432 const filename = path.basename(filePath);
433433 const contentType = await mimeForContent(content);
434+ const total = content.length;
435+
436+ const rangeHeader = request.headers.get("Range");
437+ if (rangeHeader) {
438+ const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
439+ if (match) {
440+ const start = match[1] ? parseInt(match[1], 10) : 0;
441+ const end = match[2] ? parseInt(match[2], 10) : total - 1;
442+ const clampedEnd = Math.min(end, total - 1);
443+ return new Response(content.subarray(start, clampedEnd + 1), {
444+ status: 206,
445+ headers: {
446+ "Content-Type": contentType,
447+ "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
448+ "Accept-Ranges": "bytes",
449+ "Content-Length": String(clampedEnd - start + 1),
450+ },
451+ });
452+ }
453+ }
454+
434455 return new Response(content, {
435456 headers: {
436457 "Content-Type": contentType,
437458 "Content-Disposition": `inline; filename="${filename}"`,
438- "Content-Length": String(content.length),
459+ "Content-Length": String(total),
460+ "Accept-Ranges": "bytes",
439461 },
440462 });
441463 })
@@ -513,6 +535,7 @@ export const repoRoutes = new Elysia()
513535 const files = await prepareDiff(
514536 rawDiff,
515537 `commit:${repo.name}:${params.sha}`,
538+ repo.name,
516539 );
517540 return html(
518541 <CommitDetail
Msrc/services/diffHighlight.ts
@@ -1,6 +1,7 @@
11 import path from "node:path";
22 import type { BundledLanguage } from "shiki";
3-import { MAX_DIFF_CACHE } from "../constants.ts";
3+import { INLINE_MAX_BYTES } from "../config.ts";
4+import { git } from "./git.ts";
45 import { detectLang, getHighlighter } from "./highlight.ts";
56
67 // ─── Types ───────────────────────────────────────────────────────────────────
@@ -31,6 +32,8 @@ export interface RenderedDiffFile {
3132 added: number;
3233 removed: number;
3334 isBinary: boolean;
35+ /** Byte sizes for binary files. Populated from GIT binary patch literals or blob lookups. */
36+ binarySize?: { before: number; after: number };
3437 hunks: RenderedHunk[];
3538 }
3639
@@ -55,10 +58,15 @@ export interface ParsedFile {
5558 added: number;
5659 removed: number;
5760 isBinary: boolean;
61+ binaryNewSize?: number;
62+ binaryOldSize?: number;
5863 hunks: ParsedHunk[];
5964 }
6065
61-export function parseDiff(raw: string): ParsedFile[] {
66+export async function parseDiff(
67+ raw: string,
68+ repoName?: string,
69+): Promise<ParsedFile[]> {
6270 const files: ParsedFile[] = [];
6371 const allLines = raw.split("\n");
6472 let i = 0;
@@ -82,6 +90,9 @@ export function parseDiff(raw: string): ParsedFile[] {
8290 };
8391 i++;
8492
93+ let oldBlob = "";
94+ let newBlob = "";
95+
8596 while (i < allLines.length) {
8697 const line = allLines[i]!;
8798 if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break;
@@ -101,7 +112,30 @@ export function parseDiff(raw: string): ParsedFile[] {
101112 file.oldPath = line.slice(6);
102113 else if (line.startsWith("+++ ") && line !== "+++ /dev/null")
103114 file.newPath = line.slice(6);
104- else if (line.startsWith("Binary files ")) file.isBinary = true;
115+ else if (line.startsWith("index ")) {
116+ const idxm = line.match(/^index ([0-9a-f]+)\.\.([0-9a-f]+)/i);
117+ if (idxm) {
118+ oldBlob = idxm[1]!;
119+ newBlob = idxm[2]!;
120+ }
121+ } else if (line.startsWith("Binary files ")) {
122+ file.isBinary = true;
123+ if (repoName) {
124+ const [oldSize, newSize] = await Promise.all([
125+ git.blobSize(repoName, oldBlob),
126+ git.blobSize(repoName, newBlob),
127+ ]);
128+ file.binaryOldSize = oldSize;
129+ file.binaryNewSize = newSize;
130+ }
131+ } else if (line === "GIT binary patch") {
132+ file.isBinary = true;
133+ } else if (file.isBinary && line.startsWith("literal ")) {
134+ const size = parseInt(line.slice(8), 10);
135+ if (file.binaryNewSize === undefined) file.binaryNewSize = size;
136+ else if (file.binaryOldSize === undefined)
137+ file.binaryOldSize = size;
138+ }
105139 i++;
106140 }
107141
@@ -139,6 +173,7 @@ export function parseDiff(raw: string): ParsedFile[] {
139173
140174 files.push(file);
141175 }
176+
142177 return files;
143178 }
144179
@@ -207,13 +242,29 @@ export async function highlightFile(
207242 ): Promise<RenderedDiffFile> {
208243 const displayPath = file.newPath || file.oldPath;
209244 const lang = detectLang(path.basename(displayPath));
210- const highlightedHunks = await Promise.all(
211- file.hunks.map((hunk) => highlightHunk(hunk, lang)),
245+ const totalBytes = file.hunks.reduce(
246+ (sum, h) => sum + h.lines.reduce((s, l) => s + l.content.length, 0),
247+ 0,
212248 );
249+ const highlightedHunks =
250+ totalBytes > INLINE_MAX_BYTES
251+ ? file.hunks.map((hunk) =>
252+ hunk.lines.map((l) => escapeHtml(l.content)),
253+ )
254+ : await Promise.all(
255+ file.hunks.map((hunk) => highlightHunk(hunk, lang)),
256+ );
213257 const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
214258 header: hunk.header,
215259 rows: buildRows(hunk, highlightedHunks[i]!),
216260 }));
261+ let binarySize: RenderedDiffFile["binarySize"];
262+ if (file.isBinary && file.binaryNewSize !== undefined) {
263+ binarySize = {
264+ after: file.binaryNewSize,
265+ before: file.binaryOldSize ?? 0,
266+ };
267+ }
217268 return {
218269 oldPath: file.oldPath,
219270 newPath: file.newPath,
@@ -221,48 +272,7 @@ export async function highlightFile(
221272 added: file.added,
222273 removed: file.removed,
223274 isBinary: file.isBinary,
275+ binarySize,
224276 hunks,
225277 };
226278 }
227-
228-// ─── Public API ──────────────────────────────────────────────────────────────
229-
230-const diffCache = new Map<string, RenderedDiffFile[]>();
231-
232-export async function prepareDiff(
233- rawDiff: string,
234- cacheKey: string,
235-): Promise<RenderedDiffFile[]> {
236- const cached = diffCache.get(cacheKey);
237- if (cached) return cached;
238-
239- const parsed = parseDiff(rawDiff);
240- const result = await Promise.all(
241- parsed.map(async (file) => {
242- const displayPath = file.newPath || file.oldPath;
243- const lang = detectLang(path.basename(displayPath));
244- const highlightedHunks = await Promise.all(
245- file.hunks.map((hunk) => highlightHunk(hunk, lang)),
246- );
247- const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
248- header: hunk.header,
249- rows: buildRows(hunk, highlightedHunks[i]!),
250- }));
251- return {
252- oldPath: file.oldPath,
253- newPath: file.newPath,
254- status: file.status,
255- added: file.added,
256- removed: file.removed,
257- isBinary: file.isBinary,
258- hunks,
259- };
260- }),
261- );
262- if (cacheKey) {
263- if (diffCache.size >= MAX_DIFF_CACHE)
264- diffCache.delete(diffCache.keys().next().value!);
265- diffCache.set(cacheKey, result);
266- }
267- return result;
268-}
Msrc/services/git.ts
@@ -256,6 +256,17 @@ export const git = {
256256 }
257257 },
258258
259+ async blobSize(name: string, hash: string): Promise<number> {
260+ if (/^0+$/.test(hash)) return 0;
261+ const p = repoPath(name);
262+ try {
263+ const out = await $`git -C ${p} cat-file -s ${hash}`.text();
264+ return parseInt(out.trim(), 10) || 0;
265+ } catch {
266+ return 0;
267+ }
268+ },
269+
259270 async branches(name: string): Promise<string[]> {
260271 const p = repoPath(name);
261272 try {
Msrc/services/highlight.ts
@@ -1,3 +1,4 @@
1+import { fileTypeFromBuffer } from "file-type";
12 import type { Language } from "linguist-languages";
23 import * as linguistLangs from "linguist-languages";
34 import {
@@ -92,7 +93,8 @@ export function detectLang(filename: string): string {
9293 export type FileView =
9394 | { type: "inline"; html: string; lines: number }
9495 | { type: "download"; size: number }
95- | { type: "binary"; size: number };
96+ | { type: "binary"; size: number }
97+ | { type: "media"; mimeType: string; size: number };
9698
9799 const fileCache = new Map<string, FileView>();
98100
@@ -156,6 +158,18 @@ export async function serveFile(
156158 const cached = fileCache.get(cacheKey);
157159 if (cached) return cached;
158160
161+ const fileType = await fileTypeFromBuffer(content);
162+ if (fileType) {
163+ const group = fileType.mime.split("/")[0];
164+ if (group === "image" || group === "audio" || group === "video") {
165+ return {
166+ type: "media",
167+ mimeType: fileType.mime,
168+ size: content.length,
169+ };
170+ }
171+ }
172+
159173 const isBinary = hasBinaryContent(content);
160174 if (isBinary) {
161175 return { type: "binary", size: content.length };
Msrc/services/highlightWorker.ts
@@ -102,11 +102,12 @@ export function serveFile(
102102 export async function prepareDiff(
103103 rawDiff: string,
104104 cacheKey: string,
105+ repoName?: string,
105106 ): Promise<RenderedDiffFile[]> {
106107 const cached = diffCache.get(cacheKey);
107108 if (cached) return cached;
108109
109- const parsed = parseDiff(rawDiff);
110+ const parsed = await parseDiff(rawDiff, repoName);
110111 const result = await Promise.all(
111112 parsed.map((file: ParsedFile) =>
112113 request<RenderedDiffFile>({ type: "highlightFile", file }),
Msrc/services/repoSync.ts
@@ -88,9 +88,8 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
8888 .selectAll()
8989 .where("name", "=", name)
9090 .executeTakeFirst();
91- if (existing) return existing;
92-
9391 await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
92+ if (existing) return existing;
9493
9594 const branch = await git.defaultBranch(name);
9695 const now = new Date().toISOString();
Msrc/styles/main.css
@@ -911,6 +911,25 @@
911911 .file-download-notice p {
912912 margin-bottom: var(--space-4);
913913 }
914+ .file-media {
915+ padding: var(--space-4);
916+ display: flex;
917+ justify-content: center;
918+ background: var(--color-canvas-subtle);
919+ }
920+ .file-media-img {
921+ max-width: 100%;
922+ height: auto;
923+ display: block;
924+ }
925+ .file-media-audio {
926+ width: 100%;
927+ max-width: 600px;
928+ }
929+ .file-media-video {
930+ max-width: 100%;
931+ max-height: 80vh;
932+ }
914933
915934 /* --- Commit list --- */
916935 .commit-list {
@@ -1155,6 +1174,10 @@
11551174 .nav-del {
11561175 color: var(--color-danger);
11571176 }
1177+ .nav-binary {
1178+ color: var(--color-muted);
1179+ font-size: var(--text-xs);
1180+ }
11581181 .file-nav-dir > details > .file-nav-list {
11591182 padding-left: var(--space-4);
11601183 }
@@ -1279,6 +1302,11 @@
12791302 font-family: var(--font-mono);
12801303 font-weight: 600;
12811304 }
1305+ .diff-stat-binary {
1306+ color: var(--color-muted);
1307+ font-size: var(--text-sm);
1308+ font-family: var(--font-mono);
1309+ }
12821310 .btn-xs {
12831311 padding: 1px var(--space-2);
12841312 font-size: var(--text-xs);
@@ -1521,7 +1549,6 @@
15211549 align-items: center;
15221550 gap: var(--space-3);
15231551 margin-bottom: var(--space-6);
1524- flex-wrap: wrap;
15251552 }
15261553 .issue-number {
15271554 font-size: var(--text-2xl);
@@ -1532,7 +1559,7 @@
15321559 .issue-detail-title {
15331560 font-size: var(--text-2xl);
15341561 font-weight: 600;
1535- flex: 1;
1562+ overflow-wrap: anywhere;
15361563 }
15371564 .issue-detail-meta-actions {
15381565 margin-left: auto;
@@ -1552,13 +1579,14 @@
15521579 display: flex;
15531580 align-items: center;
15541581 justify-content: space-between;
1555- flex-wrap: wrap;
15561582 gap: var(--space-3);
15571583 border-bottom: 1px solid var(--color-border);
1584+ overflow-wrap: anywhere;
15581585 }
15591586 .timeline-author time {
15601587 color: var(--color-text-muted);
15611588 font-size: var(--text-xs);
1589+ width: max-content;
15621590 }
15631591 .timeline-author-right {
15641592 margin-left: auto;
@@ -1653,6 +1681,73 @@
16531681 .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area {
16541682 display: block;
16551683 }
1684+ .timeline-item:has(.inline-edit-details[open]) .timeline-body {
1685+ display: none;
1686+ }
1687+
1688+ /* Title edit in issue/patch header */
1689+ .issue-detail-header {
1690+ align-items: flex-start;
1691+ }
1692+ .title-with-edit {
1693+ flex: 1;
1694+ min-width: 0;
1695+ display: flex;
1696+ flex-direction: column;
1697+ gap: var(--space-1);
1698+ }
1699+ .title-edit-details {
1700+ border: none;
1701+ align-self: flex-start;
1702+ }
1703+ .title-with-edit:has(.title-edit-details[open]) .issue-detail-title {
1704+ display: none;
1705+ }
1706+ .title-with-edit:has(.title-edit-details[open]) > .title-edit-details {
1707+ display: none;
1708+ }
1709+ .title-edit-details > summary {
1710+ padding: 0;
1711+ list-style: none;
1712+ }
1713+ .title-edit-details > summary::-webkit-details-marker {
1714+ display: none;
1715+ }
1716+ .title-edit-form-area {
1717+ display: none;
1718+ }
1719+ .title-with-edit:has(.title-edit-details[open]) .title-edit-form-area {
1720+ display: flex;
1721+ flex: 1;
1722+ min-width: 0;
1723+ }
1724+ .title-edit-form {
1725+ display: flex;
1726+ flex-direction: column;
1727+ gap: var(--space-2);
1728+ flex: 1;
1729+ min-width: 0;
1730+ }
1731+ .title-edit-form .form-input {
1732+ min-width: 0;
1733+ width: 100%;
1734+ padding: var(--space-2) var(--space-3);
1735+ border: 1px solid var(--color-border);
1736+ border-radius: var(--radius-md);
1737+ background: var(--color-bg);
1738+ color: var(--color-text);
1739+ font-size: var(--text-sm);
1740+ }
1741+ .title-edit-form .form-input:focus {
1742+ outline: 2px solid var(--color-accent);
1743+ outline-offset: -1px;
1744+ border-color: var(--color-accent);
1745+ }
1746+ .title-edit-actions {
1747+ display: flex;
1748+ gap: var(--space-2);
1749+ }
1750+
16561751 .inline-edit-form {
16571752 padding: var(--space-4);
16581753 display: flex;
Msrc/views/DiffView.tsx
@@ -76,15 +76,41 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
7676 </span>
7777 <span class="file-nav-name">{name}</span>
7878 <span class="file-nav-stat">
79- {node.file.added > 0 && (
80- <span class="nav-add">
81- +{node.file.added}
82- </span>
83- )}
84- {node.file.removed > 0 && (
85- <span class="nav-del">
86- -{node.file.removed}
79+ {node.file.isBinary ? (
80+ <span class="nav-binary">
81+ {node.file.binarySize ? (
82+ <>
83+ <span class="nav-del">
84+ {
85+ node.file.binarySize
86+ .before
87+ }
88+ </span>
89+ {" \u2192 "}
90+ <span class="nav-add">
91+ {
92+ node.file.binarySize
93+ .after
94+ }
95+ </span>
96+ </>
97+ ) : (
98+ "Bin"
99+ )}
87100 </span>
101+ ) : (
102+ <>
103+ {node.file.added > 0 && (
104+ <span class="nav-add">
105+ +{node.file.added}
106+ </span>
107+ )}
108+ {node.file.removed > 0 && (
109+ <span class="nav-del">
110+ -{node.file.removed}
111+ </span>
112+ )}
113+ </>
88114 )}
89115 </span>
90116 </a>
@@ -173,44 +199,64 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
173199 )}
174200 </div>
175201 <div class="diff-file-header-right">
176- {f.added > 0 && (
177- <span class="diff-stat-add">
178- +{f.added}
202+ {f.isBinary ? (
203+ <span class="diff-stat-binary">
204+ {f.binarySize ? (
205+ <>
206+ {"Bin "}
207+ <span class="diff-stat-del">
208+ {
209+ f.binarySize
210+ .before
211+ }
212+ </span>
213+ {" \u2192 "}
214+ <span class="diff-stat-add">
215+ {
216+ f.binarySize
217+ .after
218+ }
219+ </span>
220+ {" bytes"}
221+ </>
222+ ) : (
223+ "Binary"
224+ )}
179225 </span>
226+ ) : (
227+ <>
228+ {f.added > 0 && (
229+ <span class="diff-stat-add">
230+ +{f.added}
231+ </span>
232+ )}
233+ {f.removed > 0 && (
234+ <span class="diff-stat-del">
235+ -{f.removed}
236+ </span>
237+ )}
238+ </>
180239 )}
181- {f.removed > 0 && (
182- <span class="diff-stat-del">
183- -{f.removed}
184- </span>
185- )}
186- {!f.isBinary &&
187- f.status !== "deleted" && (
188- <>
189- {sha && (
190- <a
191- href={`/${repo.name}/blob/${sha}/${displayPath}`}
192- class="btn btn-xs btn-secondary"
193- title={`View file at ${sha.slice(0, 7)}`}
194- >
195- @{" "}
196- {sha.slice(
197- 0,
198- 7,
199- )}
200- </a>
201- )}
240+ {f.status !== "deleted" && (
241+ <>
242+ {sha && (
202243 <a
203- href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
244+ href={`/${repo.name}/blob/${sha}/${displayPath}`}
204245 class="btn btn-xs btn-secondary"
205- title={`View file at ${repo.default_branch}`}
246+ title={`View file at ${sha.slice(0, 7)}`}
206247 >
207- @{" "}
208- {
209- repo.default_branch
210- }
248+ @ {sha.slice(0, 7)}
211249 </a>
212- </>
213- )}
250+ )}
251+ <a
252+ href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
253+ class="btn btn-xs btn-secondary"
254+ title={`View file at ${repo.default_branch}`}
255+ >
256+ @ {repo.default_branch}
257+ </a>
258+ </>
259+ )}
214260 </div>
215261 </summary>
216262
Msrc/views/issues/IssueDetail.tsx
@@ -39,9 +39,12 @@ export function IssueDetail({
3939 commentReactions,
4040 }: IssueDetailProps) {
4141 const canEditIssue =
42- user != null && (user.isAdmin || user.id === issue.author_id);
42+ user != null &&
43+ (user.isAdmin ||
44+ (user.id === issue.author_id && issue.status === "open"));
4345 const canEditComment = (c: IssueCommentRow) =>
44- user != null && (user.isAdmin || user.id === c.author_id);
46+ user != null &&
47+ (user.isAdmin || (user.id === c.author_id && issue.status === "open"));
4548 return (
4649 <Layout user={user} title={`${issue.title} — ${repo.name}`}>
4750 <div class="container">
@@ -50,7 +53,52 @@ export function IssueDetail({
5053 <div class="issue-detail">
5154 <div class="issue-detail-header">
5255 <span class="issue-number">#{issue.number}</span>
53- <h2 class="issue-detail-title">{issue.title}</h2>
56+ <div class="title-with-edit">
57+ <h2 class="issue-detail-title">{issue.title}</h2>
58+ {canEditIssue && (
59+ <>
60+ <div class="title-edit-form-area">
61+ <form
62+ method="POST"
63+ action={`/${repo.name}/issues/${issue.number}/edit`}
64+ class="title-edit-form"
65+ >
66+ <input
67+ class="form-input"
68+ name="title"
69+ value={issue.title}
70+ required
71+ />
72+ <input
73+ type="hidden"
74+ name="edit_body"
75+ value={issue.body}
76+ />
77+ <div class="title-edit-actions">
78+ <button
79+ type="submit"
80+ class="btn btn-sm btn-primary"
81+ >
82+ Save
83+ </button>
84+ <button
85+ type="button"
86+ class="btn btn-sm"
87+ onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
88+ >
89+ Cancel
90+ </button>
91+ </div>
92+ </form>
93+ </div>
94+ <details class="title-edit-details">
95+ <summary class="btn btn-xs btn-ghost">
96+ Edit title
97+ </summary>
98+ </details>
99+ </>
100+ )}
101+ </div>
54102 <span class={`issue-badge ${issue.status}`}>
55103 {issue.status}
56104 </span>
@@ -146,28 +194,12 @@ export function IssueDetail({
146194 action={`/${repo.name}/issues/${issue.number}/edit`}
147195 class="inline-edit-form"
148196 >
197+ <input
198+ type="hidden"
199+ name="title"
200+ value={issue.title}
201+ />
149202 <div class="form-group">
150- <label
151- class="form-label"
152- for="edit-issue-title"
153- >
154- Title
155- </label>
156- <input
157- class="form-input"
158- id="edit-issue-title"
159- name="title"
160- value={issue.title}
161- required
162- />
163- </div>
164- <div class="form-group">
165- <label
166- class="form-label"
167- for="edit-issue-body"
168- >
169- Description
170- </label>
171203 <textarea
172204 class="form-input"
173205 id="edit-issue-body"
Msrc/views/issues/IssueList.tsx
@@ -10,7 +10,10 @@ import { RepoNav } from "../repos/RepoNav.tsx";
1010 interface IssueListProps {
1111 user: SessionUser | null;
1212 repo: RepositoryRow;
13- issues: (IssueRow & { author_username: string; author_avatar_version: number | null })[];
13+ issues: (IssueRow & {
14+ author_username: string;
15+ author_avatar_version: number | null;
16+ })[];
1417 status: "open" | "closed" | "completed";
1518 counts: Record<string, number>;
1619 pagination: PaginationInfo;
Msrc/views/patches/PatchDetail.tsx
@@ -48,9 +48,12 @@ export function PatchDetail({
4848 commentReactions,
4949 }: PatchDetailProps) {
5050 const canEdit =
51- user != null && (user.isAdmin || user.id === patch.author_id);
51+ user != null &&
52+ (user.isAdmin ||
53+ (user.id === patch.author_id && patch.status === "open"));
5254 const canEditComment = (c: PatchCommentRow) =>
53- user != null && (user.isAdmin || user.id === c.author_id);
55+ user != null &&
56+ (user.isAdmin || (user.id === c.author_id && patch.status === "open"));
5457
5558 const baseUrl = `/${repo.name}/patches/${patch.number}`;
5659 const reactUrl = `${baseUrl}/react`;
@@ -64,7 +67,52 @@ export function PatchDetail({
6467 <div class="issue-detail">
6568 <div class="issue-detail-header">
6669 <span class="issue-number">#{patch.number}</span>
67- <h2 class="issue-detail-title">{patch.title}</h2>
70+ <div class="title-with-edit">
71+ <h2 class="issue-detail-title">{patch.title}</h2>
72+ {canEdit && (
73+ <>
74+ <div class="title-edit-form-area">
75+ <form
76+ method="POST"
77+ action={`${baseUrl}/edit`}
78+ class="title-edit-form"
79+ >
80+ <input
81+ class="form-input"
82+ name="title"
83+ value={patch.title}
84+ required
85+ />
86+ <input
87+ type="hidden"
88+ name="edit_description"
89+ value={patch.description}
90+ />
91+ <div class="title-edit-actions">
92+ <button
93+ type="submit"
94+ class="btn btn-sm btn-primary"
95+ >
96+ Save
97+ </button>
98+ <button
99+ type="button"
100+ class="btn btn-sm"
101+ onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
102+ >
103+ Cancel
104+ </button>
105+ </div>
106+ </form>
107+ </div>
108+ <details class="title-edit-details">
109+ <summary class="btn btn-xs btn-ghost">
110+ Edit title
111+ </summary>
112+ </details>
113+ </>
114+ )}
115+ </div>
68116 <span class={`patch-badge ${patch.status}`}>
69117 {patch.status}
70118 </span>
@@ -188,28 +236,12 @@ export function PatchDetail({
188236 action={`${baseUrl}/edit`}
189237 class="inline-edit-form"
190238 >
239+ <input
240+ type="hidden"
241+ name="title"
242+ value={patch.title}
243+ />
191244 <div class="form-group">
192- <label
193- class="form-label"
194- for="edit-patch-title"
195- >
196- Title
197- </label>
198- <input
199- class="form-input"
200- id="edit-patch-title"
201- name="title"
202- value={patch.title}
203- required
204- />
205- </div>
206- <div class="form-group">
207- <label
208- class="form-label"
209- for="edit-patch-desc"
210- >
211- Description
212- </label>
213245 <textarea
214246 class="form-input"
215247 id="edit-patch-desc"
Msrc/views/patches/PatchList.tsx
@@ -10,7 +10,10 @@ import { RepoNav } from "../repos/RepoNav.tsx";
1010 interface PatchListProps {
1111 user: SessionUser | null;
1212 repo: RepositoryRow;
13- patches: (PatchRow & { author_username: string; author_avatar_version: number | null })[];
13+ patches: (PatchRow & {
14+ author_username: string;
15+ author_avatar_version: number | null;
16+ })[];
1417 status: string;
1518 counts: Record<string, number>;
1619 pagination: PaginationInfo;
Msrc/views/repos/FileBlob.tsx
@@ -78,6 +78,30 @@ export function FileBlob({
7878 <div class="file-blob-body">
7979 {view.type === "inline" ? (
8080 <div class="shiki-wrapper">{view.html}</div>
81+ ) : view.type === "media" ? (
82+ <div class="file-media">
83+ {view.mimeType.startsWith("image/") ? (
84+ <img
85+ src={`/${repo.name}/raw/${blobRef}/${filePath}`}
86+ alt={filename}
87+ class="file-media-img"
88+ />
89+ ) : view.mimeType.startsWith("audio/") ? (
90+ // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
91+ <audio
92+ controls
93+ src={`/${repo.name}/raw/${blobRef}/${filePath}`}
94+ class="file-media-audio"
95+ />
96+ ) : (
97+ // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
98+ <video
99+ controls
100+ src={`/${repo.name}/raw/${blobRef}/${filePath}`}
101+ class="file-media-video"
102+ />
103+ )}
104+ </div>
81105 ) : view.type === "binary" ? (
82106 <div class="file-download-notice">
83107 <p>Binary file ({formatSize(view.size)})</p>