add single file edit

AuthorKonata <konata@posteo.jp>
Date
Commit3ce7acf5f48b79c053121dabec02201e9935ed19
Parent6a24612
8 files changed, 395 insertions(+), 10 deletions(-)
MREADME.md
@@ -94,7 +94,7 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res
9494 ## Roadmap
9595 - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
9696 - Issue labels
97-- Repository list reordering (e.g. last committed) and starring
9897 - redirect image urls in readme
99-- simple file editor
100-- generic diff viewer (show diff for a given path between two refs)
98+- registration queue
99+- edit patches
100+- show ^M in diffs
Msrc/routes/repos.tsx
@@ -8,6 +8,7 @@ import {
88 REPOS_PER_PAGE,
99 VALID_REPO_NAME_RE,
1010 } from "../constants.ts";
11+import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
1112 import { db } from "../db/index.ts";
1213 import { requireAdmin, resolveSession } from "../middleware/session.ts";
1314 import { git, repoPath } from "../services/git.ts";
@@ -23,6 +24,7 @@ import { html } from "../views/render.tsx";
2324 import { CommitDetail } from "../views/repos/CommitDetail.tsx";
2425 import { CommitLog } from "../views/repos/CommitLog.tsx";
2526 import { FileBlob } from "../views/repos/FileBlob.tsx";
27+import { FileEdit } from "../views/repos/FileEdit.tsx";
2628 import { FileTree } from "../views/repos/FileTree.tsx";
2729 import { NewRepo } from "../views/repos/NewRepo.tsx";
2830 import { RepoHome } from "../views/repos/RepoHome.tsx";
@@ -490,6 +492,77 @@ export const repoRoutes = new Elysia()
490492 });
491493 })
492494
495+ .get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
496+ const user = await resolveSession(cookie.session.value);
497+ const deny = requireAdmin(user);
498+ if (deny) return deny;
499+ const repo = await getRepo(params.repo, true);
500+ if (!repo) return new Response("Not found", { status: 404 });
501+
502+ const filePath = decodeURIComponent(params["*"]);
503+ const branches = await git.branches(repo.name);
504+ if (!branches.includes(params.ref))
505+ return new Response("Not found", { status: 404 });
506+
507+ const content = await git.show(repo.name, params.ref, filePath);
508+ if (!content) return new Response("Not found", { status: 404 });
509+
510+ if (hasBinaryContent(content.subarray(0, 8000)))
511+ return new Response("Not found", { status: 404 });
512+
513+ return html(
514+ <FileEdit
515+ user={user!}
516+ repo={repo}
517+ ref={params.ref}
518+ filePath={filePath}
519+ content={content.toString("utf-8")}
520+ />,
521+ );
522+ })
523+
524+ .post(
525+ "/:repo/edit/:ref/*",
526+ async ({ params, body, cookie }) => {
527+ const user = await resolveSession(cookie.session.value);
528+ const deny = requireAdmin(user);
529+ if (deny) return deny;
530+ const repo = await getRepo(params.repo, true);
531+ if (!repo) return new Response("Not found", { status: 404 });
532+
533+ const filePath = decodeURIComponent(params["*"]);
534+ const branches = await git.branches(repo.name);
535+ if (!branches.includes(params.ref))
536+ return new Response("Not found", { status: 404 });
537+
538+ const message = body.message?.trim() || `Edited ${path.basename(filePath)}`;
539+ const content = (body.content ?? "").replaceAll("\r\n", "\n");
540+
541+ const commit = await git.editFile(
542+ repo.name,
543+ params.ref,
544+ filePath,
545+ content,
546+ message,
547+ COMMITTER_NAME,
548+ COMMITTER_EMAIL,
549+ );
550+
551+ return new Response(null, {
552+ status: 302,
553+ headers: {
554+ Location: `/${repo.name}/commit/${commit}`,
555+ },
556+ });
557+ },
558+ {
559+ body: t.Object({
560+ content: t.Optional(t.String()),
561+ message: t.Optional(t.String()),
562+ }),
563+ },
564+ )
565+
493566 .get(
494567 "/:repo/commits/:ref",
495568 async ({ params, cookie, query }) => {
Msrc/services/git.ts
@@ -470,12 +470,7 @@ export const git = {
470470 const commit = (
471471 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
472472 .env({
473- ...process.env,
474- LC_ALL: "C",
475- LANG: "C",
476- GIT_CONFIG_GLOBAL: "/dev/null",
477- GIT_CONFIG_SYSTEM: "/dev/null",
478- GIT_CONFIG_COUNT: "0",
473+ ...gitEnv,
479474 GIT_AUTHOR_NAME: authorName,
480475 GIT_AUTHOR_EMAIL: authorEmail,
481476 GIT_COMMITTER_NAME: committerName,
@@ -493,6 +488,56 @@ export const git = {
493488 });
494489 },
495490
491+ async editFile(
492+ name: string,
493+ branch: string,
494+ filePath: string,
495+ content: string,
496+ message: string,
497+ committerName: string,
498+ committerEmail: string,
499+ ): Promise<string> {
500+ return withRepoLock(name, async () => {
501+ const p = repoPath(name);
502+ const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
503+ try {
504+ await Bun.write(tmpFile, content);
505+ await $`git -C ${p} read-tree refs/heads/${branch}`;
506+ const blobHash = (
507+ await $`git -C ${p} hash-object -w ${tmpFile}`.text()
508+ ).trim();
509+ await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
510+ const tree = (
511+ await $`git -C ${p} write-tree`.text()
512+ ).trim();
513+ const parent = (
514+ await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
515+ ).trim();
516+ const sigArgs = [
517+ "-c",
518+ "gpg.format=ssh",
519+ "-c",
520+ `user.signingKey=${SSH_HOST_KEY_PATH}`,
521+ ];
522+ const commit = (
523+ await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
524+ .env({
525+ ...gitEnv,
526+ GIT_AUTHOR_NAME: committerName,
527+ GIT_AUTHOR_EMAIL: committerEmail,
528+ GIT_COMMITTER_NAME: committerName,
529+ GIT_COMMITTER_EMAIL: committerEmail,
530+ })
531+ .text()
532+ ).trim();
533+ await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
534+ return commit;
535+ } finally {
536+ await $`rm -f ${tmpFile}`.quiet().nothrow();
537+ }
538+ });
539+ },
540+
496541 async createTag(
497542 repoName: string,
498543 tagName: string,
Msrc/styles/main.css
@@ -874,6 +874,7 @@
874874 background: var(--color-bg-subtle);
875875 border: 1px solid var(--color-border);
876876 border-radius: var(--radius-lg) var(--radius-lg) 0 0;
877+ margin-top: var(--space-4);
877878 }
878879 .file-blob-name {
879880 font-size: var(--text-sm);
@@ -885,6 +886,24 @@
885886 border-radius: 0 0 var(--radius-lg) var(--radius-lg);
886887 overflow: auto;
887888 }
889+ .file-edit-textarea {
890+ display: block;
891+ width: 100%;
892+ box-sizing: border-box;
893+ font-family: var(--font-mono);
894+ font-size: var(--text-sm);
895+ line-height: 1.6;
896+ padding: var(--space-4);
897+ background: var(--color-bg);
898+ color: var(--color-text);
899+ border: none;
900+ resize: vertical;
901+ min-height: 400px;
902+ }
903+ .file-edit-textarea:focus {
904+ outline: 2px solid var(--color-accent);
905+ outline-offset: -2px;
906+ }
888907 /* Blob line-number table */
889908 .blob-table {
890909 min-width: 100%;
Msrc/views/DiffView.tsx
@@ -154,7 +154,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
154154
155155 {files.length === 0 ? (
156156 <p class="text-muted" style="margin-top: var(--space-6)">
157- No diff available.
157+ Empty diff or no diff available.
158158 </p>
159159 ) : (
160160 <div class="commit-layout">
Msrc/views/repos/FileBlob.tsx
@@ -73,6 +73,16 @@ export function FileBlob({
7373 >
7474 Raw
7575 </a>
76+ {view.type === "inline" &&
77+ branches.includes(blobRef) &&
78+ user?.isAdmin && (
79+ <a
80+ href={`/${repo.name}/edit/${blobRef}/${filePath}`}
81+ class="btn btn-sm btn-primary"
82+ >
83+ Edit
84+ </a>
85+ )}
7686 </div>
7787 </div>
7888 <div class="file-blob-body">
Asrc/views/repos/FileEdit.tsx
@@ -0,0 +1,116 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { RepoHeader } from "../repos/RepoHeader.tsx";
5+import { RepoNav } from "./RepoNav.tsx";
6+
7+interface FileEditProps {
8+ user: SessionUser;
9+ repo: RepositoryRow;
10+ ref: string;
11+ filePath: string;
12+ content: string;
13+ error?: string;
14+}
15+
16+export function FileEdit({
17+ user,
18+ repo,
19+ ref: editRef,
20+ filePath,
21+ content,
22+ error,
23+}: FileEditProps) {
24+ const parts = filePath.split("/");
25+ const filename = parts[parts.length - 1] ?? filePath;
26+ return (
27+ <Layout user={user} title={`Edit ${repo.name}/${filePath}`}>
28+ <div class="container">
29+ <RepoHeader repo={repo} />
30+ <RepoNav repo={repo} active="code" user={user} />
31+ <div class="breadcrumb">
32+ <a href={`/${repo.name}/tree/${editRef}`}>{repo.name}</a>
33+ {parts.map((part, i) => {
34+ const partPath = parts.slice(0, i + 1).join("/");
35+ const isLast = i === parts.length - 1;
36+ return (
37+ <>
38+ <span class="breadcrumb-sep">/</span>
39+ {isLast ? (
40+ <span class="breadcrumb-current">
41+ {part}
42+ </span>
43+ ) : (
44+ <a
45+ href={`/${repo.name}/tree/${editRef}/${partPath}`}
46+ >
47+ {part}
48+ </a>
49+ )}
50+ </>
51+ );
52+ })}
53+ </div>
54+ <p class="form-hint">WARNING: Line endings are normalized to LF (\n) on save.</p>
55+ {error && <p class="form-error">{error}</p>}
56+ <form
57+ method="POST"
58+ action={`/${repo.name}/edit/${editRef}/${filePath}`}
59+ >
60+ <div class="file-blob-header">
61+ <span class="file-blob-name">{filename}</span>
62+ <div class="file-blob-actions">
63+ <a
64+ href={`/${repo.name}/blob/${editRef}/${filePath}`}
65+ class="btn btn-sm btn-ghost"
66+ >
67+ Cancel
68+ </a>
69+ </div>
70+ </div>
71+ <div class="file-blob-body">
72+ <textarea
73+ name="content"
74+ class="file-edit-textarea"
75+ rows="30"
76+ spellcheck="false"
77+ autocomplete="off"
78+ autocorrect="off"
79+ autocapitalize="off"
80+ >
81+ {content}
82+ </textarea>
83+ </div>
84+ <div class="form-card">
85+ <p class="form-hint" style="margin-bottom: var(--space-4);">
86+ Committing directly to{" "}
87+ <strong>{editRef}</strong>
88+ </p>
89+ <div class="form-group">
90+ <label for="message">Commit message</label>
91+ <textarea
92+ id="message"
93+ name="message"
94+ rows="3"
95+ required
96+ >
97+ {`Edited ${filename}`}
98+ </textarea>
99+ </div>
100+ <div class="form-actions">
101+ <button type="submit" class="btn btn-primary">
102+ Commit changes
103+ </button>
104+ <a
105+ href={`/${repo.name}/blob/${editRef}/${filePath}`}
106+ class="btn btn-ghost"
107+ >
108+ Cancel
109+ </a>
110+ </div>
111+ </div>
112+ </form>
113+ </div>
114+ </Layout>
115+ );
116+}
Mtests/e2e.test.ts
@@ -2590,3 +2590,125 @@ describe('repo sorting and pinning', () => {
25902590 } finally { await page.close(); }
25912591 });
25922592 });
2593+
2594+// ─── File editing ─────────────────────────────────────────────────────────────
2595+
2596+describe('file editing', () => {
2597+ let adminCtx: BrowserContext;
2598+
2599+ beforeAll(async () => {
2600+ adminCtx = await loggedInContext();
2601+ // Create a dedicated repo so edits don't interfere with other tests
2602+ const page = await adminCtx.newPage();
2603+ try {
2604+ await page.goto(`${BASE}/new`);
2605+ await page.fill('[name=name]', 'edit-repo');
2606+ await page.click('form[action="/new"] button[type=submit]');
2607+ await page.waitForURL(`${BASE}/edit-repo`);
2608+ } finally { await page.close(); }
2609+ await seedRepo('edit-repo');
2610+ });
2611+
2612+ afterAll(async () => { await adminCtx.close(); });
2613+
2614+ test('Edit button appears on text file blob when viewing a branch as admin', async () => {
2615+ const page = await adminCtx.newPage();
2616+ try {
2617+ await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
2618+ const editBtn = page.locator('a[href*="/edit/main/index.js"]');
2619+ expect(await editBtn.isVisible()).toBe(true);
2620+ expect(await editBtn.textContent()).toBe('Edit');
2621+ } finally { await page.close(); }
2622+ });
2623+
2624+ test('Edit button does not appear when viewing a commit SHA', async () => {
2625+ const sha = await getHeadCommit('edit-repo');
2626+ const page = await adminCtx.newPage();
2627+ try {
2628+ await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
2629+ expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
2630+ } finally { await page.close(); }
2631+ });
2632+
2633+ test('Edit button does not appear for unauthenticated visitors', async () => {
2634+ const ctx = await browser.newContext();
2635+ const page = await ctx.newPage();
2636+ try {
2637+ await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
2638+ expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
2639+ } finally {
2640+ await page.close();
2641+ await ctx.close();
2642+ }
2643+ });
2644+
2645+ test('edit page loads with file content pre-filled', async () => {
2646+ const page = await adminCtx.newPage();
2647+ try {
2648+ await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2649+ expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
2650+ const content = await page.locator('textarea[name=content]').inputValue();
2651+ expect(content).toContain('hello');
2652+ const msg = await page.locator('textarea[name=message]').inputValue();
2653+ expect(msg).toBe('Edited index.js');
2654+ } finally { await page.close(); }
2655+ });
2656+
2657+ test('edit page shows which branch will be committed to', async () => {
2658+ const page = await adminCtx.newPage();
2659+ try {
2660+ await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2661+ expect(await page.content()).toContain('main');
2662+ } finally { await page.close(); }
2663+ });
2664+
2665+ test('edit page returns 404 for non-branch ref', async () => {
2666+ const sha = await getHeadCommit('edit-repo');
2667+ const page = await adminCtx.newPage();
2668+ try {
2669+ const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
2670+ expect(resp.status()).toBe(404);
2671+ } finally { await page.close(); }
2672+ });
2673+
2674+ test('submitting edit creates a new commit and redirects to blob view', async () => {
2675+ const page = await adminCtx.newPage();
2676+ try {
2677+ await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2678+ await page.fill('textarea[name=content]', 'console.log("edited");\n');
2679+ await page.fill('textarea[name=message]', 'Update index.js via web editor');
2680+ await page.locator('.form-actions button[type=submit]').click();
2681+ await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
2682+ // The commit detail view should show the commit message
2683+ expect(await page.content()).toContain('Update index.js via web editor');
2684+ } finally { await page.close(); }
2685+ });
2686+
2687+ test('edit commit has a gpgsig header (is signed)', async () => {
2688+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
2689+ const hash = (
2690+ await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet()
2691+ ).text().trim();
2692+ expect(hash).toBeTruthy();
2693+ const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2694+ expect(obj).toContain('gpgsig');
2695+ });
2696+
2697+ test('edit commit shows verified badge in commit log', async () => {
2698+ const page = await adminCtx.newPage();
2699+ try {
2700+ await page.goto(`${BASE}/edit-repo/commits/main`);
2701+ const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
2702+ expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
2703+ } finally { await page.close(); }
2704+ });
2705+
2706+ test('GET edit page returns 404 for non-branch ref', async () => {
2707+ const sha = await getHeadCommit('edit-repo');
2708+ const page = await adminCtx.newPage();
2709+ try {
2710+ const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
2711+ expect(resp.status()).toBe(404);
2712+ } finally { await page.close(); }
2713+ });
2714+});