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.
This commit is contained in:
2026-06-03 03:18:41 +00:00
parent 5da72c120a
commit bee5597108
14 changed files with 2290 additions and 49 deletions

View File

@@ -178,6 +178,12 @@ export interface RefetchMessagesEvent {
type: 'refetch_messages';
}
// git-diff-panel Phase 1: emitted client-side to trigger a panel refresh.
// Not a WS frame — no @boocode/contracts change required.
export interface GitDiffRefreshEvent {
type: 'git_diff_refresh';
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -204,7 +210,8 @@ export type SessionEvent =
| ProjectUnarchivedEvent
| ProjectUpdatedEvent
| ChatStatusEvent
| RefetchMessagesEvent;
| RefetchMessagesEvent
| GitDiffRefreshEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/api/client';
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
import { sessionEvents } from './sessionEvents';
export function useGitDiff(projectId: string | null | undefined) {
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
const [pinned, setPinned] = useState(false);
const [result, setResult] = useState<GitDiffResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// FIX 4: non-null when user has pinned a mode that differs from the server's auto-selected mode.
const [modeSuggestion, setModeSuggestion] = useState<GitDiffMode | null>(null);
// Coalescence guard: absorb concurrent refresh triggers into the running request.
const inFlightRef = useRef(false);
const refresh = useCallback(() => {
if (!projectId || inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
setError(null);
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
// dirty state (dirty → uncommitted, clean → committed).
api.projects
.gitDiff(projectId, pinned ? mode : null)
.then((r) => {
if (!pinned) {
setMode(r.mode);
}
// FIX 4: if pinned and the server's auto-selected mode differs, surface a suggestion.
if (pinned && r.auto_mode && r.auto_mode !== mode) {
setModeSuggestion(r.auto_mode);
} else {
setModeSuggestion(null);
}
setResult(r);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Failed to load diff');
})
.finally(() => {
inFlightRef.current = false;
setLoading(false);
});
}, [projectId, mode, pinned]);
// Re-run refresh when mode changes (user pinned a new mode).
useEffect(() => {
if (!projectId) {
setResult(null);
return;
}
refresh();
}, [projectId, mode]); // eslint-disable-line react-hooks/exhaustive-deps
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'git_diff_refresh') refresh();
});
}, [refresh]);
const selectMode = useCallback((m: GitDiffMode) => {
setPinned(true);
setMode(m);
setModeSuggestion(null); // FIX 4: clear suggestion on explicit mode pick
}, []);
const [mutating, setMutating] = useState(false);
const [mutateError, setMutateError] = useState<string | null>(null);
const runMutation = useCallback(
async (fn: () => Promise<unknown>): Promise<boolean> => {
if (!projectId) return false;
setMutating(true);
setMutateError(null);
try {
await fn();
sessionEvents.emit({ type: 'git_diff_refresh' });
return true;
} catch (err) {
setMutateError(err instanceof Error ? err.message : 'Operation failed');
return false;
} finally {
setMutating(false);
}
},
[projectId],
);
const stage = useCallback(
(files: string[]) => runMutation(() => api.projects.gitStage(projectId!, files)),
[projectId, runMutation],
);
const unstage = useCallback(
(files: string[]) => runMutation(() => api.projects.gitUnstage(projectId!, files)),
[projectId, runMutation],
);
const commit = useCallback(
(message: string, files?: string[]) =>
runMutation(() => api.projects.gitCommit(projectId!, { message, files })),
[projectId, runMutation],
);
const discard = useCallback(
(files: GitDiscardFileInfo[]) => runMutation(() => api.projects.gitDiscard(projectId!, files)),
[projectId, runMutation],
);
return { result, loading, error, mode, selectMode, refresh, mutating, mutateError, stage, unstage, commit, discard, modeSuggestion };
}

View File

@@ -273,6 +273,10 @@ export function useSessionStream(sessionId: string | undefined) {
return;
}
setState((s) => applyFrame(s, frame));
// Trigger git diff refresh after each completed assistant turn.
if (frame.type === 'message_complete') {
sessionEvents.emit({ type: 'git_diff_refresh' });
}
} catch (err) {
console.warn('bad ws frame', err);
}

View File

@@ -186,6 +186,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_deleted':
case 'chat_status':
case 'refetch_messages':
case 'git_diff_refresh':
// Consumed by useGitDiff; no sidebar state change needed.
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);