Adds DiffSplitView component for side-by-side diff mode, whitespace-only change filtering, inline review comments with thread/gutter cell UI, diff preferences persistence, and write-file API support for in-browser editing. Backend: hideWhitespace param on git diff endpoint, write_file route.
115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
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, hideWhitespace = false) {
|
|
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, hideWhitespace)
|
|
.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, hideWhitespace]);
|
|
|
|
// Re-run refresh when mode changes (user pinned a new mode).
|
|
useEffect(() => {
|
|
if (!projectId) {
|
|
setResult(null);
|
|
return;
|
|
}
|
|
refresh();
|
|
}, [projectId, mode, hideWhitespace]); // 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 };
|
|
}
|