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:
@@ -0,0 +1,178 @@
|
||||
import React, { act } from 'react';
|
||||
import { expect } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
|
||||
export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached') {
|
||||
const dom = new Window({ url: 'http://localhost' });
|
||||
// Highlighting is unrelated to patch ownership. Keep that browser I/O
|
||||
// pending while exercising the real view, menus and Git adapter.
|
||||
class PendingHighlightWorker extends EventTarget {
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries({
|
||||
window: dom, Window: dom.Window, document: dom.document, navigator: dom.navigator, location: dom.location, localStorage: dom.localStorage,
|
||||
Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement, Node: dom.Node,
|
||||
ShadowRoot: dom.ShadowRoot, Document: dom.Document, Worker: PendingHighlightWorker,
|
||||
SVGElement: dom.SVGElement, DocumentFragment: dom.DocumentFragment, Text: dom.Text, Range: dom.Range,
|
||||
customElements: dom.customElements, CSSStyleSheet: dom.CSSStyleSheet,
|
||||
Event: dom.Event, CustomEvent: dom.CustomEvent, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent,
|
||||
MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver, IntersectionObserver: dom.IntersectionObserver,
|
||||
getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
|
||||
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
|
||||
})) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const originalFetch = globalThis.fetch;
|
||||
// Unrelated bootstrap I/O stays pending; the Git adapter below owns this
|
||||
// scenario's reads and mutations. No real account or filesystem is touched.
|
||||
globalThis.fetch = Object.assign(async () => new Promise<Response>(() => {}), originalFetch);
|
||||
const { I18nProvider } = await import('@/lib/i18n');
|
||||
const { RuntimeAPIContext } = await import('@/contexts/runtimeAPIContext');
|
||||
const { createWebAPIs } = await import('../../../../web/src/api/index');
|
||||
const { MultiFileDiffEntry } = await import('@/components/views/DiffView');
|
||||
const { SyncProvider } = await import('@/sync/sync-context');
|
||||
const { opencodeClient } = await import('@/lib/opencode/client');
|
||||
const { useGitStore } = await import('@/stores/useGitStore');
|
||||
const changes = [1, 25, 50];
|
||||
let remaining = [...changes];
|
||||
let fullContext = snapshotCase === 'cold';
|
||||
let currentVersion = snapshotCase === 'cold' ? 2 : 1;
|
||||
let fullVersion = 1;
|
||||
let deferVersions = snapshotCase === 'cold';
|
||||
let releaseFull: (() => void) | undefined;
|
||||
let releaseCanonical: (() => void) | undefined;
|
||||
let openedPatch: string | null = null;
|
||||
let historical = false;
|
||||
let failReads = false;
|
||||
let normalReads = 0;
|
||||
let mutations = 0;
|
||||
const makePatch = (full: boolean, version = currentVersion) => {
|
||||
const header = `diff --git a/file.txt b/file.txt\nindex ${'a'.repeat(40)}..${String(version).repeat(40)} 100644\n--- a/file.txt\n+++ b/file.txt\n`;
|
||||
const ranges = full ? [[0, 60]] : remaining.map((line) => [Math.max(0, line - 3), line + 4]);
|
||||
return header + ranges.map(([start, end]) => `@@ -${start + 1},${end - start} +${start + 1},${end - start} @@\n` +
|
||||
Array.from({ length: end - start }, (_, offset) => {
|
||||
const index = start + offset;
|
||||
return remaining.includes(index) ? `-line${index}\n+v${version}-changed${index}\n` : ` line${index}\n`;
|
||||
}).join('')).join('');
|
||||
};
|
||||
const file = { path: 'file.txt', index: 'M', working_dir: 'M', insertions: 3, deletions: 3, isNew: false };
|
||||
const status: GitStatus = { current: 'feature', tracking: null, ahead: 0, behind: 0, files: [file], isClean: false, diffStats: {} };
|
||||
const base = createWebAPIs();
|
||||
const apis = { ...base, git: { ...base.git,
|
||||
checkIsGitRepository: async () => true,
|
||||
getGitStatus: async () => status,
|
||||
getGitDiff: async (_directory: string, options: { path?: string; staged?: boolean; contextLines?: number }) => {
|
||||
if (failReads) throw new Error('Refresh unavailable');
|
||||
if (options.contextLines === 3) normalReads += 1;
|
||||
const full = (options.contextLines ?? 3) > 3;
|
||||
const response = { diff: makePatch(full, full ? fullVersion : currentVersion) };
|
||||
if (deferVersions) return new Promise<{ diff: string }>((resolve) => {
|
||||
if (full) releaseFull = () => resolve(response);
|
||||
else releaseCanonical = () => resolve(response);
|
||||
});
|
||||
return response;
|
||||
},
|
||||
stageGitHunk: async (_directory: string, _path: string, patch: string) => {
|
||||
expect(patch).toContain(`+v${currentVersion}-changed${remaining[0]}\n`);
|
||||
mutations += 1;
|
||||
remaining = remaining.slice(1);
|
||||
},
|
||||
} };
|
||||
useGitStore.getState().setActiveDirectory('/repo');
|
||||
const container = document.createElement('div');
|
||||
container.dataset.diffVirtualRoot = '';
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const render = () => act(async () => root.render(<I18nProvider><SyncProvider sdk={opencodeClient.getSdkClient()} directory=""><RuntimeAPIContext.Provider value={apis}>
|
||||
<MultiFileDiffEntry directory="/repo" file={file} layout="inline" wrapLines={false} isSelected={false}
|
||||
isExpanded isMounted onSelect={() => {}} onExpandedChange={() => {}} registerSectionRef={() => {}}
|
||||
showOpenInEditorAction onOpenInEditor={(_path, diff) => { openedPatch = diff?.patch ?? null; }}
|
||||
hunkActionsEnabled={!historical} loadFullFiles={fullContext}
|
||||
initialDiffData={historical ? { original: '', modified: '', patch: makePatch(false), contextMode: 'patch' } : null} />
|
||||
</RuntimeAPIContext.Provider></SyncProvider></I18nProvider>));
|
||||
const click = async (selector: string) => {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (!element) throw new Error(`Missing ${selector}`);
|
||||
await act(async () => element.click());
|
||||
};
|
||||
const openHunks = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>('button[aria-label="Hunks"]');
|
||||
if (!trigger) throw new Error('Missing hunk trigger');
|
||||
if (trigger.disabled) throw new Error(`Hunk trigger disabled: ${container.textContent}`);
|
||||
await act(async () => {
|
||||
trigger.focus();
|
||||
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||
});
|
||||
};
|
||||
try {
|
||||
await render();
|
||||
if (snapshotCase) {
|
||||
if (snapshotCase === 'cold') {
|
||||
if (!releaseFull || !releaseCanonical) throw new Error('Both snapshot reads must start');
|
||||
await act(async () => { releaseFull?.(); releaseCanonical?.(); });
|
||||
} else {
|
||||
currentVersion = 2;
|
||||
fullVersion = 2;
|
||||
fullContext = true;
|
||||
await render();
|
||||
}
|
||||
expect(container.textContent).toContain('Refresh the diff and try again');
|
||||
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
|
||||
expect(mutations).toBe(0);
|
||||
deferVersions = false;
|
||||
fullVersion = currentVersion;
|
||||
const retry = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Retry');
|
||||
if (!retry) throw new Error('Missing snapshot retry');
|
||||
await act(async () => retry.click());
|
||||
expect(container.textContent).toContain('Hunks · 3');
|
||||
expect(normalReads).toBe(2);
|
||||
await click('button[title="Open this file in editor at change"]');
|
||||
expect(openedPatch).toContain('+v2-changed1\n');
|
||||
await openHunks();
|
||||
await click('[role="menuitem"][aria-label="Stage hunk 1"]');
|
||||
expect(mutations).toBe(1);
|
||||
return;
|
||||
}
|
||||
expect(container.textContent).toContain('Hunks · 3');
|
||||
expect(normalReads).toBe(1);
|
||||
fullContext = true;
|
||||
await render();
|
||||
expect(container.textContent).toContain('Hunks · 3');
|
||||
expect(normalReads).toBe(1);
|
||||
await openHunks();
|
||||
await click('[role="menuitem"][aria-label="Stage hunk 1"]');
|
||||
expect(mutations).toBe(1);
|
||||
expect(container.textContent).toContain('Hunks · 2');
|
||||
expect(normalReads).toBe(2);
|
||||
failReads = true;
|
||||
await openHunks();
|
||||
await click('[role="menuitem"][aria-label="Stage hunk 1"]');
|
||||
expect(mutations).toBe(2);
|
||||
expect(container.textContent).toContain('Refresh unavailable');
|
||||
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
|
||||
failReads = false;
|
||||
const retry = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Retry');
|
||||
if (!retry) throw new Error('Missing retry');
|
||||
await act(async () => retry.click());
|
||||
expect(container.textContent).not.toContain('Refresh unavailable');
|
||||
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
|
||||
remaining = [...changes];
|
||||
historical = true;
|
||||
await render();
|
||||
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
|
||||
expect(mutations).toBe(2);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
await dom.happyDOM.close();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { act } from 'react';
|
||||
import { expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
test('a long hunk menu counts header-like content and navigates to its last action', async () => {
|
||||
const dom = new Window({ url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries({
|
||||
window: dom, Window: dom.Window, document: dom.document, navigator: dom.navigator,
|
||||
Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node, ShadowRoot: dom.ShadowRoot,
|
||||
customElements: dom.customElements, CSSStyleSheet: dom.CSSStyleSheet,
|
||||
Event: dom.Event, CustomEvent: dom.CustomEvent, KeyboardEvent: dom.KeyboardEvent,
|
||||
MouseEvent: dom.MouseEvent, ResizeObserver: dom.ResizeObserver,
|
||||
getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
|
||||
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
|
||||
})) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('@/lib/i18n');
|
||||
const { HunkActions } = await import('./HunkActions');
|
||||
const patch = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n' + Array.from({ length: 60 }, (_, i) => `@@ -${i * 10 + 1} +${i * 10 + 1} @@\n--- a/old\n+++ b/new\n`).join('');
|
||||
const actions: string[] = [];
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const key = async (name: string, ctrlKey = false) => act(async () => {
|
||||
document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: name, ctrlKey, bubbles: true, cancelable: true }));
|
||||
});
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><HunkActions filePath="f" patch={patch} staged={false} busyHunk={null} disabled={false} onAction={(index, action) => actions.push(`${action}:${index}`)} /></I18nProvider>));
|
||||
const trigger = container.querySelector<HTMLButtonElement>('button[aria-label="Hunks"]');
|
||||
if (!trigger) throw new Error('Missing hunk trigger');
|
||||
trigger.focus();
|
||||
await key('ArrowDown');
|
||||
const menu = document.querySelector<HTMLElement>('[role="menu"]');
|
||||
if (!menu) throw new Error('Missing hunk menu');
|
||||
expect(menu.className).toContain('overflow-y-auto');
|
||||
expect(menu.className).toContain('max-h-');
|
||||
const first = menu.querySelector('[aria-label="Stage hunk 1"]');
|
||||
expect(first?.textContent).toContain('+1');
|
||||
expect(first?.textContent).toContain('-1');
|
||||
await key('End');
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('Discard hunk 60');
|
||||
await key('p', true);
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('Stage hunk 60');
|
||||
await key('n', true);
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('Discard hunk 60');
|
||||
expect(document.activeElement?.getAttribute('data-variant')).toBe('destructive');
|
||||
const last = document.activeElement;
|
||||
if (!(last instanceof HTMLElement)) throw new Error('Missing final action');
|
||||
await act(async () => last.click());
|
||||
expect(actions).toEqual(['discard:59']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
await dom.happyDOM.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { splitPatchIntoHunks } from '@/lib/diff/patchFileDiff';
|
||||
|
||||
export type HunkDiffAction = 'stage' | 'unstage' | 'discard';
|
||||
|
||||
export type HunkBusyState = {
|
||||
index: number;
|
||||
action: HunkDiffAction;
|
||||
} | null;
|
||||
|
||||
interface HunkActionsProps {
|
||||
filePath: string;
|
||||
patch: string;
|
||||
staged: boolean;
|
||||
busyHunk: HunkBusyState;
|
||||
disabled: boolean;
|
||||
onAction: (hunkIndex: number, action: HunkDiffAction) => void;
|
||||
}
|
||||
|
||||
interface HunkSummary {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
const summarizeHunks = (patch: string): HunkSummary[] =>
|
||||
splitPatchIntoHunks(patch).map((hunkPatch) => {
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
let inBody = false;
|
||||
for (const line of hunkPatch.split('\n')) {
|
||||
if (line.startsWith('@@ ')) { inBody = true; continue; }
|
||||
if (!inBody) continue;
|
||||
if (line.startsWith('+')) insertions += 1;
|
||||
else if (line.startsWith('-')) deletions += 1;
|
||||
}
|
||||
return { insertions, deletions };
|
||||
});
|
||||
|
||||
const HunkCounts = React.memo<{ insertions: number; deletions: number }>(function HunkCounts({
|
||||
insertions,
|
||||
deletions,
|
||||
}) {
|
||||
if (insertions === 0 && deletions === 0) return null;
|
||||
return (
|
||||
<span className="ml-auto pl-3 typography-micro">
|
||||
{insertions > 0 ? (
|
||||
<span style={{ color: 'var(--status-success)' }}>+{insertions}</span>
|
||||
) : null}
|
||||
{insertions > 0 && deletions > 0 ? (
|
||||
<span className="mx-0.5 text-muted-foreground">/</span>
|
||||
) : null}
|
||||
{deletions > 0 ? (
|
||||
<span style={{ color: 'var(--status-error)' }}>-{deletions}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export const HunkActions = React.memo<HunkActionsProps>(function HunkActions({
|
||||
filePath,
|
||||
patch,
|
||||
staged,
|
||||
busyHunk,
|
||||
disabled,
|
||||
onAction,
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const hunks = useMemo(() => summarizeHunks(patch), [patch]);
|
||||
|
||||
if (hunks.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryAction: HunkDiffAction = staged ? 'unstage' : 'stage';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
className={dropdownTriggerVariants({ size: 'sm' })}
|
||||
aria-label={t('diffView.hunk.label')}
|
||||
title={t('diffView.hunk.label')}
|
||||
>
|
||||
<Icon name="stack" className="size-3.5" />
|
||||
<span>{t('diffView.hunk.label')} · {hunks.length}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6} className="w-64 max-h-[min(24rem,var(--available-height))] overflow-y-auto">
|
||||
<DropdownMenuLabel className="max-w-full truncate" title={filePath}>
|
||||
{t('diffView.hunk.label')} · {filePath}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{hunks.map((hunk, index) => {
|
||||
const displayIndex = index + 1;
|
||||
const busyPrimary = busyHunk?.index === index && busyHunk.action === primaryAction;
|
||||
const busyDiscard = busyHunk?.index === index && busyHunk.action === 'discard';
|
||||
const primaryTitle = staged
|
||||
? t('diffView.hunk.unstageTitle', { index: displayIndex })
|
||||
: t('diffView.hunk.stageTitle', { index: displayIndex });
|
||||
const discardTitle = t('diffView.hunk.discardTitle', { index: displayIndex });
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
disabled={disabled || busyDiscard}
|
||||
onSelect={() => onAction(index, primaryAction)}
|
||||
aria-label={primaryTitle}
|
||||
title={primaryTitle}
|
||||
>
|
||||
{busyPrimary ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name={staged ? 'arrow-go-back' : 'add'} className="size-3.5" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{primaryTitle}</span>
|
||||
<HunkCounts insertions={hunk.insertions} deletions={hunk.deletions} />
|
||||
</DropdownMenuItem>
|
||||
{!staged ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={disabled || busyPrimary}
|
||||
onSelect={() => onAction(index, 'discard')}
|
||||
aria-label={discardTitle}
|
||||
title={discardTitle}
|
||||
>
|
||||
{busyDiscard ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-go-back" className="size-3.5" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{discardTitle}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractHunkPatch, splitPatchIntoHunks } from "./patchFileDiff";
|
||||
import { extractHunkPatch, splitPatchIntoHunks, haveMatchingPatchVersions } from "./patchFileDiff";
|
||||
|
||||
const SAMPLE_PATCH = `diff --git a/foo.txt b/foo.txt
|
||||
index 1111111..2222222 100644
|
||||
@@ -67,6 +67,22 @@ describe("splitPatchIntoHunks", () => {
|
||||
});
|
||||
|
||||
describe("extractHunkPatch", () => {
|
||||
test("pairs display and action patches only with identical full blob identities and file headers", () => {
|
||||
const patch = (hash: string, file = 'f') => `diff --git a/${file} b/${file}\nindex ${'a'.repeat(40)}..${hash} 100644\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-a\n+b\n`;
|
||||
const first = patch('b'.repeat(40));
|
||||
expect(haveMatchingPatchVersions(first, first)).toBe(true);
|
||||
expect(haveMatchingPatchVersions(first, patch('c'.repeat(40)))).toBe(false);
|
||||
expect(haveMatchingPatchVersions(first, patch('b'.repeat(40), 'other'))).toBe(false);
|
||||
expect(haveMatchingPatchVersions(SAMPLE_PATCH, SAMPLE_PATCH)).toBe(false);
|
||||
});
|
||||
test("preserves CRLF content and mixed endings byte for byte", () => {
|
||||
const header = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n';
|
||||
const first = '@@ -1,2 +1,2 @@\n-before\r\n+after\r\n context\n';
|
||||
const second = '@@ -20 +20 @@\n-old\n+new\r\n';
|
||||
expect(splitPatchIntoHunks(header + first + second)).toEqual([header + first, header + second]);
|
||||
expect(extractHunkPatch(header + first + second, 1)).toBe(header + second);
|
||||
});
|
||||
|
||||
test("returns the standalone patch for the requested index", () => {
|
||||
const second = extractHunkPatch(SAMPLE_PATCH, 1);
|
||||
expect(second).not.toBeNull();
|
||||
|
||||
@@ -13,6 +13,21 @@ const patchFileDiffCache = new Map<string, FileDiffMetadata>();
|
||||
export const isBinaryPatch = (patch: string): boolean =>
|
||||
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
||||
|
||||
const patchVersionHeader = (patch: string): string | null => {
|
||||
const firstHunk = patch.search(/^@@\s/m);
|
||||
if (firstHunk < 0) return null;
|
||||
const header = patch.slice(0, firstHunk);
|
||||
// getDiff requests full object IDs. Do not infer identity from abbreviated
|
||||
// hashes or from changed-line totals, which survive partial staging.
|
||||
return /^index (?:[a-f0-9]{40}|[a-f0-9]{64})\.\.(?:[a-f0-9]{40}|[a-f0-9]{64})(?: [0-7]+)?$/m.test(header)
|
||||
? header : null;
|
||||
};
|
||||
|
||||
export const haveMatchingPatchVersions = (displayPatch: string, actionPatch: string): boolean => {
|
||||
const displayHeader = patchVersionHeader(displayPatch);
|
||||
return displayHeader !== null && displayHeader === patchVersionHeader(actionPatch);
|
||||
};
|
||||
|
||||
export const fileDiffFromPatch = (
|
||||
file: string,
|
||||
patch: string,
|
||||
@@ -141,32 +156,15 @@ const emptyFileDiff = (file: string): FileDiffMetadata =>
|
||||
export const splitPatchIntoHunks = (patch: string): string[] => {
|
||||
if (!patch) return [];
|
||||
|
||||
const lines = patch.split(/\r?\n/);
|
||||
const hunkHeaderRegex = /^@@\s/;
|
||||
const headerLines: string[] = [];
|
||||
let firstHunk = 0;
|
||||
while (firstHunk < lines.length && !hunkHeaderRegex.test(lines[firstHunk] ?? '')) {
|
||||
headerLines.push(lines[firstHunk]);
|
||||
firstHunk += 1;
|
||||
}
|
||||
|
||||
if (firstHunk >= lines.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hunks: string[][] = [];
|
||||
for (let index = firstHunk; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (hunkHeaderRegex.test(line ?? '')) {
|
||||
hunks.push([...headerLines, line]);
|
||||
} else if (hunks.length > 0) {
|
||||
hunks[hunks.length - 1].push(line ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return hunks.map((hunkLines) => hunkLines.join('\n'))
|
||||
.filter((hunk) => hunk.trim().length > 0)
|
||||
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
||||
// Git's structural newlines are LF. A CR before LF inside a hunk belongs
|
||||
// to the file contents and must survive an apply/reverse round trip.
|
||||
const starts = [...patch.matchAll(/^@@\s/gm)].map((match) => match.index);
|
||||
if (starts.length === 0) return [];
|
||||
const header = patch.slice(0, starts[0]);
|
||||
return starts.map((start, index) => {
|
||||
const hunk = header + patch.slice(start, starts[index + 1] ?? patch.length);
|
||||
return hunk.endsWith('\n') ? hunk : `${hunk}\n`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user