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`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,7 @@ The following functions are exported and used by the web server:
|
||||
|
||||
### Status and Diff Operations
|
||||
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree with full Git blob identities. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines, includeWorkingTree })`: Compare the merge base of the exact selected refs with `head`. With `includeWorkingTree: true`, compare with the checked-out branch's current files instead, including committed, staged, unstaged, and untracked work in one net diff. This mode rejects a head that is not the checked-out branch. Exposed as `GET /api/git/range-diff`; omit `path` for the whole comparison.
|
||||
- `getRangeFiles(directory, { base, head, includeWorkingTree })`: List changed paths using the same comparison as `getRangeDiff`. A successful empty list means the final files match the merge base, even if staging and working-tree changes cancel each other out.
|
||||
- Both range operations honor refs literally. A local `main` is never replaced with `origin/main`, and an unavailable ref fails rather than choosing a different remote. The UI picker sends qualified refs to distinguish local and remote branches with matching display names.
|
||||
@@ -37,7 +37,7 @@ The following functions are exported and used by the web server:
|
||||
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
|
||||
- `stageFile(directory, filePath)`: Add one file path to the index.
|
||||
- `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). Inside the index mutation queue, the server verifies that the complete patch exactly matches one current three-context-line hunk for that file and scope, then runs `--check` before applying. Applicability alone cannot prove an unstaged change: old staged or committed hunks can reverse cleanly too. Stale, historical and multi-file patches fail with a refresh error. Temporary patch files are removed on success and failure; hunk content retains CRLF bytes.
|
||||
|
||||
### Branch Operations
|
||||
- `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
|
||||
@@ -137,6 +137,15 @@ The following functions are internal helpers used by exported functions:
|
||||
- Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. Desktop Changes, mobile Changes, and the existing walkthrough surface share branch/commit comparison semantics. Mobile Changes uses the same selectors and `useGitComparison` file-list owner, with a read-only list-to-detail flow. VS Code keeps its existing modes because its Git bridge does not provide these comparison operations. The HTTP operations are available to web, Electron, hosted mobile, and Capacitor clients.
|
||||
|
||||
### Staged and unstaged change handling
|
||||
- Desktop Changes keeps a canonical three-line-context action patch separate
|
||||
from its full-file display patch. Multi-hunk actions require identical file
|
||||
headers and full blob identities for the display/action pair, including cached
|
||||
action patches; mismatch or missing identity leaves actions unavailable until
|
||||
Retry obtains a matching pair. Successful file/hunk mutations invalidate
|
||||
every mounted view of that path through `sessionEvents.requestGitRefresh`.
|
||||
Actions remain unavailable until the refresh succeeds. Last turn, Branch and
|
||||
Commit snapshots never expose hunk mutations. Mobile uses its separate Changes
|
||||
surface and VS Code does not mount this menu.
|
||||
- Untracked patches from `getDiff` and `getUntrackedDiffs` use `git diff --no-index` with separate stdout, stderr, and process exit status. Exit codes 0 and 1 return stdout only, so line-ending warnings never become patch text or request failures. Other exits and process failures reject the single-file request; the batch keeps an empty entry for the failed path and preserves the other results.
|
||||
- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
|
||||
- A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
|
||||
|
||||
@@ -2466,7 +2466,7 @@ export async function getStatus(directory, options = {}) {
|
||||
}
|
||||
|
||||
const getNoIndexDiff = async (repoRoot, repoPath, contextLines) => {
|
||||
const args = ['diff', '--no-color'];
|
||||
const args = ['diff', '--no-color', '--full-index'];
|
||||
if (Number.isFinite(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
@@ -2484,7 +2484,7 @@ export async function getDiff(directory, { path: filePath, staged = false, conte
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
|
||||
try {
|
||||
const args = ['diff', '--no-color'];
|
||||
const args = ['diff', '--no-color', '--full-index'];
|
||||
const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null;
|
||||
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
@@ -3099,11 +3099,13 @@ const normalizePatchTargetPath = (value) => {
|
||||
};
|
||||
|
||||
const extractPatchTargetPath = (patch) => {
|
||||
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
||||
const firstHunk = patch.search(/^@@\s/m);
|
||||
const header = firstHunk < 0 ? patch : patch.slice(0, firstHunk);
|
||||
const matches = [...header.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
||||
const realTargets = matches
|
||||
.map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0])))
|
||||
.filter(Boolean);
|
||||
return realTargets[0] || null;
|
||||
return realTargets.at(-1) || null;
|
||||
};
|
||||
|
||||
const writeTempPatchFile = async (patch) => {
|
||||
@@ -3131,9 +3133,21 @@ export async function applyHunk(directory, filePath, options = {}) {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]);
|
||||
|
||||
const targetPath = extractPatchTargetPath(patch);
|
||||
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
|
||||
throw new Error('patch target path does not match the requested file');
|
||||
// Applicability alone is insufficient: a previously staged or committed
|
||||
// hunk may still reverse cleanly against the working tree. Accept only a
|
||||
// canonical hunk from this file's current working/index diff.
|
||||
const current = await getDiff(directory, { path: filePath, staged: action === 'unstage', contextLines: 3 });
|
||||
const starts = [...current.matchAll(/^@@\s/gm)].map((match) => match.index);
|
||||
const header = current.slice(0, starts[0] ?? 0);
|
||||
const isCurrentHunk = starts.some((start, index) => (
|
||||
header + current.slice(start, starts[index + 1] ?? current.length) === patch
|
||||
));
|
||||
if (!isCurrentHunk) {
|
||||
const targetPath = extractPatchTargetPath(patch);
|
||||
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
|
||||
throw new Error('patch target path does not match the requested file');
|
||||
}
|
||||
throw new Error('Hunk no longer applies — refresh and try again.');
|
||||
}
|
||||
|
||||
const flags = HUNK_ACTION_FLAGS[action];
|
||||
|
||||
@@ -231,22 +231,8 @@ describe.runIf(canRunGit())('setLocalIdentity', () => {
|
||||
// applyHunk (per-hunk stage / unstage / discard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal unified-diff splitter: returns standalone per-hunk patches. */
|
||||
const splitHunks = (patch) => {
|
||||
const lines = patch.split(/\r?\n/);
|
||||
const headerEnd = lines.findIndex((line) => /^@@\s/.test(line));
|
||||
if (headerEnd === -1) return [];
|
||||
const header = lines.slice(0, headerEnd);
|
||||
const hunks = [];
|
||||
for (let i = headerEnd; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (/^@@\s/.test(line)) hunks.push([...header, line]);
|
||||
else if (hunks.length > 0) hunks[hunks.length - 1].push(line);
|
||||
}
|
||||
return hunks.map((hunk) => hunk.join('\n'))
|
||||
.filter((hunk) => hunk.trim().length > 0)
|
||||
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
||||
};
|
||||
// Exercise the actual client splitter against the server apply boundary.
|
||||
import { splitPatchIntoHunks as splitHunks } from '../../../../ui/src/lib/diff/patchFileDiff.ts';
|
||||
|
||||
const writeFile = (repo, name, contents) =>
|
||||
fs.promises.writeFile(path.join(repo, name), contents, 'utf8');
|
||||
@@ -262,6 +248,77 @@ const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'),
|
||||
const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n');
|
||||
|
||||
describe('applyHunk', () => {
|
||||
it('stages successive hunks and never discards a stale staged or committed patch', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const original = Array.from({ length: 60 }, (_, index) => `line${index}`);
|
||||
const changed = [...original];
|
||||
changed[1] = 'FIRST'; changed[25] = 'SECOND'; changed[50] = 'THIRD';
|
||||
await writeFile(tmpDir, 'file.txt', original.join('\n') + '\n');
|
||||
await git.add('file.txt'); await git.commit('Initial');
|
||||
await writeFile(tmpDir, 'file.txt', changed.join('\n') + '\n');
|
||||
const historical = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
expect(historical).toHaveLength(3);
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'stage' });
|
||||
const remaining = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
expect(remaining).toHaveLength(2);
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: remaining[0], action: 'stage' });
|
||||
const stalePath = path.join(tmpDir, 'stale.patch');
|
||||
await fs.promises.writeFile(stalePath, historical[0]);
|
||||
// Git's reverse applicability check accepts it, but it is no longer an
|
||||
// unstaged hunk. The server must reject it before touching the working file.
|
||||
await git.raw(['apply', '--reverse', '--check', stalePath]);
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
|
||||
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
||||
const last = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
expect(last).toHaveLength(1);
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: last[0], action: 'discard' });
|
||||
changed[50] = original[50];
|
||||
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
||||
expect(await readStaged(git)).toBe(changed.join('\n') + '\n');
|
||||
const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
|
||||
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
||||
await git.add('file.txt'); await git.commit('Committed changes');
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
|
||||
});
|
||||
|
||||
it.each(['crlf', 'mixed'])('preserves %s file bytes through stage, unstage and discard', async (endings) => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await git.addConfig('core.autocrlf', 'false');
|
||||
const serialize = (first, last) => Array.from({ length: 30 }, (_, index) => {
|
||||
const text = index === 0 ? first : index === 29 ? last : `line${index}`;
|
||||
return text + (endings === 'crlf' || index % 2 === 0 ? '\r\n' : '\n');
|
||||
}).join('');
|
||||
const original = serialize('first', 'last');
|
||||
const edited = serialize('FIRST', 'LAST');
|
||||
await writeFile(tmpDir, 'file.txt', original);
|
||||
await git.add('file.txt'); await git.commit('Initial');
|
||||
await writeFile(tmpDir, 'file.txt', edited);
|
||||
const hunks = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' });
|
||||
expect(await git.raw(['show', ':file.txt'])).toBe(serialize('FIRST', 'last'));
|
||||
const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
|
||||
expect(await git.raw(['show', ':file.txt'])).toBe(original);
|
||||
const working = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: working[0], action: 'discard' });
|
||||
expect(await fs.promises.readFile(path.join(tmpDir, 'file.txt'), 'utf8')).toBe(serialize('first', 'LAST'));
|
||||
});
|
||||
|
||||
it('rejects extra files hidden before the requested patch', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, ORIGINAL_FILE);
|
||||
await git.add('.'); await git.commit('Initial');
|
||||
for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, EDITED_FILE);
|
||||
const other = splitHunks(await getDiff(tmpDir, { path: 'other.txt' }))[0];
|
||||
const requested = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }))[0];
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: requested + other, action: 'stage' })).rejects.toThrow('refresh and try again');
|
||||
expect(await git.raw(['diff', '--cached'])).toBe('');
|
||||
});
|
||||
|
||||
it('rejects an invalid action or a patch without a hunk header', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow(
|
||||
@@ -344,10 +401,9 @@ describe('applyHunk', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts hunk patches for files with spaces in their path', async () => {
|
||||
it.each(['file name.txt', 'зміни.txt'])('accepts hunk patches for %s', async (filePath) => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = 'file name.txt';
|
||||
await writeFile(tmpDir, filePath, ORIGINAL_FILE);
|
||||
await git.add(filePath);
|
||||
await git.commit('Initial');
|
||||
@@ -374,7 +430,7 @@ describe.runIf(canRunGit())('untracked diffs', () => {
|
||||
// Confirm this fixture produces a real diff exit, including stderr in the warning case.
|
||||
let expectedPatch;
|
||||
try {
|
||||
runGit(tmpDir, ['diff', '--no-color', '--no-index', '--', '/dev/null', 'new file.txt']);
|
||||
runGit(tmpDir, ['diff', '--no-color', '--full-index', '--no-index', '--', '/dev/null', 'new file.txt']);
|
||||
throw new Error('Expected git diff to exit with differences');
|
||||
} catch (error) {
|
||||
expect(error.status).toBe(1);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test } from 'vitest';
|
||||
import { exerciseDiffHunkActions } from '@openchamber/ui/components/views/DiffView.hunks.fixture';
|
||||
|
||||
// The actual view imports Vite asset globs. Run its UI-owned DOM fixture through
|
||||
// the web renderer's transform pipeline instead of mocking those modules.
|
||||
test('hunk actions refresh unchanged-status patches, survive full context and exclude historical diffs', () => exerciseDiffHunkActions());
|
||||
test('full-context and action reads must describe the same file version', () => exerciseDiffHunkActions('cold'));
|
||||
test('cached action patches cannot be paired with a newer full-context display', () => exerciseDiffHunkActions('cached'));
|
||||
Reference in New Issue
Block a user