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>
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() | |||
|---|---|---|---|
| 495 | 495 | const issueNum = parseInt(params.number, 10); | |
| 496 | 496 | const issue = await db | |
| 497 | 497 | .selectFrom("issues") | |
| 498 | - | .select(["id", "author_id"]) | |
| 498 | + | .select(["id", "author_id", "status"]) | |
| 499 | 499 | .where("repo_id", "=", repo.id) | |
| 500 | 500 | .where("number", "=", issueNum) | |
| 501 | 501 | .executeTakeFirst(); | |
| 502 | 502 | if (!issue) return new Response("Not found", { status: 404 }); | |
| 503 | 503 | if (issue.author_id !== user?.id && !user?.isAdmin) | |
| 504 | 504 | return new Response("Forbidden", { status: 403 }); | |
| 505 | + | if (issue.status !== "open" && !user?.isAdmin) | |
| 506 | + | return new Response("Forbidden", { status: 403 }); | |
| 505 | 507 | ||
| 506 | 508 | await db | |
| 507 | 509 | .updateTable("issues") | |
| @@ -544,6 +546,13 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 544 | 546 | if (!comment) return new Response("Not found", { status: 404 }); | |
| 545 | 547 | if (comment.author_id !== user?.id && !user?.isAdmin) | |
| 546 | 548 | 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 }); | |
| 547 | 556 | ||
| 548 | 557 | const issueNum = parseInt(params.number, 10); | |
| 549 | 558 | await db | |
Msrc/routes/patches.tsx
| @@ -623,12 +623,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 623 | 623 | ||
| 624 | 624 | const comment = await db | |
| 625 | 625 | .selectFrom("patch_comments") | |
| 626 | - | .select(["id", "author_id"]) | |
| 626 | + | .select(["id", "author_id", "patch_id"]) | |
| 627 | 627 | .where("id", "=", params.id) | |
| 628 | 628 | .executeTakeFirst(); | |
| 629 | 629 | if (!comment) return new Response("Not found", { status: 404 }); | |
| 630 | 630 | if (comment.author_id !== user?.id && !user?.isAdmin) | |
| 631 | 631 | 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 }); | |
| 632 | 639 | ||
| 633 | 640 | const patchNum = parseInt(params.number, 10); | |
| 634 | 641 | await db | |
| @@ -667,13 +674,15 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 667 | 674 | const patchNum = parseInt(params.number, 10); | |
| 668 | 675 | const patch = await db | |
| 669 | 676 | .selectFrom("patches") | |
| 670 | - | .select(["id", "author_id"]) | |
| 677 | + | .select(["id", "author_id", "status"]) | |
| 671 | 678 | .where("repo_id", "=", repo.id) | |
| 672 | 679 | .where("number", "=", patchNum) | |
| 673 | 680 | .executeTakeFirst(); | |
| 674 | 681 | if (!patch) return new Response("Not found", { status: 404 }); | |
| 675 | 682 | if (patch.author_id !== user?.id && !user?.isAdmin) | |
| 676 | 683 | return new Response("Forbidden", { status: 403 }); | |
| 684 | + | if (patch.status !== "open" && !user?.isAdmin) | |
| 685 | + | return new Response("Forbidden", { status: 403 }); | |
| 677 | 686 | ||
| 678 | 687 | await db | |
| 679 | 688 | .updateTable("patches") | |
Msrc/routes/repos.tsx
| @@ -354,7 +354,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 354 | 354 | const resolved = await git.resolveRef(repo.name, params.ref); | |
| 355 | 355 | if (!resolved) return new Response("Not found", { status: 404 }); | |
| 356 | 356 | ||
| 357 | - | const subpath = params["*"]; | |
| 357 | + | const subpath = decodeURIComponent(params["*"]); | |
| 358 | 358 | const [entries, branches] = await Promise.all([ | |
| 359 | 359 | git.lsTree(repo.name, params.ref, subpath), | |
| 360 | 360 | git.branches(repo.name), | |
| @@ -393,7 +393,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 393 | 393 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 394 | 394 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 395 | 395 | ||
| 396 | - | const filePath = params["*"]; | |
| 396 | + | const filePath = decodeURIComponent(params["*"]); | |
| 397 | 397 | const [content, branches, commitSHA] = await Promise.all([ | |
| 398 | 398 | git.show(repo.name, params.ref, filePath), | |
| 399 | 399 | git.branches(repo.name), | |
| @@ -420,22 +420,44 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 420 | 420 | ); | |
| 421 | 421 | }) | |
| 422 | 422 | ||
| 423 | - | .get("/:repo/raw/:ref/*", async ({ params, cookie }) => { | |
| 423 | + | .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => { | |
| 424 | 424 | const user = await resolveSession(cookie.session.value); | |
| 425 | 425 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 426 | 426 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 427 | 427 | ||
| 428 | - | const filePath = params["*"]; | |
| 428 | + | const filePath = decodeURIComponent(params["*"]); | |
| 429 | 429 | const content = await git.show(repo.name, params.ref, filePath); | |
| 430 | 430 | if (!content) return new Response("Not found", { status: 404 }); | |
| 431 | 431 | ||
| 432 | 432 | const filename = path.basename(filePath); | |
| 433 | 433 | 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 | + | ||
| 434 | 455 | return new Response(content, { | |
| 435 | 456 | headers: { | |
| 436 | 457 | "Content-Type": contentType, | |
| 437 | 458 | "Content-Disposition": `inline; filename="${filename}"`, | |
| 438 | - | "Content-Length": String(content.length), | |
| 459 | + | "Content-Length": String(total), | |
| 460 | + | "Accept-Ranges": "bytes", | |
| 439 | 461 | }, | |
| 440 | 462 | }); | |
| 441 | 463 | }) | |
| @@ -513,6 +535,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 513 | 535 | const files = await prepareDiff( | |
| 514 | 536 | rawDiff, | |
| 515 | 537 | `commit:${repo.name}:${params.sha}`, | |
| 538 | + | repo.name, | |
| 516 | 539 | ); | |
| 517 | 540 | return html( | |
| 518 | 541 | <CommitDetail | |
Msrc/services/diffHighlight.ts
| @@ -1,6 +1,7 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | 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"; | |
| 4 | 5 | import { detectLang, getHighlighter } from "./highlight.ts"; | |
| 5 | 6 | ||
| 6 | 7 | // ─── Types ─────────────────────────────────────────────────────────────────── | |
| @@ -31,6 +32,8 @@ export interface RenderedDiffFile { | |||
|---|---|---|---|
| 31 | 32 | added: number; | |
| 32 | 33 | removed: number; | |
| 33 | 34 | isBinary: boolean; | |
| 35 | + | /** Byte sizes for binary files. Populated from GIT binary patch literals or blob lookups. */ | |
| 36 | + | binarySize?: { before: number; after: number }; | |
| 34 | 37 | hunks: RenderedHunk[]; | |
| 35 | 38 | } | |
| 36 | 39 | ||
| @@ -55,10 +58,15 @@ export interface ParsedFile { | |||
|---|---|---|---|
| 55 | 58 | added: number; | |
| 56 | 59 | removed: number; | |
| 57 | 60 | isBinary: boolean; | |
| 61 | + | binaryNewSize?: number; | |
| 62 | + | binaryOldSize?: number; | |
| 58 | 63 | hunks: ParsedHunk[]; | |
| 59 | 64 | } | |
| 60 | 65 | ||
| 61 | - | export function parseDiff(raw: string): ParsedFile[] { | |
| 66 | + | export async function parseDiff( | |
| 67 | + | raw: string, | |
| 68 | + | repoName?: string, | |
| 69 | + | ): Promise<ParsedFile[]> { | |
| 62 | 70 | const files: ParsedFile[] = []; | |
| 63 | 71 | const allLines = raw.split("\n"); | |
| 64 | 72 | let i = 0; | |
| @@ -82,6 +90,9 @@ export function parseDiff(raw: string): ParsedFile[] { | |||
|---|---|---|---|
| 82 | 90 | }; | |
| 83 | 91 | i++; | |
| 84 | 92 | ||
| 93 | + | let oldBlob = ""; | |
| 94 | + | let newBlob = ""; | |
| 95 | + | ||
| 85 | 96 | while (i < allLines.length) { | |
| 86 | 97 | const line = allLines[i]!; | |
| 87 | 98 | if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break; | |
| @@ -101,7 +112,30 @@ export function parseDiff(raw: string): ParsedFile[] { | |||
|---|---|---|---|
| 101 | 112 | file.oldPath = line.slice(6); | |
| 102 | 113 | else if (line.startsWith("+++ ") && line !== "+++ /dev/null") | |
| 103 | 114 | 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 | + | } | |
| 105 | 139 | i++; | |
| 106 | 140 | } | |
| 107 | 141 | ||
| @@ -139,6 +173,7 @@ export function parseDiff(raw: string): ParsedFile[] { | |||
|---|---|---|---|
| 139 | 173 | ||
| 140 | 174 | files.push(file); | |
| 141 | 175 | } | |
| 176 | + | ||
| 142 | 177 | return files; | |
| 143 | 178 | } | |
| 144 | 179 | ||
| @@ -207,13 +242,29 @@ export async function highlightFile( | |||
|---|---|---|---|
| 207 | 242 | ): Promise<RenderedDiffFile> { | |
| 208 | 243 | const displayPath = file.newPath || file.oldPath; | |
| 209 | 244 | 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, | |
| 212 | 248 | ); | |
| 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 | + | ); | |
| 213 | 257 | const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({ | |
| 214 | 258 | header: hunk.header, | |
| 215 | 259 | rows: buildRows(hunk, highlightedHunks[i]!), | |
| 216 | 260 | })); | |
| 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 | + | } | |
| 217 | 268 | return { | |
| 218 | 269 | oldPath: file.oldPath, | |
| 219 | 270 | newPath: file.newPath, | |
| @@ -221,48 +272,7 @@ export async function highlightFile( | |||
|---|---|---|---|
| 221 | 272 | added: file.added, | |
| 222 | 273 | removed: file.removed, | |
| 223 | 274 | isBinary: file.isBinary, | |
| 275 | + | binarySize, | |
| 224 | 276 | hunks, | |
| 225 | 277 | }; | |
| 226 | 278 | } | |
| 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 = { | |||
|---|---|---|---|
| 256 | 256 | } | |
| 257 | 257 | }, | |
| 258 | 258 | ||
| 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 | + | ||
| 259 | 270 | async branches(name: string): Promise<string[]> { | |
| 260 | 271 | const p = repoPath(name); | |
| 261 | 272 | try { | |
Msrc/services/highlight.ts
| @@ -1,3 +1,4 @@ | |||
|---|---|---|---|
| 1 | + | import { fileTypeFromBuffer } from "file-type"; | |
| 1 | 2 | import type { Language } from "linguist-languages"; | |
| 2 | 3 | import * as linguistLangs from "linguist-languages"; | |
| 3 | 4 | import { | |
| @@ -92,7 +93,8 @@ export function detectLang(filename: string): string { | |||
|---|---|---|---|
| 92 | 93 | export type FileView = | |
| 93 | 94 | | { type: "inline"; html: string; lines: number } | |
| 94 | 95 | | { type: "download"; size: number } | |
| 95 | - | | { type: "binary"; size: number }; | |
| 96 | + | | { type: "binary"; size: number } | |
| 97 | + | | { type: "media"; mimeType: string; size: number }; | |
| 96 | 98 | ||
| 97 | 99 | const fileCache = new Map<string, FileView>(); | |
| 98 | 100 | ||
| @@ -156,6 +158,18 @@ export async function serveFile( | |||
|---|---|---|---|
| 156 | 158 | const cached = fileCache.get(cacheKey); | |
| 157 | 159 | if (cached) return cached; | |
| 158 | 160 | ||
| 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 | + | ||
| 159 | 173 | const isBinary = hasBinaryContent(content); | |
| 160 | 174 | if (isBinary) { | |
| 161 | 175 | return { type: "binary", size: content.length }; | |
Msrc/services/highlightWorker.ts
| @@ -102,11 +102,12 @@ export function serveFile( | |||
|---|---|---|---|
| 102 | 102 | export async function prepareDiff( | |
| 103 | 103 | rawDiff: string, | |
| 104 | 104 | cacheKey: string, | |
| 105 | + | repoName?: string, | |
| 105 | 106 | ): Promise<RenderedDiffFile[]> { | |
| 106 | 107 | const cached = diffCache.get(cacheKey); | |
| 107 | 108 | if (cached) return cached; | |
| 108 | 109 | ||
| 109 | - | const parsed = parseDiff(rawDiff); | |
| 110 | + | const parsed = await parseDiff(rawDiff, repoName); | |
| 110 | 111 | const result = await Promise.all( | |
| 111 | 112 | parsed.map((file: ParsedFile) => | |
| 112 | 113 | request<RenderedDiffFile>({ type: "highlightFile", file }), | |
Msrc/services/repoSync.ts
| @@ -88,9 +88,8 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> { | |||
|---|---|---|---|
| 88 | 88 | .selectAll() | |
| 89 | 89 | .where("name", "=", name) | |
| 90 | 90 | .executeTakeFirst(); | |
| 91 | - | if (existing) return existing; | |
| 92 | - | ||
| 93 | 91 | await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`; | |
| 92 | + | if (existing) return existing; | |
| 94 | 93 | ||
| 95 | 94 | const branch = await git.defaultBranch(name); | |
| 96 | 95 | const now = new Date().toISOString(); | |
Msrc/styles/main.css
| @@ -911,6 +911,25 @@ | |||
|---|---|---|---|
| 911 | 911 | .file-download-notice p { | |
| 912 | 912 | margin-bottom: var(--space-4); | |
| 913 | 913 | } | |
| 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 | + | } | |
| 914 | 933 | ||
| 915 | 934 | /* --- Commit list --- */ | |
| 916 | 935 | .commit-list { | |
| @@ -1155,6 +1174,10 @@ | |||
|---|---|---|---|
| 1155 | 1174 | .nav-del { | |
| 1156 | 1175 | color: var(--color-danger); | |
| 1157 | 1176 | } | |
| 1177 | + | .nav-binary { | |
| 1178 | + | color: var(--color-muted); | |
| 1179 | + | font-size: var(--text-xs); | |
| 1180 | + | } | |
| 1158 | 1181 | .file-nav-dir > details > .file-nav-list { | |
| 1159 | 1182 | padding-left: var(--space-4); | |
| 1160 | 1183 | } | |
| @@ -1279,6 +1302,11 @@ | |||
|---|---|---|---|
| 1279 | 1302 | font-family: var(--font-mono); | |
| 1280 | 1303 | font-weight: 600; | |
| 1281 | 1304 | } | |
| 1305 | + | .diff-stat-binary { | |
| 1306 | + | color: var(--color-muted); | |
| 1307 | + | font-size: var(--text-sm); | |
| 1308 | + | font-family: var(--font-mono); | |
| 1309 | + | } | |
| 1282 | 1310 | .btn-xs { | |
| 1283 | 1311 | padding: 1px var(--space-2); | |
| 1284 | 1312 | font-size: var(--text-xs); | |
| @@ -1521,7 +1549,6 @@ | |||
|---|---|---|---|
| 1521 | 1549 | align-items: center; | |
| 1522 | 1550 | gap: var(--space-3); | |
| 1523 | 1551 | margin-bottom: var(--space-6); | |
| 1524 | - | flex-wrap: wrap; | |
| 1525 | 1552 | } | |
| 1526 | 1553 | .issue-number { | |
| 1527 | 1554 | font-size: var(--text-2xl); | |
| @@ -1532,7 +1559,7 @@ | |||
|---|---|---|---|
| 1532 | 1559 | .issue-detail-title { | |
| 1533 | 1560 | font-size: var(--text-2xl); | |
| 1534 | 1561 | font-weight: 600; | |
| 1535 | - | flex: 1; | |
| 1562 | + | overflow-wrap: anywhere; | |
| 1536 | 1563 | } | |
| 1537 | 1564 | .issue-detail-meta-actions { | |
| 1538 | 1565 | margin-left: auto; | |
| @@ -1552,13 +1579,14 @@ | |||
|---|---|---|---|
| 1552 | 1579 | display: flex; | |
| 1553 | 1580 | align-items: center; | |
| 1554 | 1581 | justify-content: space-between; | |
| 1555 | - | flex-wrap: wrap; | |
| 1556 | 1582 | gap: var(--space-3); | |
| 1557 | 1583 | border-bottom: 1px solid var(--color-border); | |
| 1584 | + | overflow-wrap: anywhere; | |
| 1558 | 1585 | } | |
| 1559 | 1586 | .timeline-author time { | |
| 1560 | 1587 | color: var(--color-text-muted); | |
| 1561 | 1588 | font-size: var(--text-xs); | |
| 1589 | + | width: max-content; | |
| 1562 | 1590 | } | |
| 1563 | 1591 | .timeline-author-right { | |
| 1564 | 1592 | margin-left: auto; | |
| @@ -1653,6 +1681,73 @@ | |||
|---|---|---|---|
| 1653 | 1681 | .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area { | |
| 1654 | 1682 | display: block; | |
| 1655 | 1683 | } | |
| 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 | + | ||
| 1656 | 1751 | .inline-edit-form { | |
| 1657 | 1752 | padding: var(--space-4); | |
| 1658 | 1753 | display: flex; | |
Msrc/views/DiffView.tsx
| @@ -76,15 +76,41 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element { | |||
|---|---|---|---|
| 76 | 76 | </span> | |
| 77 | 77 | <span class="file-nav-name">{name}</span> | |
| 78 | 78 | <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 | + | )} | |
| 87 | 100 | </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 | + | </> | |
| 88 | 114 | )} | |
| 89 | 115 | </span> | |
| 90 | 116 | </a> | |
| @@ -173,44 +199,64 @@ export function DiffView({ files, repo, sha }: DiffViewProps) { | |||
|---|---|---|---|
| 173 | 199 | )} | |
| 174 | 200 | </div> | |
| 175 | 201 | <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 | + | )} | |
| 179 | 225 | </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 | + | </> | |
| 180 | 239 | )} | |
| 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 && ( | |
| 202 | 243 | <a | |
| 203 | - | href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`} | |
| 244 | + | href={`/${repo.name}/blob/${sha}/${displayPath}`} | |
| 204 | 245 | class="btn btn-xs btn-secondary" | |
| 205 | - | title={`View file at ${repo.default_branch}`} | |
| 246 | + | title={`View file at ${sha.slice(0, 7)}`} | |
| 206 | 247 | > | |
| 207 | - | @{" "} | |
| 208 | - | { | |
| 209 | - | repo.default_branch | |
| 210 | - | } | |
| 248 | + | @ {sha.slice(0, 7)} | |
| 211 | 249 | </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 | + | )} | |
| 214 | 260 | </div> | |
| 215 | 261 | </summary> | |
| 216 | 262 | ||
Msrc/views/issues/IssueDetail.tsx
| @@ -39,9 +39,12 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 39 | 39 | commentReactions, | |
| 40 | 40 | }: IssueDetailProps) { | |
| 41 | 41 | 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")); | |
| 43 | 45 | 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")); | |
| 45 | 48 | return ( | |
| 46 | 49 | <Layout user={user} title={`${issue.title} — ${repo.name}`}> | |
| 47 | 50 | <div class="container"> | |
| @@ -50,7 +53,52 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 50 | 53 | <div class="issue-detail"> | |
| 51 | 54 | <div class="issue-detail-header"> | |
| 52 | 55 | <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> | |
| 54 | 102 | <span class={`issue-badge ${issue.status}`}> | |
| 55 | 103 | {issue.status} | |
| 56 | 104 | </span> | |
| @@ -146,28 +194,12 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 146 | 194 | action={`/${repo.name}/issues/${issue.number}/edit`} | |
| 147 | 195 | class="inline-edit-form" | |
| 148 | 196 | > | |
| 197 | + | <input | |
| 198 | + | type="hidden" | |
| 199 | + | name="title" | |
| 200 | + | value={issue.title} | |
| 201 | + | /> | |
| 149 | 202 | <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> | |
| 171 | 203 | <textarea | |
| 172 | 204 | class="form-input" | |
| 173 | 205 | id="edit-issue-body" | |
Msrc/views/issues/IssueList.tsx
| @@ -10,7 +10,10 @@ import { RepoNav } from "../repos/RepoNav.tsx"; | |||
|---|---|---|---|
| 10 | 10 | interface IssueListProps { | |
| 11 | 11 | user: SessionUser | null; | |
| 12 | 12 | 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 | + | })[]; | |
| 14 | 17 | status: "open" | "closed" | "completed"; | |
| 15 | 18 | counts: Record<string, number>; | |
| 16 | 19 | pagination: PaginationInfo; | |
Msrc/views/patches/PatchDetail.tsx
| @@ -48,9 +48,12 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 48 | 48 | commentReactions, | |
| 49 | 49 | }: PatchDetailProps) { | |
| 50 | 50 | 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")); | |
| 52 | 54 | 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")); | |
| 54 | 57 | ||
| 55 | 58 | const baseUrl = `/${repo.name}/patches/${patch.number}`; | |
| 56 | 59 | const reactUrl = `${baseUrl}/react`; | |
| @@ -64,7 +67,52 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 64 | 67 | <div class="issue-detail"> | |
| 65 | 68 | <div class="issue-detail-header"> | |
| 66 | 69 | <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> | |
| 68 | 116 | <span class={`patch-badge ${patch.status}`}> | |
| 69 | 117 | {patch.status} | |
| 70 | 118 | </span> | |
| @@ -188,28 +236,12 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 188 | 236 | action={`${baseUrl}/edit`} | |
| 189 | 237 | class="inline-edit-form" | |
| 190 | 238 | > | |
| 239 | + | <input | |
| 240 | + | type="hidden" | |
| 241 | + | name="title" | |
| 242 | + | value={patch.title} | |
| 243 | + | /> | |
| 191 | 244 | <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> | |
| 213 | 245 | <textarea | |
| 214 | 246 | class="form-input" | |
| 215 | 247 | id="edit-patch-desc" | |
Msrc/views/patches/PatchList.tsx
| @@ -10,7 +10,10 @@ import { RepoNav } from "../repos/RepoNav.tsx"; | |||
|---|---|---|---|
| 10 | 10 | interface PatchListProps { | |
| 11 | 11 | user: SessionUser | null; | |
| 12 | 12 | 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 | + | })[]; | |
| 14 | 17 | status: string; | |
| 15 | 18 | counts: Record<string, number>; | |
| 16 | 19 | pagination: PaginationInfo; | |
Msrc/views/repos/FileBlob.tsx
| @@ -78,6 +78,30 @@ export function FileBlob({ | |||
|---|---|---|---|
| 78 | 78 | <div class="file-blob-body"> | |
| 79 | 79 | {view.type === "inline" ? ( | |
| 80 | 80 | <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> | |
| 81 | 105 | ) : view.type === "binary" ? ( | |
| 82 | 106 | <div class="file-download-notice"> | |
| 83 | 107 | <p>Binary file ({formatSize(view.size)})</p> | |