feat(git-status): click a file in the Git panel to see its diff

Rows open an in-panel diff (staged, not staged, untracked as additions, deleted as removals) via GET /api/sessions/:id/git-diff, with Back and Open file. The route matches repo and path against the current status, runs git diff read-only (--no-ext-diff --no-textconv), and caps output at 400 KB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
Devvyn
2026-10-05 09:14:07 +08:00
co-authored by Claude Sonnet 5.5
parent bfc164a262
commit cd9218c23e
8 changed files with 402 additions and 13 deletions
+55
View File
@@ -533,3 +533,58 @@ export async function getGitWorkspaceOverview(
if (!repos.length) return emptyOverview('not-a-repo');
return { state: 'ok', repos, reposTruncated: found.truncated, checkedAt: Date.now() };
}
// ── Per-file diff ──────────────────────────────────────────────────────────
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
export const MAX_DIFF_BYTES = 400 * 1024;
export interface GitFileDiff {
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
diff: string;
truncated: boolean;
binary: boolean;
}
/** A repo-relative path git reported, minus anything that could be read as an option or escape the repo. */
export function isSafeRepoRelativePath(p: string): boolean {
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('-') || p.startsWith('/')) return false;
return !p.split('/').includes('..');
}
/**
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
* `untracked` is the whole file as additions. Read-only, and `--no-ext-diff --no-textconv` keep a
* repository's own config from running programs on behalf of a click.
*/
export async function getGitFileDiff(
repoRoot: string,
file: { path: string; origPath?: string; kind: GitFileKind },
opts: { git?: GitRunner } = {}
): Promise<GitFileDiff> {
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
throw new Error('Invalid path');
}
const git = opts.git ?? runGit;
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
let args: string[];
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
else {
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
}
let out: string;
try {
out = await git(repoRoot, args);
} catch (err) {
// `--no-index` exits 1 when the files differ, which is the normal case for it.
const e = err as { code?: number; stdout?: unknown };
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
else throw err;
}
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: false, binary };
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
}
+102 -4
View File
@@ -214,6 +214,7 @@ Object.assign(CodemanApp.prototype, {
},
closeGitStatusPanel() {
this._gitDiffView = null;
const panel = this.$('gitStatusPanel');
if (panel) {
panel.classList.remove('visible');
@@ -277,6 +278,15 @@ Object.assign(CodemanApp.prototype, {
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const view = this._gitDiffView;
if (view && view.sessionId === this.activeSessionId) {
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
this._renderGitDiffView(body, view);
if (head) head.textContent = '';
if (foot) foot.textContent = '';
return;
}
this._gitDiffView = null;
body.replaceChildren();
const clearChrome = () => {
if (head) head.textContent = '';
@@ -453,13 +463,13 @@ Object.assign(CodemanApp.prototype, {
row.append(name);
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
// Deleted files and untracked folders have nothing to preview.
const previewable = letter !== 'D' && !f.path.endsWith('/') && data.repoRoot;
if (previewable) {
// An untracked folder has no single diff; every other row opens its changes.
if (!f.path.endsWith('/') && data.repoRoot) {
row.classList.add('git-status-file--clickable');
row.tabIndex = 0;
row.setAttribute('role', 'button');
const open = () => this.openFilePreview?.(`${data.repoRoot}/${f.path}`, this.activeSessionId);
row.title = 'Show what changed';
const open = () => this.openGitDiff(data.repoRoot, f, letter);
row.addEventListener('click', open);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -471,6 +481,94 @@ Object.assign(CodemanApp.prototype, {
return row;
},
// ── Diff view ───────────────────────────────────────────────────────────
/** Show `file`'s changes in the panel (a Back button returns to the list). */
async openGitDiff(repoRoot, file, letter) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
this._gitDiffView = view;
this._renderGitStatusPanel();
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
// Back, another file or another session while this was in flight: drop the answer.
if (this._gitDiffView !== view) return;
let body = null;
try {
body = res ? await res.json() : null;
} catch {
/* fall through */
}
if (this._gitDiffView !== view) return;
if (res && res.ok && body?.success) {
view.state = 'ok';
view.result = body.data;
} else {
view.state = 'error';
view.error = body?.error || 'Could not read the diff.';
}
this._renderGitStatusPanel();
},
closeGitDiff() {
this._gitDiffView = null;
this._renderGitStatusPanel();
},
_renderGitDiffView(body, view) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
body.replaceChildren();
const bar = el('div', 'git-diff-bar');
const back = el('button', 'btn-toolbar btn-sm', '← Back');
back.type = 'button';
back.addEventListener('click', () => this.closeGitDiff());
bar.append(back);
bar.append(el('span', 'git-diff-path', view.file.path));
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
if (view.letter !== 'D') {
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
open.type = 'button';
open.addEventListener('click', () =>
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
);
bar.append(open);
}
body.append(bar);
if (view.state === 'loading') {
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
return;
}
if (view.state === 'error') {
body.append(el('div', 'git-status-empty', view.error));
return;
}
const { diff, truncated, binary } = view.result;
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
if (!diff.trim()) {
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
return;
}
const pre = el('pre', 'git-diff');
const frag = document.createDocumentFragment();
for (const line of diff.split('\n')) {
let cls = 'git-diff-line';
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
else if (
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
)
cls += ' git-diff-line--meta';
else if (line.startsWith('+')) cls += ' git-diff-line--add';
else if (line.startsWith('-')) cls += ' git-diff-line--del';
frag.append(el('span', cls, line + '\n'));
}
pre.append(frag);
body.append(pre);
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
},
_gitCommitRow(c) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-commit');
+59
View File
@@ -19575,3 +19575,62 @@ html .toolbar .btn-git-status.git-status--conflict {
.git-status-repo-body {
padding: 0.5rem 0.6rem;
}
/* Git status panel: per-file diff view */
.git-diff-bar {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.4rem;
min-width: 0;
}
.git-diff-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.git-diff-kind {
font-size: 0.64rem;
color: var(--text-muted);
white-space: nowrap;
}
.git-diff {
margin: 0;
padding: 0.3rem 0;
overflow-x: auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
line-height: 1.45;
border: 1px solid var(--border);
border-radius: 4px;
}
.git-diff-line {
display: block;
padding: 0 0.5rem;
white-space: pre;
}
.git-diff-line--add {
background: color-mix(in srgb, var(--green) 18%, transparent);
}
.git-diff-line--del {
background: color-mix(in srgb, var(--red) 18%, transparent);
}
.git-diff-line--hunk {
color: var(--text-muted);
background: var(--bg-hover);
}
.git-diff-line--meta {
color: var(--text-muted);
}
+30 -1
View File
@@ -11,11 +11,14 @@
*/
import type { FastifyInstance } from 'fastify';
import type { ApiResponse } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { findSessionOrFail } from '../route-helpers.js';
import {
emptyOverview,
getGitFileDiff,
getGitWorkspaceOverview,
type GitFileDiff,
type GitFileKind,
type GitRunner,
type GitWorkspaceOverview,
} from '../../git-workspace-status.js';
@@ -30,4 +33,30 @@ export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort,
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
return { success: true, data: await getGitWorkspaceOverview(session.workingDir, { git, fresh: fresh === '1' }) };
});
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
// the route cannot be pointed at an arbitrary directory or file.
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
const { id } = req.params as { id: string };
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
}
const overview = await getGitWorkspaceOverview(session.workingDir, { git, fresh: true });
const status = overview.repos.find((r) => r.status.repoRoot === repo)?.status;
const entry = status?.files.find((f) => f.path === path && f.kind === (kind as GitFileKind));
if (!status?.repoRoot || !entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
}
try {
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `git diff failed: ${getErrorMessage(err)}`);
}
});
}