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 @@
/**
* Seed script — populates the live database with enough data to test pagination.
* Run with: bun run db:seed
*
* Safe to run multiple times — skips existing items.
*/
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import path from "node:path";
import * as argon2 from "argon2";
import { ADMIN_USERNAME, DB_PATH, REPOS_DIR } from "../constants.ts";
const db = new Database(DB_PATH);
db.run("PRAGMA journal_mode=WAL");
db.run("PRAGMA foreign_keys=ON");
// ── Resolve admin user ────────────────────────────────────────────────────────
const adminRow = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
.get(ADMIN_USERNAME);
if (!adminRow) {
console.error("Admin user not found. Run `bun run db:init` first.");
process.exit(1);
}
const adminId = adminRow.id;
// Ensure a second user "alice" exists for variety
let aliceId: number;
const aliceRow = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
.get("alice");
if (aliceRow) {
aliceId = aliceRow.id;
} else {
const hash = await argon2.hash("password123");
const now = new Date().toISOString();
db.run(
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
["alice", hash, now],
);
aliceId = db
.query<{ id: number }, [string]>(
"SELECT id FROM users WHERE username = ?",
)
.get("alice")!.id;
console.log("Created user alice (password: password123)");
}
// ── Helper ────────────────────────────────────────────────────────────────────
function getOrCreateRepo(name: string, description: string): number {
const existing = db
.query<{ id: number }, [string]>(
"SELECT id FROM repositories WHERE name = ?",
)
.get(name);
if (existing) return existing.id;
const now = new Date().toISOString();
db.run(
"INSERT INTO repositories (name, description, is_private, default_branch, created_at) VALUES (?, ?, 0, 'main', ?)",
[name, description, now],
);
const repoPath = path.join(REPOS_DIR, `${name}.git`);
mkdirSync(repoPath, { recursive: true });
// init bare repo (sync-ish via Bun.spawnSync)
Bun.spawnSync(["git", "init", "--bare", "--initial-branch=main", repoPath]);
console.log(`Created repo: ${name}`);
return db
.query<{ id: number }, [string]>(
"SELECT id FROM repositories WHERE name = ?",
)
.get(name)!.id;
}
// ── 25 extra repositories (for repo list pagination) ─────────────────────────
const topics = [
"A web framework",
"CLI toolkit",
"Database driver",
"Auth library",
"Test runner",
"Build system",
"Linter plugin",
"ORM layer",
"Cache client",
"Queue worker",
"API gateway",
"Graph engine",
"ML utilities",
"Crypto helpers",
"File watcher",
"Schema validator",
"Logger library",
"Rate limiter",
"Metrics exporter",
"Job scheduler",
"Config manager",
"Template engine",
"Markdown parser",
"Image resizer",
"Email sender",
];
for (let i = 1; i <= 25; i++) {
const n = String(i).padStart(2, "0");
getOrCreateRepo(`seed-repo-${n}`, topics[i - 1]!);
}
// ── "demo" repo — seed issues and patches ────────────────────────────────────
const demoRepoId = getOrCreateRepo(
"demo",
"Demo repository for pagination testing",
);
// Seed 35 issues (mix of open and closed)
const issueCount =
db
.query<{ n: number }, [number]>(
"SELECT COUNT(*) as n FROM issues WHERE repo_id = ?",
)
.get(demoRepoId)?.n ?? 0;
if (issueCount < 35) {
const start = issueCount + 1;
const issueTitles = [
"Fix null pointer dereference in parser",
"Add dark mode support",
"Improve error messages",
"Upgrade dependencies to latest",
"Memory leak in connection pool",
"Race condition in event loop",
"Add pagination to commit log",
"Slow query on large datasets",
"Missing CORS headers",
"Typo in README",
"Refactor authentication middleware",
"Support IPv6 addresses",
"Add unit tests for utils",
"Broken link in documentation",
"Config file not loaded on Windows",
"Infinite loop when input is empty",
"Add rate limiting",
"Log rotation broken",
"Session cookie not cleared on logout",
"Crash on malformed JSON input",
"Add export to CSV feature",
"Support custom themes",
"API returns 500 on edge case",
"Update license to MIT",
"Improve startup time",
"Handle timeout errors gracefully",
"Add health check endpoint",
"Support environment variables in config",
"Fix XSS in search",
"Stale cache after update",
"Add OpenAPI spec",
"Sort order wrong in list view",
"Binary file detection false positive",
"Column alignment off in table view",
"Wrong timezone in timestamps",
];
for (let i = start; i <= 35; i++) {
const titleIndex = (i - 1) % issueTitles.length;
const status = i <= 25 ? "open" : "closed";
const authorId = i % 3 === 0 ? aliceId : adminId;
const now = new Date(Date.now() - (35 - i) * 3600_000).toISOString();
db.run(
"INSERT OR IGNORE INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
demoRepoId,
authorId,
i,
issueTitles[titleIndex]!,
`Details for issue #${i}.`,
status,
now,
now,
],
);
}
console.log(`Seeded issues up to #35 in demo repo`);
}
// Seed 25 patches
const patchCount =
db
.query<{ n: number }, [number]>(
"SELECT COUNT(*) as n FROM patches WHERE repo_id = ?",
)
.get(demoRepoId)?.n ?? 0;
if (patchCount < 25) {
const start = patchCount + 1;
const SAMPLE_PATCH = [
"diff --git a/placeholder.txt b/placeholder.txt",
"new file mode 100644",
"index 0000000..e69de29",
"--- /dev/null",
"+++ b/placeholder.txt",
"@@ -0,0 +1 @@",
"+placeholder",
"",
].join("\n");
const patchTitles = [
"Fix null deref",
"Add dark mode",
"Improve errors",
"Upgrade deps",
"Fix memory leak",
"Fix race condition",
"Add pagination",
"Optimise query",
"Add CORS headers",
"Fix typo",
"Refactor auth",
"Support IPv6",
"Add unit tests",
"Fix broken link",
"Fix Windows config",
"Fix infinite loop",
"Add rate limiting",
"Fix log rotation",
"Fix logout cookie",
"Handle bad JSON",
"Add CSV export",
"Custom themes",
"Fix 500 error",
"Update license",
"Improve startup",
];
for (let i = start; i <= 25; i++) {
const titleIndex = (i - 1) % patchTitles.length;
const status = i <= 20 ? "open" : "closed";
const authorId = i % 3 === 0 ? aliceId : adminId;
const now = new Date(Date.now() - (25 - i) * 3600_000).toISOString();
db.run(
"INSERT OR IGNORE INTO patches (repo_id, author_id, number, title, description, patch_content, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
demoRepoId,
authorId,
i,
patchTitles[titleIndex]!,
`Description for patch #${i}.`,
SAMPLE_PATCH,
status,
now,
now,
],
);
}
console.log(`Seeded patches up to #25 in demo repo`);
}
db.close();
console.log("Seed complete.");
Msrc/routes/issues.tsx
@@ -495,13 +495,15 @@ export const issueRoutes = new Elysia()
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id", "author_id"])
.select(["id", "author_id", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
if (issue.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
if (issue.status !== "open" && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
await db
.updateTable("issues")
@@ -544,6 +546,13 @@ export const issueRoutes = new Elysia()
if (!comment) return new Response("Not found", { status: 404 });
if (comment.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const parentIssue = await db
.selectFrom("issues")
.select("status")
.where("id", "=", comment.issue_id)
.executeTakeFirst();
if (parentIssue?.status !== "open" && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const issueNum = parseInt(params.number, 10);
await db
Msrc/routes/patches.tsx
@@ -623,12 +623,19 @@ export const patchRoutes = new Elysia()
const comment = await db
.selectFrom("patch_comments")
.select(["id", "author_id"])
.select(["id", "author_id", "patch_id"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!comment) return new Response("Not found", { status: 404 });
if (comment.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const parentPatch = await db
.selectFrom("patches")
.select("status")
.where("id", "=", comment.patch_id)
.executeTakeFirst();
if (parentPatch?.status !== "open" && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const patchNum = parseInt(params.number, 10);
await db
@@ -667,13 +674,15 @@ export const patchRoutes = new Elysia()
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "author_id"])
.select(["id", "author_id", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
if (patch.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
if (patch.status !== "open" && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
await db
.updateTable("patches")
Msrc/routes/repos.tsx
@@ -354,7 +354,7 @@ export const repoRoutes = new Elysia()
const resolved = await git.resolveRef(repo.name, params.ref);
if (!resolved) return new Response("Not found", { status: 404 });
const subpath = params["*"];
const subpath = decodeURIComponent(params["*"]);
const [entries, branches] = await Promise.all([
git.lsTree(repo.name, params.ref, subpath),
git.branches(repo.name),
@@ -393,7 +393,7 @@ export const repoRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = params["*"];
const filePath = decodeURIComponent(params["*"]);
const [content, branches, commitSHA] = await Promise.all([
git.show(repo.name, params.ref, filePath),
git.branches(repo.name),
@@ -420,22 +420,44 @@ export const repoRoutes = new Elysia()
);
})
.get("/:repo/raw/:ref/*", async ({ params, cookie }) => {
.get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = params["*"];
const filePath = decodeURIComponent(params["*"]);
const content = await git.show(repo.name, params.ref, filePath);
if (!content) return new Response("Not found", { status: 404 });
const filename = path.basename(filePath);
const contentType = await mimeForContent(content);
const total = content.length;
const rangeHeader = request.headers.get("Range");
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : total - 1;
const clampedEnd = Math.min(end, total - 1);
return new Response(content.subarray(start, clampedEnd + 1), {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
"Accept-Ranges": "bytes",
"Content-Length": String(clampedEnd - start + 1),
},
});
}
}
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Content-Length": String(content.length),
"Content-Length": String(total),
"Accept-Ranges": "bytes",
},
});
})
@@ -513,6 +535,7 @@ export const repoRoutes = new Elysia()
const files = await prepareDiff(
rawDiff,
`commit:${repo.name}:${params.sha}`,
repo.name,
);
return html(
<CommitDetail
Msrc/services/diffHighlight.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import type { BundledLanguage } from "shiki";
import { MAX_DIFF_CACHE } from "../constants.ts";
import { INLINE_MAX_BYTES } from "../config.ts";
import { git } from "./git.ts";
import { detectLang, getHighlighter } from "./highlight.ts";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -31,6 +32,8 @@ export interface RenderedDiffFile {
added: number;
removed: number;
isBinary: boolean;
/** Byte sizes for binary files. Populated from GIT binary patch literals or blob lookups. */
binarySize?: { before: number; after: number };
hunks: RenderedHunk[];
}
@@ -55,10 +58,15 @@ export interface ParsedFile {
added: number;
removed: number;
isBinary: boolean;
binaryNewSize?: number;
binaryOldSize?: number;
hunks: ParsedHunk[];
}
export function parseDiff(raw: string): ParsedFile[] {
export async function parseDiff(
raw: string,
repoName?: string,
): Promise<ParsedFile[]> {
const files: ParsedFile[] = [];
const allLines = raw.split("\n");
let i = 0;
@@ -82,6 +90,9 @@ export function parseDiff(raw: string): ParsedFile[] {
};
i++;
let oldBlob = "";
let newBlob = "";
while (i < allLines.length) {
const line = allLines[i]!;
if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break;
@@ -101,7 +112,30 @@ export function parseDiff(raw: string): ParsedFile[] {
file.oldPath = line.slice(6);
else if (line.startsWith("+++ ") && line !== "+++ /dev/null")
file.newPath = line.slice(6);
else if (line.startsWith("Binary files ")) file.isBinary = true;
else if (line.startsWith("index ")) {
const idxm = line.match(/^index ([0-9a-f]+)\.\.([0-9a-f]+)/i);
if (idxm) {
oldBlob = idxm[1]!;
newBlob = idxm[2]!;
}
} else if (line.startsWith("Binary files ")) {
file.isBinary = true;
if (repoName) {
const [oldSize, newSize] = await Promise.all([
git.blobSize(repoName, oldBlob),
git.blobSize(repoName, newBlob),
]);
file.binaryOldSize = oldSize;
file.binaryNewSize = newSize;
}
} else if (line === "GIT binary patch") {
file.isBinary = true;
} else if (file.isBinary && line.startsWith("literal ")) {
const size = parseInt(line.slice(8), 10);
if (file.binaryNewSize === undefined) file.binaryNewSize = size;
else if (file.binaryOldSize === undefined)
file.binaryOldSize = size;
}
i++;
}
@@ -139,6 +173,7 @@ export function parseDiff(raw: string): ParsedFile[] {
files.push(file);
}
return files;
}
@@ -207,13 +242,29 @@ export async function highlightFile(
): Promise<RenderedDiffFile> {
const displayPath = file.newPath || file.oldPath;
const lang = detectLang(path.basename(displayPath));
const highlightedHunks = await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
const totalBytes = file.hunks.reduce(
(sum, h) => sum + h.lines.reduce((s, l) => s + l.content.length, 0),
0,
);
const highlightedHunks =
totalBytes > INLINE_MAX_BYTES
? file.hunks.map((hunk) =>
hunk.lines.map((l) => escapeHtml(l.content)),
)
: await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
);
const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
header: hunk.header,
rows: buildRows(hunk, highlightedHunks[i]!),
}));
let binarySize: RenderedDiffFile["binarySize"];
if (file.isBinary && file.binaryNewSize !== undefined) {
binarySize = {
after: file.binaryNewSize,
before: file.binaryOldSize ?? 0,
};
}
return {
oldPath: file.oldPath,
newPath: file.newPath,
@@ -221,48 +272,7 @@ export async function highlightFile(
added: file.added,
removed: file.removed,
isBinary: file.isBinary,
binarySize,
hunks,
};
}
// ─── Public API ──────────────────────────────────────────────────────────────
const diffCache = new Map<string, RenderedDiffFile[]>();
export async function prepareDiff(
rawDiff: string,
cacheKey: string,
): Promise<RenderedDiffFile[]> {
const cached = diffCache.get(cacheKey);
if (cached) return cached;
const parsed = parseDiff(rawDiff);
const result = await Promise.all(
parsed.map(async (file) => {
const displayPath = file.newPath || file.oldPath;
const lang = detectLang(path.basename(displayPath));
const highlightedHunks = await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
);
const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
header: hunk.header,
rows: buildRows(hunk, highlightedHunks[i]!),
}));
return {
oldPath: file.oldPath,
newPath: file.newPath,
status: file.status,
added: file.added,
removed: file.removed,
isBinary: file.isBinary,
hunks,
};
}),
);
if (cacheKey) {
if (diffCache.size >= MAX_DIFF_CACHE)
diffCache.delete(diffCache.keys().next().value!);
diffCache.set(cacheKey, result);
}
return result;
}
Msrc/services/git.ts
@@ -256,6 +256,17 @@ export const git = {
}
},
async blobSize(name: string, hash: string): Promise<number> {
if (/^0+$/.test(hash)) return 0;
const p = repoPath(name);
try {
const out = await $`git -C ${p} cat-file -s ${hash}`.text();
return parseInt(out.trim(), 10) || 0;
} catch {
return 0;
}
},
async branches(name: string): Promise<string[]> {
const p = repoPath(name);
try {
Msrc/services/highlight.ts
@@ -1,3 +1,4 @@
import { fileTypeFromBuffer } from "file-type";
import type { Language } from "linguist-languages";
import * as linguistLangs from "linguist-languages";
import {
@@ -92,7 +93,8 @@ export function detectLang(filename: string): string {
export type FileView =
| { type: "inline"; html: string; lines: number }
| { type: "download"; size: number }
| { type: "binary"; size: number };
| { type: "binary"; size: number }
| { type: "media"; mimeType: string; size: number };
const fileCache = new Map<string, FileView>();
@@ -156,6 +158,18 @@ export async function serveFile(
const cached = fileCache.get(cacheKey);
if (cached) return cached;
const fileType = await fileTypeFromBuffer(content);
if (fileType) {
const group = fileType.mime.split("/")[0];
if (group === "image" || group === "audio" || group === "video") {
return {
type: "media",
mimeType: fileType.mime,
size: content.length,
};
}
}
const isBinary = hasBinaryContent(content);
if (isBinary) {
return { type: "binary", size: content.length };
Msrc/services/highlightWorker.ts
@@ -102,11 +102,12 @@ export function serveFile(
export async function prepareDiff(
rawDiff: string,
cacheKey: string,
repoName?: string,
): Promise<RenderedDiffFile[]> {
const cached = diffCache.get(cacheKey);
if (cached) return cached;
const parsed = parseDiff(rawDiff);
const parsed = await parseDiff(rawDiff, repoName);
const result = await Promise.all(
parsed.map((file: ParsedFile) =>
request<RenderedDiffFile>({ type: "highlightFile", file }),
Msrc/services/repoSync.ts
@@ -88,9 +88,8 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
.selectAll()
.where("name", "=", name)
.executeTakeFirst();
if (existing) return existing;
await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
if (existing) return existing;
const branch = await git.defaultBranch(name);
const now = new Date().toISOString();
Msrc/styles/main.css
@@ -911,6 +911,25 @@
.file-download-notice p {
margin-bottom: var(--space-4);
}
.file-media {
padding: var(--space-4);
display: flex;
justify-content: center;
background: var(--color-canvas-subtle);
}
.file-media-img {
max-width: 100%;
height: auto;
display: block;
}
.file-media-audio {
width: 100%;
max-width: 600px;
}
.file-media-video {
max-width: 100%;
max-height: 80vh;
}
/* --- Commit list --- */
.commit-list {
@@ -1155,6 +1174,10 @@
.nav-del {
color: var(--color-danger);
}
.nav-binary {
color: var(--color-muted);
font-size: var(--text-xs);
}
.file-nav-dir > details > .file-nav-list {
padding-left: var(--space-4);
}
@@ -1279,6 +1302,11 @@
font-family: var(--font-mono);
font-weight: 600;
}
.diff-stat-binary {
color: var(--color-muted);
font-size: var(--text-sm);
font-family: var(--font-mono);
}
.btn-xs {
padding: 1px var(--space-2);
font-size: var(--text-xs);
@@ -1521,7 +1549,6 @@
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-6);
flex-wrap: wrap;
}
.issue-number {
font-size: var(--text-2xl);
@@ -1532,7 +1559,7 @@
.issue-detail-title {
font-size: var(--text-2xl);
font-weight: 600;
flex: 1;
overflow-wrap: anywhere;
}
.issue-detail-meta-actions {
margin-left: auto;
@@ -1552,13 +1579,14 @@
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
border-bottom: 1px solid var(--color-border);
overflow-wrap: anywhere;
}
.timeline-author time {
color: var(--color-text-muted);
font-size: var(--text-xs);
width: max-content;
}
.timeline-author-right {
margin-left: auto;
@@ -1653,6 +1681,73 @@
.timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area {
display: block;
}
.timeline-item:has(.inline-edit-details[open]) .timeline-body {
display: none;
}
/* Title edit in issue/patch header */
.issue-detail-header {
align-items: flex-start;
}
.title-with-edit {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.title-edit-details {
border: none;
align-self: flex-start;
}
.title-with-edit:has(.title-edit-details[open]) .issue-detail-title {
display: none;
}
.title-with-edit:has(.title-edit-details[open]) > .title-edit-details {
display: none;
}
.title-edit-details > summary {
padding: 0;
list-style: none;
}
.title-edit-details > summary::-webkit-details-marker {
display: none;
}
.title-edit-form-area {
display: none;
}
.title-with-edit:has(.title-edit-details[open]) .title-edit-form-area {
display: flex;
flex: 1;
min-width: 0;
}
.title-edit-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.title-edit-form .form-input {
min-width: 0;
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.title-edit-form .form-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.title-edit-actions {
display: flex;
gap: var(--space-2);
}
.inline-edit-form {
padding: var(--space-4);
display: flex;
Msrc/views/DiffView.tsx
@@ -76,15 +76,41 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
</span>
<span class="file-nav-name">{name}</span>
<span class="file-nav-stat">
{node.file.added > 0 && (
<span class="nav-add">
+{node.file.added}
</span>
)}
{node.file.removed > 0 && (
<span class="nav-del">
-{node.file.removed}
{node.file.isBinary ? (
<span class="nav-binary">
{node.file.binarySize ? (
<>
<span class="nav-del">
{
node.file.binarySize
.before
}
</span>
{" \u2192 "}
<span class="nav-add">
{
node.file.binarySize
.after
}
</span>
</>
) : (
"Bin"
)}
</span>
) : (
<>
{node.file.added > 0 && (
<span class="nav-add">
+{node.file.added}
</span>
)}
{node.file.removed > 0 && (
<span class="nav-del">
-{node.file.removed}
</span>
)}
</>
)}
</span>
</a>
@@ -173,44 +199,64 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
)}
</div>
<div class="diff-file-header-right">
{f.added > 0 && (
<span class="diff-stat-add">
+{f.added}
{f.isBinary ? (
<span class="diff-stat-binary">
{f.binarySize ? (
<>
{"Bin "}
<span class="diff-stat-del">
{
f.binarySize
.before
}
</span>
{" \u2192 "}
<span class="diff-stat-add">
{
f.binarySize
.after
}
</span>
{" bytes"}
</>
) : (
"Binary"
)}
</span>
) : (
<>
{f.added > 0 && (
<span class="diff-stat-add">
+{f.added}
</span>
)}
{f.removed > 0 && (
<span class="diff-stat-del">
-{f.removed}
</span>
)}
</>
)}
{f.removed > 0 && (
<span class="diff-stat-del">
-{f.removed}
</span>
)}
{!f.isBinary &&
f.status !== "deleted" && (
<>
{sha && (
<a
href={`/${repo.name}/blob/${sha}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${sha.slice(0, 7)}`}
>
@{" "}
{sha.slice(
0,
7,
)}
</a>
)}
{f.status !== "deleted" && (
<>
{sha && (
<a
href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
href={`/${repo.name}/blob/${sha}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${repo.default_branch}`}
title={`View file at ${sha.slice(0, 7)}`}
>
@{" "}
{
repo.default_branch
}
@ {sha.slice(0, 7)}
</a>
</>
)}
)}
<a
href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${repo.default_branch}`}
>
@ {repo.default_branch}
</a>
</>
)}
</div>
</summary>
Msrc/views/issues/IssueDetail.tsx
@@ -39,9 +39,12 @@ export function IssueDetail({
commentReactions,
}: IssueDetailProps) {
const canEditIssue =
user != null && (user.isAdmin || user.id === issue.author_id);
user != null &&
(user.isAdmin ||
(user.id === issue.author_id && issue.status === "open"));
const canEditComment = (c: IssueCommentRow) =>
user != null && (user.isAdmin || user.id === c.author_id);
user != null &&
(user.isAdmin || (user.id === c.author_id && issue.status === "open"));
return (
<Layout user={user} title={`${issue.title} — ${repo.name}`}>
<div class="container">
@@ -50,7 +53,52 @@ export function IssueDetail({
<div class="issue-detail">
<div class="issue-detail-header">
<span class="issue-number">#{issue.number}</span>
<h2 class="issue-detail-title">{issue.title}</h2>
<div class="title-with-edit">
<h2 class="issue-detail-title">{issue.title}</h2>
{canEditIssue && (
<>
<div class="title-edit-form-area">
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/edit`}
class="title-edit-form"
>
<input
class="form-input"
name="title"
value={issue.title}
required
/>
<input
type="hidden"
name="edit_body"
value={issue.body}
/>
<div class="title-edit-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
<button
type="button"
class="btn btn-sm"
onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
>
Cancel
</button>
</div>
</form>
</div>
<details class="title-edit-details">
<summary class="btn btn-xs btn-ghost">
Edit title
</summary>
</details>
</>
)}
</div>
<span class={`issue-badge ${issue.status}`}>
{issue.status}
</span>
@@ -146,28 +194,12 @@ export function IssueDetail({
action={`/${repo.name}/issues/${issue.number}/edit`}
class="inline-edit-form"
>
<input
type="hidden"
name="title"
value={issue.title}
/>
<div class="form-group">
<label
class="form-label"
for="edit-issue-title"
>
Title
</label>
<input
class="form-input"
id="edit-issue-title"
name="title"
value={issue.title}
required
/>
</div>
<div class="form-group">
<label
class="form-label"
for="edit-issue-body"
>
Description
</label>
<textarea
class="form-input"
id="edit-issue-body"
Msrc/views/issues/IssueList.tsx
@@ -10,7 +10,10 @@ import { RepoNav } from "../repos/RepoNav.tsx";
interface IssueListProps {
user: SessionUser | null;
repo: RepositoryRow;
issues: (IssueRow & { author_username: string; author_avatar_version: number | null })[];
issues: (IssueRow & {
author_username: string;
author_avatar_version: number | null;
})[];
status: "open" | "closed" | "completed";
counts: Record<string, number>;
pagination: PaginationInfo;
Msrc/views/patches/PatchDetail.tsx
@@ -48,9 +48,12 @@ export function PatchDetail({
commentReactions,
}: PatchDetailProps) {
const canEdit =
user != null && (user.isAdmin || user.id === patch.author_id);
user != null &&
(user.isAdmin ||
(user.id === patch.author_id && patch.status === "open"));
const canEditComment = (c: PatchCommentRow) =>
user != null && (user.isAdmin || user.id === c.author_id);
user != null &&
(user.isAdmin || (user.id === c.author_id && patch.status === "open"));
const baseUrl = `/${repo.name}/patches/${patch.number}`;
const reactUrl = `${baseUrl}/react`;
@@ -64,7 +67,52 @@ export function PatchDetail({
<div class="issue-detail">
<div class="issue-detail-header">
<span class="issue-number">#{patch.number}</span>
<h2 class="issue-detail-title">{patch.title}</h2>
<div class="title-with-edit">
<h2 class="issue-detail-title">{patch.title}</h2>
{canEdit && (
<>
<div class="title-edit-form-area">
<form
method="POST"
action={`${baseUrl}/edit`}
class="title-edit-form"
>
<input
class="form-input"
name="title"
value={patch.title}
required
/>
<input
type="hidden"
name="edit_description"
value={patch.description}
/>
<div class="title-edit-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
<button
type="button"
class="btn btn-sm"
onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
>
Cancel
</button>
</div>
</form>
</div>
<details class="title-edit-details">
<summary class="btn btn-xs btn-ghost">
Edit title
</summary>
</details>
</>
)}
</div>
<span class={`patch-badge ${patch.status}`}>
{patch.status}
</span>
@@ -188,28 +236,12 @@ export function PatchDetail({
action={`${baseUrl}/edit`}
class="inline-edit-form"
>
<input
type="hidden"
name="title"
value={patch.title}
/>
<div class="form-group">
<label
class="form-label"
for="edit-patch-title"
>
Title
</label>
<input
class="form-input"
id="edit-patch-title"
name="title"
value={patch.title}
required
/>
</div>
<div class="form-group">
<label
class="form-label"
for="edit-patch-desc"
>
Description
</label>
<textarea
class="form-input"
id="edit-patch-desc"
Msrc/views/patches/PatchList.tsx
@@ -10,7 +10,10 @@ import { RepoNav } from "../repos/RepoNav.tsx";
interface PatchListProps {
user: SessionUser | null;
repo: RepositoryRow;
patches: (PatchRow & { author_username: string; author_avatar_version: number | null })[];
patches: (PatchRow & {
author_username: string;
author_avatar_version: number | null;
})[];
status: string;
counts: Record<string, number>;
pagination: PaginationInfo;
Msrc/views/repos/FileBlob.tsx
@@ -78,6 +78,30 @@ export function FileBlob({
<div class="file-blob-body">
{view.type === "inline" ? (
<div class="shiki-wrapper">{view.html}</div>
) : view.type === "media" ? (
<div class="file-media">
{view.mimeType.startsWith("image/") ? (
<img
src={`/${repo.name}/raw/${blobRef}/${filePath}`}
alt={filename}
class="file-media-img"
/>
) : view.mimeType.startsWith("audio/") ? (
// biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
<audio
controls
src={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="file-media-audio"
/>
) : (
// biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
<video
controls
src={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="file-media-video"
/>
)}
</div>
) : view.type === "binary" ? (
<div class="file-download-notice">
<p>Binary file ({formatSize(view.size)})</p>