feat(git): safely apply individual diff hunks (#3443)

Resolve the Changes-view conflict without reverting branch or commit comparisons. Pair canonical action patches with displayed blob identities, refresh all path views after mutation, exclude historical snapshots, and reject stale or multi-file patches at the server boundary.

Preserve CRLF bytes and make long hunk menus keyboard-reachable. Workspace type-check, lint and build passed; focused parser, menu, view and real Git regressions passed.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 19:39:28 +03:00
10 changed files with 662 additions and 72 deletions
+109 -17
View File
@@ -38,6 +38,7 @@ import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
import type { DiffViewMode } from '@/components/chat/message/types';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { PierreDiffViewer } from './PierreDiffViewer';
import { HunkActions, type HunkBusyState, type HunkDiffAction } from './git/HunkActions';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
@@ -47,7 +48,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store';
import { fileDiffFromPatch, isBinaryPatch } from '@/lib/diff/patchFileDiff';
import { fileDiffFromPatch, isBinaryPatch, extractHunkPatch, haveMatchingPatchVersions } from '@/lib/diff/patchFileDiff';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startReviewFlow } from '@/lib/reviewFlow';
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
@@ -621,9 +622,11 @@ interface MultiFileDiffEntryProps {
onRetryComparisonDiff?: () => void;
/** Hide stage/unstage/revert actions for branch and commit comparisons. */
readOnlyActions?: boolean;
/** Hunk mutations require a live working/index diff, never a turn snapshot. */
hunkActionsEnabled?: boolean;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
directory,
file,
layout,
@@ -643,6 +646,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
comparisonDiff,
onRetryComparisonDiff,
readOnlyActions = false,
hunkActionsEnabled = false,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
@@ -661,6 +665,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const diffLoadError = comparisonDiff ? (comparisonDiff.status === 'error' ? comparisonDiff.message : null) : localDiffLoadError;
const isLoading = comparisonDiff ? comparisonDiff.status === 'loading' : isFetching;
const [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
const [hunkAction, setHunkAction] = React.useState<HunkBusyState>(null);
const mutationInFlight = React.useRef(false);
const [canonicalPatch, setCanonicalPatch] = React.useState<{ scope: string; patch: string } | null>(null);
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
@@ -671,6 +678,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const renderSideBySide = layout === 'side-by-side';
const desiredContextMode: DiffContextMode = loadFullFiles ? 'full' : 'patch';
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
const hunkEligible = hunkActionsEnabled && !readOnlyActions && !initialDiffData && !comparisonDiff && !isImageFile(file.path);
const patchScope = JSON.stringify([getRuntimeKey(), directory, file.path, staged, fileStatusKey, diffRetryNonce]);
const actionPatch = canonicalPatch?.scope === patchScope ? canonicalPatch.patch : null;
const diffData = React.useMemo<DiffData | null>(() => {
if (comparisonDiff) return comparisonDiff.status === 'ready' ? comparisonDiff.data : null;
@@ -709,7 +719,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
React.useEffect(() => {
if (!isExpanded || !isMounted) return;
if (!directory || comparisonDiff || initialDiffData || (diffData && diffDataMatchesContextMode)) {
if (localDiffLoadError) return;
if (!directory || comparisonDiff || initialDiffData || (diffData && diffDataMatchesContextMode && (!hunkEligible || actionPatch !== null))) {
lastDiffRequestRef.current = null;
setIsLoading(false);
return;
@@ -726,17 +737,32 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
let cancelled = false;
const runtimeKey = getRuntimeKey();
const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES;
const fetchPromise = isImageFile(file.path)
const displayRequest = isImageFile(file.path)
? git.getGitFileDiff(directory, { path: file.path, staged })
: !loadFullFiles && actionPatch !== null
? Promise.resolve({ diff: actionPatch })
: git.getGitDiff(directory, { path: file.path, staged, contextLines });
const canonicalRequest = hunkEligible && loadFullFiles && actionPatch === null
? git.getGitDiff(directory, { path: file.path, staged, contextLines: DEFAULT_CONTEXT_DIFF_LINES }).then((response) => response.diff)
: Promise.resolve(actionPatch);
const fetchPromise = Promise.all([displayRequest, canonicalRequest]);
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
void Promise.race([fetchPromise, timeoutPromise])
.then((response) => {
if (cancelled) return;
.then(([response, normalPatch]) => {
if (cancelled || runtimeKey !== getRuntimeKey()) return;
const patch = 'diff' in response && !loadFullFiles ? response.diff : normalPatch;
if (hunkEligible && loadFullFiles && patch !== null && (patch.match(/^@@\s/gm)?.length ?? 0) > 1
&& ('diff' in response && !haveMatchingPatchVersions(response.diff, patch))) {
setCanonicalPatch(null);
throw new Error(t('diffView.hunk.unavailable'));
}
if (hunkEligible && patch !== null) setCanonicalPatch({ scope: patchScope, patch });
if ('diff' in response) {
const nextDiff = createTextDiffDataFromPatch(file.path, response.diff, desiredContextMode);
@@ -761,30 +787,42 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setIsLoading(false);
})
.catch((error) => {
if (cancelled) return;
if (cancelled || runtimeKey !== getRuntimeKey()) return;
const message = error instanceof Error ? error.message : String(error);
setDiffLoadError(message);
setIsLoading(false);
});
}).finally(() => clearTimeout(timeout));
return () => {
cancelled = true;
clearTimeout(timeout);
if (lastDiffRequestRef.current === requestKey) {
lastDiffRequestRef.current = null;
}
};
}, [comparisonDiff, desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
}, [actionPatch, hunkEligible, patchScope, comparisonDiff, desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, localDiffLoadError, setDiff, staged, t]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
handleSelect();
}, [handleOpenChange, handleSelect, isExpanded]);
const invalidatePatch = React.useCallback(() => {
setDiffLoadError(null);
setCanonicalPatch(null);
setLocalDiffData(null);
setStagedDiffData(null);
lastDiffRequestRef.current = null;
setDiffRetryNonce((nonce) => nonce + 1);
}, []);
const handleFileAction = React.useCallback(async (action: FileDiffAction) => {
if (!directory || fileAction !== null) {
if (!directory || mutationInFlight.current || fileAction !== null) {
return;
}
mutationInFlight.current = true;
const runtimeKey = getRuntimeKey();
setFileAction(action);
try {
if (action === 'stage') {
@@ -794,7 +832,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
} else {
await git.revertGitFile(directory, file.path, { scope: 'working' });
}
setDiffRetryNonce((nonce) => nonce + 1);
if (runtimeKey !== getRuntimeKey()) return;
invalidatePatch();
sessionEvents.requestGitRefresh({ directory, paths: [file.path] });
await fetchStatus(directory, git);
} catch (error) {
const fallbackKey = action === 'unstage'
@@ -804,9 +844,50 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
: 'gitView.toast.revertFailed';
toast.error(error instanceof Error ? error.message : t(fallbackKey));
} finally {
mutationInFlight.current = false;
setFileAction((current) => (current === action ? null : current));
}
}, [directory, fetchStatus, file.path, fileAction, git, t]);
}, [directory, fetchStatus, file.path, fileAction, git, invalidatePatch, t]);
const handleHunkAction = React.useCallback(async (hunkIndex: number, action: HunkDiffAction) => {
if (!directory || !hunkEligible || isLoading || diffLoadError || mutationInFlight.current || hunkAction !== null || fileAction !== null) {
return;
}
const hunkPatch = actionPatch ? extractHunkPatch(actionPatch, hunkIndex) : null;
if (!hunkPatch) {
toast.error(t('diffView.hunk.unavailable'));
return;
}
if ((staged && action !== 'unstage') || (!staged && action === 'unstage')) return;
mutationInFlight.current = true;
const runtimeKey = getRuntimeKey();
setHunkAction({ index: hunkIndex, action });
try {
const hunkMutation = action === 'stage'
? git.stageGitHunk
: action === 'unstage'
? git.unstageGitHunk
: git.revertGitHunk;
if (!hunkMutation) {
toast.error(t('diffView.hunk.unsupported'));
return;
}
await hunkMutation(directory, file.path, hunkPatch);
if (runtimeKey !== getRuntimeKey()) return;
invalidatePatch();
sessionEvents.requestGitRefresh({ directory, paths: [file.path] });
await fetchStatus(directory, git);
} catch (error) {
if (runtimeKey !== getRuntimeKey()) return;
invalidatePatch();
toast.error(error instanceof Error && error.message ? error.message : t('diffView.hunk.unavailable'));
} finally {
mutationInFlight.current = false;
setHunkAction((current) => (current?.index === hunkIndex && current.action === action ? null : current));
}
}, [actionPatch, hunkEligible, isLoading, diffLoadError, directory, fetchStatus, file.path, fileAction, git, hunkAction, invalidatePatch, staged, t]);
return (
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
@@ -936,7 +1017,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => comparisonDiff ? onRetryComparisonDiff?.() : setDiffRetryNonce((nonce) => nonce + 1)}
onClick={() => comparisonDiff ? onRetryComparisonDiff?.() : invalidatePatch()}
>
{t('diffView.actions.retry')}
</button>
@@ -974,13 +1055,23 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
wrapLines={wrapLines}
/>
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
<div className="pointer-events-auto">
<div className="pointer-events-auto flex items-center gap-1.5">
{hunkEligible && actionPatch !== null ? (
<HunkActions
filePath={file.path}
patch={actionPatch}
staged={staged}
busyHunk={hunkAction}
disabled={isLoading || Boolean(diffLoadError) || fileAction !== null || hunkAction !== null}
onAction={handleHunkAction}
/>
) : null}
{!readOnlyActions ? (
<FileDiffActions
filePath={file.path}
staged={staged}
busyAction={fileAction}
disabled={fileAction !== null}
disabled={fileAction !== null || hunkAction !== null}
onAction={handleFileAction}
/>
) : null}
@@ -1896,7 +1987,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
{changedFiles.map((file) => (
<MultiFileDiffEntry
key={`${file.path}:${fileDiffRefreshNonce.get(file.path) ?? 0}`}
key={`${getRuntimeKey()}:${effectiveDirectory}:${file.path}:${fileDiffRefreshNonce.get(file.path) ?? 0}`}
directory={effectiveDirectory}
file={file}
layout={getLayoutForFile(file)}
@@ -1915,6 +2006,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
staged={getFileStaged(file.path)}
loadFullFiles={loadFullFiles}
readOnlyActions={activeDiffScope === 'branch' || activeDiffScope === 'commit'}
hunkActionsEnabled={activeDiffScope === 'all' || activeDiffScope === 'working' || activeDiffScope === 'staged'}
comparisonDiff={activeDiffScope === 'branch' || activeDiffScope === 'commit'
? comparisonDiffData.get(file.path) ?? EMPTY_COMPARISON_DIFF
: undefined}