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
## Roadmap
- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
- Issue labels
- Repository list reordering (e.g. last committed) and starring
- redirect image urls in readme
- simple file editor
- generic diff viewer (show diff for a given path between two refs)
- registration queue
- edit patches
- show ^M in diffs
▾Msrc/routes/repos.tsx
@@ -8,6 +8,7 @@ import {
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
import { db } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath } from "../services/git.ts";
@@ -23,6 +24,7 @@ import { html } from "../views/render.tsx";
import { CommitDetail } from "../views/repos/CommitDetail.tsx";
import { CommitLog } from "../views/repos/CommitLog.tsx";
import { FileBlob } from "../views/repos/FileBlob.tsx";
import { FileEdit } from "../views/repos/FileEdit.tsx";
import { FileTree } from "../views/repos/FileTree.tsx";
import { NewRepo } from "../views/repos/NewRepo.tsx";
import { RepoHome } from "../views/repos/RepoHome.tsx";
@@ -490,6 +492,77 @@ export const repoRoutes = new Elysia()
});
})
.get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = decodeURIComponent(params["*"]);
const branches = await git.branches(repo.name);
if (!branches.includes(params.ref))
return new Response("Not found", { status: 404 });
const content = await git.show(repo.name, params.ref, filePath);
if (!content) return new Response("Not found", { status: 404 });
if (hasBinaryContent(content.subarray(0, 8000)))
return new Response("Not found", { status: 404 });
return html(
<FileEdit
user={user!}
repo={repo}
ref={params.ref}
filePath={filePath}
content={content.toString("utf-8")}
/>,
);
})
.post(
"/:repo/edit/:ref/*",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = decodeURIComponent(params["*"]);
const branches = await git.branches(repo.name);
if (!branches.includes(params.ref))
return new Response("Not found", { status: 404 });
const message = body.message?.trim() || `Edited ${path.basename(filePath)}`;
const content = (body.content ?? "").replaceAll("\r\n", "\n");
const commit = await git.editFile(
repo.name,
params.ref,
filePath,
content,
message,
COMMITTER_NAME,
COMMITTER_EMAIL,
);
return new Response(null, {
status: 302,
headers: {
Location: `/${repo.name}/commit/${commit}`,
},
});
},
{
body: t.Object({
content: t.Optional(t.String()),
message: t.Optional(t.String()),
}),
},
)
.get(
"/:repo/commits/:ref",
async ({ params, cookie, query }) => {
▾Msrc/services/git.ts
@@ -470,12 +470,7 @@ export const git = {
const commit = (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
.env({
...process.env,
LC_ALL: "C",
LANG: "C",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
GIT_CONFIG_COUNT: "0",
...gitEnv,
GIT_AUTHOR_NAME: authorName,
GIT_AUTHOR_EMAIL: authorEmail,
GIT_COMMITTER_NAME: committerName,
@@ -493,6 +488,56 @@ export const git = {
});
},
async editFile(
name: string,
branch: string,
filePath: string,
content: string,
message: string,
committerName: string,
committerEmail: string,
): Promise<string> {
return withRepoLock(name, async () => {
const p = repoPath(name);
const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
await Bun.write(tmpFile, content);
await $`git -C ${p} read-tree refs/heads/${branch}`;
const blobHash = (
await $`git -C ${p} hash-object -w ${tmpFile}`.text()
).trim();
await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
const tree = (
await $`git -C ${p} write-tree`.text()
).trim();
const parent = (
await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
).trim();
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${SSH_HOST_KEY_PATH}`,
];
const commit = (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
.env({
...gitEnv,
GIT_AUTHOR_NAME: committerName,
GIT_AUTHOR_EMAIL: committerEmail,
GIT_COMMITTER_NAME: committerName,
GIT_COMMITTER_EMAIL: committerEmail,
})
.text()
).trim();
await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
return commit;
} finally {
await $`rm -f ${tmpFile}`.quiet().nothrow();
}
});
},
async createTag(
repoName: string,
tagName: string,
▾Msrc/styles/main.css
@@ -874,6 +874,7 @@
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
margin-top: var(--space-4);
}
.file-blob-name {
font-size: var(--text-sm);
@@ -885,6 +886,24 @@
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: auto;
}
.file-edit-textarea {
display: block;
width: 100%;
box-sizing: border-box;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.6;
padding: var(--space-4);
background: var(--color-bg);
color: var(--color-text);
border: none;
resize: vertical;
min-height: 400px;
}
.file-edit-textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
/* Blob line-number table */
.blob-table {
min-width: 100%;
▾Msrc/views/DiffView.tsx
@@ -154,7 +154,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
{files.length === 0 ? (
<p class="text-muted" style="margin-top: var(--space-6)">
No diff available.
Empty diff or no diff available.
</p>
) : (
<div class="commit-layout">
▾Msrc/views/repos/FileBlob.tsx
@@ -73,6 +73,16 @@ export function FileBlob({
>
Raw
</a>
{view.type === "inline" &&
branches.includes(blobRef) &&
user?.isAdmin && (
<a
href={`/${repo.name}/edit/${blobRef}/${filePath}`}
class="btn btn-sm btn-primary"
>
Edit
</a>
)}
</div>
</div>
<div class="file-blob-body">
▾Asrc/views/repos/FileEdit.tsx
@@ -0,0 +1,116 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface FileEditProps {
user: SessionUser;
repo: RepositoryRow;
ref: string;
filePath: string;
content: string;
error?: string;
}
export function FileEdit({
user,
repo,
ref: editRef,
filePath,
content,
error,
}: FileEditProps) {
const parts = filePath.split("/");
const filename = parts[parts.length - 1] ?? filePath;
return (
<Layout user={user} title={`Edit ${repo.name}/${filePath}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${editRef}`}>{repo.name}</a>
{parts.map((part, i) => {
const partPath = parts.slice(0, i + 1).join("/");
const isLast = i === parts.length - 1;
return (
<>
<span class="breadcrumb-sep">/</span>
{isLast ? (
<span class="breadcrumb-current">
{part}
</span>
) : (
<a
href={`/${repo.name}/tree/${editRef}/${partPath}`}
>
{part}
</a>
)}
</>
);
})}
</div>
<p class="form-hint">WARNING: Line endings are normalized to LF (\n) on save.</p>
{error && <p class="form-error">{error}</p>}
<form
method="POST"
action={`/${repo.name}/edit/${editRef}/${filePath}`}
>
<div class="file-blob-header">
<span class="file-blob-name">{filename}</span>
<div class="file-blob-actions">
<a
href={`/${repo.name}/blob/${editRef}/${filePath}`}
class="btn btn-sm btn-ghost"
>
Cancel
</a>
</div>
</div>
<div class="file-blob-body">
<textarea
name="content"
class="file-edit-textarea"
rows="30"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
>
{content}
</textarea>
</div>
<div class="form-card">
<p class="form-hint" style="margin-bottom: var(--space-4);">
Committing directly to{" "}
<strong>{editRef}</strong>
</p>
<div class="form-group">
<label for="message">Commit message</label>
<textarea
id="message"
name="message"
rows="3"
required
>
{`Edited ${filename}`}
</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Commit changes
</button>
<a
href={`/${repo.name}/blob/${editRef}/${filePath}`}
class="btn btn-ghost"
>
Cancel
</a>
</div>
</div>
</form>
</div>
</Layout>
);
}
▾Mtests/e2e.test.ts
@@ -2590,3 +2590,125 @@ describe('repo sorting and pinning', () => {
} finally { await page.close(); }
});
});
// ─── File editing ─────────────────────────────────────────────────────────────
describe('file editing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo so edits don't interfere with other tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'edit-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/edit-repo`);
} finally { await page.close(); }
await seedRepo('edit-repo');
});
afterAll(async () => { await adminCtx.close(); });
test('Edit button appears on text file blob when viewing a branch as admin', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
const editBtn = page.locator('a[href*="/edit/main/index.js"]');
expect(await editBtn.isVisible()).toBe(true);
expect(await editBtn.textContent()).toBe('Edit');
} finally { await page.close(); }
});
test('Edit button does not appear when viewing a commit SHA', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
} finally { await page.close(); }
});
test('Edit button does not appear for unauthenticated visitors', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('edit page loads with file content pre-filled', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
const content = await page.locator('textarea[name=content]').inputValue();
expect(content).toContain('hello');
const msg = await page.locator('textarea[name=message]').inputValue();
expect(msg).toBe('Edited index.js');
} finally { await page.close(); }
});
test('edit page shows which branch will be committed to', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
expect(await page.content()).toContain('main');
} finally { await page.close(); }
});
test('edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('submitting edit creates a new commit and redirects to blob view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
await page.fill('textarea[name=content]', 'console.log("edited");\n');
await page.fill('textarea[name=message]', 'Update index.js via web editor');
await page.locator('.form-actions button[type=submit]').click();
await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
// The commit detail view should show the commit message
expect(await page.content()).toContain('Update index.js via web editor');
} finally { await page.close(); }
});
test('edit commit has a gpgsig header (is signed)', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
const hash = (
await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet()
).text().trim();
expect(hash).toBeTruthy();
const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
expect(obj).toContain('gpgsig');
});
test('edit commit shows verified badge in commit log', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/edit-repo/commits/main`);
const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('GET edit page returns 404 for non-branch ref', async () => {
const sha = await getHeadCommit('edit-repo');
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});