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:
114
apps/web/src/hooks/useGitDiff.ts
Normal file
114
apps/web/src/hooks/useGitDiff.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user