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
@@ -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();
}
}
+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}
@@ -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>
);
});
+17 -1
View File
@@ -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();
+24 -26
View File
@@ -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`;
});
};
/**