feat: git diff panel (Files/Git tab in the file browser)

Adds a Git tab to the right-side file panel that shows the project
repository's diff and lets the user stage, unstage, commit, and discard
whole files in-session. Two comparison modes (Uncommitted vs HEAD, and the
branch vs its base — upstream tracking branch else default branch), auto-
selected by repo state on first open and pinned after explicit choice;
per-file expand/collapse with lazy syntax-highlighted diffs, +/- stats, and
binary/large-file placeholders. All git read and write logic lives in
apps/server via a new git_diff service: argv-safe execFile only (never a
shell), per-file paths validated repo-relative through pathGuard with a
realpath symlink-escape check, server-derived commit identity (the request
carries no author fields), and the write endpoints are deliberately absent
from the assistant tool registry. Reads are bounded (30s deadline, 10MB);
an index lock or an in-progress merge/rebase/cherry-pick/bisect surfaces as
"repository busy" and disables writes. The panel stays current via a client
git_diff_refresh session event (no new wire contract) coalesced across tab
open, mutations, turn completion, and pending-change apply. Discard is an
irrecoverable hard-delete behind a plain confirm that distinguishes
reverting a tracked file from deleting an untracked one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 03:18:41 +00:00
parent f32fd928b3
commit d8bb2dabfe
14 changed files with 2290 additions and 49 deletions

View File

@@ -10,6 +10,9 @@ import type {
ViewFileResult,
AgentsResponse,
GitMeta,
GitDiffMode,
GitDiffResult,
GitDiscardFileInfo,
Skill,
ToolCostStat,
ProviderSnapshotEntry,
@@ -151,6 +154,32 @@ export const api = {
request<{ files: string[] }>(`/api/projects/${id}/files`),
git: (id: string) =>
request<GitMeta>(`/api/projects/${id}/git`),
gitDiff: (id: string, mode: GitDiffMode | null) =>
request<GitDiffResult>(
mode !== null
? `/api/projects/${id}/git/diff?mode=${mode}`
: `/api/projects/${id}/git/diff`,
),
gitStage: (id: string, files: string[]) =>
request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
method: 'POST',
body: JSON.stringify({ files }),
}),
gitUnstage: (id: string, files: string[]) =>
request<{ ok: boolean }>(`/api/projects/${id}/git/unstage`, {
method: 'POST',
body: JSON.stringify({ files }),
}),
gitCommit: (id: string, body: { message: string; files?: string[] }) =>
request<{ ok: boolean }>(`/api/projects/${id}/git/commit`, {
method: 'POST',
body: JSON.stringify(body),
}),
gitDiscard: (id: string, files: GitDiscardFileInfo[]) =>
request<{ ok: boolean }>(`/api/projects/${id}/git/discard`, {
method: 'POST',
body: JSON.stringify({ files }),
}),
},
sessions: {