feat(git): float actions beside each diff hunk

Replace the shared hunk menu with compact per-hunk controls over following context. Preserve canonical patch checks, bind controls to rendered rows, and keep EOF actions inside the code column.

Validated 19 focused tests, UI type-check, UI/web lint and the web build. Browser checks covered unified and split layouts, themes, and one-line EOF hunks.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 22:36:15 +03:00
parent d9186afdc0
commit b267b995a5
9 changed files with 386 additions and 394 deletions
@@ -1,21 +1,59 @@
import React, { act } from 'react';
import { expect } from 'bun:test';
import { Window } from 'happy-dom';
import { Worker as NodeWorker } from 'node:worker_threads';
import type { WorkerRequest, WorkerResponse } from '@pierre/diffs/worker';
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 WorkerEventTarget = globalThis.EventTarget;
const WorkerMessageEvent = globalThis.MessageEvent;
const workerThreads = new Set<NodeWorker>();
const pendingHighlightRequests = new Set<string>();
const workerFailures: Error[] = [];
const workerEntry = import.meta.resolve('@pierre/diffs/worker/worker.js');
// Run the installed Pierre worker unchanged. Only its browser message boundary
// is adapted to Node, so inline slots use real rendered diff rows in this test.
class FixtureHighlightWorker extends WorkerEventTarget {
private worker = new NodeWorker(new URL(`data:text/javascript,${encodeURIComponent(`
import { parentPort, workerData } from 'node:worker_threads';
globalThis.postMessage = (data) => parentPort.postMessage(data);
globalThis.self = { addEventListener(type, listener) {
if (type === 'message') parentPort.on('message', (data) => listener({ data }));
} };
await import(workerData);
`)}`), { workerData: workerEntry });
constructor() {
super();
workerThreads.add(this.worker);
this.worker.on('message', (data: WorkerResponse) => {
pendingHighlightRequests.delete(data.id);
this.dispatchEvent(new WorkerMessageEvent('message', { data }));
});
this.worker.on('error', (error) => workerFailures.push(error));
}
postMessage(request: WorkerRequest) {
pendingHighlightRequests.add(request.id);
this.worker.postMessage(request);
}
terminate() { return this.worker.terminate(); }
}
export const closeDiffHunkWorkers = async () => {
await Promise.all([...workerThreads].map((worker) => worker.terminate()));
workerThreads.clear();
};
export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached' | 'cold-single', layout: 'inline' | 'side-by-side' = 'inline') {
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, 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,
Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement, HTMLButtonElement: dom.HTMLButtonElement, Node: dom.Node,
ShadowRoot: dom.ShadowRoot, Document: dom.Document, Worker: FixtureHighlightWorker,
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,
@@ -27,6 +65,7 @@ export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached')
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import('react-dom/client');
document.documentElement.style.fontSize = '16px';
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.
@@ -38,12 +77,13 @@ export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached')
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];
const changes = snapshotCase === 'cold-single' ? [1] : [1, 25, 50];
let remaining = [...changes];
let fullContext = snapshotCase === 'cold';
let currentVersion = snapshotCase === 'cold' ? 2 : 1;
const cold = snapshotCase === 'cold' || snapshotCase === 'cold-single';
let fullContext = cold;
let currentVersion = cold ? 2 : 1;
let fullVersion = 1;
let deferVersions = snapshotCase === 'cold';
let deferVersions = cold;
let releaseFull: (() => void) | undefined;
let releaseCanonical: (() => void) | undefined;
let openedPatch: string | null = null;
@@ -86,10 +126,12 @@ export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached')
useGitStore.getState().setActiveDirectory('/repo');
const container = document.createElement('div');
container.dataset.diffVirtualRoot = '';
container.getBoundingClientRect = () => new dom.DOMRect(0, 0, 1280, 2000);
Object.defineProperty(container, 'clientHeight', { value: 2000 });
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}
<MultiFileDiffEntry directory="/repo" file={file} layout={layout} wrapLines={false} isSelected={false}
isExpanded isMounted onSelect={() => {}} onExpandedChange={() => {}} registerSectionRef={() => {}}
showOpenInEditorAction onOpenInEditor={(_path, diff) => { openedPatch = diff?.patch ?? null; }}
hunkActionsEnabled={!historical} loadFullFiles={fullContext}
@@ -100,19 +142,36 @@ export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached')
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 }));
});
const waitForActions = async (count: number) => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (workerFailures.length > 0) throw workerFailures[0];
if (container.querySelectorAll('[data-hunk-actions]').length === count) {
for (const [index, line] of remaining.entries()) {
const target = container.querySelector<HTMLElement>(`[data-hunk-action-target="${index}"]`);
const wrapper = target?.parentElement;
const slots = container.querySelector('diffs-container')?.shadowRoot?.querySelectorAll('slot') ?? [];
const slot = wrapper ? [...slots].find((candidate) => candidate.assignedElements().includes(wrapper)) : undefined;
expect(slot?.name).toBe(`annotation-additions-${line + 1}`);
expect(target?.style.height).toBe('0px');
expect(slot?.closest('[data-line-annotation]')).not.toBeNull();
if (layout === 'side-by-side') expect(slot?.closest('[data-additions]')).not.toBeNull();
}
return;
}
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
}
const host = container.querySelector('diffs-container');
throw new Error(`Missing inline actions: ${JSON.stringify({
expected: count, targets: host?.querySelectorAll('[data-hunk-action-target]').length,
slots: [...(host?.shadowRoot?.querySelectorAll('slot') ?? [])].map((slot) => ({ name: slot.name, assigned: slot.assignedElements().length })),
shadow: host?.shadowRoot?.innerHTML.slice(-1500),
})}`);
};
try {
await render();
if (snapshotCase) {
if (snapshotCase === 'cold') {
if (cold) {
if (!releaseFull || !releaseCanonical) throw new Error('Both snapshot reads must start');
await act(async () => { releaseFull?.(); releaseCanonical?.(); });
} else {
@@ -122,52 +181,62 @@ export async function exerciseDiffHunkActions(snapshotCase?: 'cold' | 'cached')
await render();
}
expect(container.textContent).toContain('Refresh the diff and try again');
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
expect(container.querySelector('[data-hunk-actions]')).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');
await waitForActions(changes.length);
expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(changes.length);
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"]');
await click('button[aria-label="Stage hunk 1"]');
expect(mutations).toBe(1);
return;
}
expect(container.textContent).toContain('Hunks · 3');
await waitForActions(3);
expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(3);
expect(normalReads).toBe(1);
fullContext = true;
await render();
expect(container.textContent).toContain('Hunks · 3');
await waitForActions(3);
expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(3);
expect(normalReads).toBe(1);
await openHunks();
await click('[role="menuitem"][aria-label="Stage hunk 1"]');
await click('button[aria-label="Stage hunk 1"]');
expect(mutations).toBe(1);
expect(container.textContent).toContain('Hunks · 2');
await waitForActions(2);
expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(2);
expect(normalReads).toBe(2);
failReads = true;
await openHunks();
await click('[role="menuitem"][aria-label="Stage hunk 1"]');
await click('button[aria-label="Stage hunk 1"]');
expect(mutations).toBe(2);
expect(container.textContent).toContain('Refresh unavailable');
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
expect(container.querySelector('[data-hunk-actions]')).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();
await waitForActions(1);
expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(1);
await click('button[aria-label="Stage hunk 1"]');
expect(mutations).toBe(3);
expect(container.querySelector('[data-hunk-actions]')).toBeNull();
remaining = [...changes];
historical = true;
await render();
expect(container.querySelector('button[aria-label="Hunks"]')).toBeNull();
expect(mutations).toBe(2);
expect(container.querySelector('[data-hunk-actions]')).toBeNull();
expect(mutations).toBe(3);
} finally {
await act(async () => root.unmount());
const deadline = Date.now() + 5000;
while (pendingHighlightRequests.size > 0 && workerFailures.length === 0 && Date.now() < deadline) {
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
}
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); });
globalThis.fetch = originalFetch;
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
+18 -156
View File
@@ -37,7 +37,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
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 { PierreDiffViewer, type DiffHunkActions } from './PierreDiffViewer';
import { HunkActions, type HunkBusyState, type HunkDiffAction } from './git/HunkActions';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
@@ -48,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, extractHunkPatch, haveMatchingPatchVersions } from '@/lib/diff/patchFileDiff';
import { fileDiffFromPatch, isBinaryPatch, extractHunkPatch, haveMatchingPatchVersions, getPatchHunkAnchors } from '@/lib/diff/patchFileDiff';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startReviewFlow } from '@/lib/reviewFlow';
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
@@ -465,6 +465,7 @@ interface InlineDiffViewerProps {
diff: DiffData;
renderSideBySide: boolean;
wrapLines: boolean;
hunkActions?: DiffHunkActions;
}
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
@@ -472,6 +473,7 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
diff,
renderSideBySide,
wrapLines,
hunkActions,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath) || 'text',
@@ -503,104 +505,12 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
layout="inline"
hunkActions={hunkActions}
/>
</div>
);
});
type FileDiffAction = 'stage' | 'unstage' | 'discard';
interface FileDiffActionsProps {
filePath: string;
staged: boolean;
busyAction: FileDiffAction | null;
disabled: boolean;
onAction: (action: FileDiffAction) => void;
}
const FileDiffActions = React.memo<FileDiffActionsProps>(({
filePath,
staged,
busyAction,
disabled,
onAction,
}) => {
const { t } = useI18n();
return (
<div className="flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-sm backdrop-blur-md">
{staged ? (
<FileDiffActionButton
label={t('gitView.changes.unstageFileAria', { path: filePath })}
icon="arrow-go-back"
loading={busyAction === 'unstage'}
disabled={disabled}
onClick={() => onAction('unstage')}
/>
) : (
<>
<FileDiffActionButton
label={t('gitView.changes.revertFileAria', { path: filePath })}
icon="arrow-go-back"
loading={busyAction === 'discard'}
disabled={disabled}
tone="failure"
onClick={() => onAction('discard')}
/>
<FileDiffActionButton
label={t('gitView.changes.stageFileAria', { path: filePath })}
icon="add"
loading={busyAction === 'stage'}
disabled={disabled}
tone="success"
onClick={() => onAction('stage')}
/>
</>
)}
</div>
);
});
interface FileDiffActionButtonProps {
label: string;
icon: 'add' | 'arrow-go-back';
loading: boolean;
disabled: boolean;
tone?: 'failure' | 'success';
onClick: () => void;
}
const FileDiffActionButton: React.FC<FileDiffActionButtonProps> = ({
label,
icon,
loading,
disabled,
tone,
onClick,
}) => (
<Button
variant="ghost"
size="sm"
className={cn(
'h-6 w-6 rounded-none bg-transparent p-0 text-muted-foreground opacity-70 hover:bg-transparent hover:text-foreground hover:opacity-100',
tone === 'failure' && 'text-[var(--status-error)] hover:text-[var(--status-error)]',
tone === 'success' && 'text-[var(--status-success)] hover:text-[var(--status-success)]'
)}
disabled={disabled}
title={label}
aria-label={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{loading ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : (
<Icon name={icon} className={icon === 'add' ? 'size-4' : 'size-3.5'} />
)}
</Button>
);
interface MultiFileDiffEntryProps {
directory: string;
file: FileEntry;
@@ -664,7 +574,6 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const [isFetching, setIsLoading] = React.useState(false);
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);
@@ -757,8 +666,8 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
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))) {
if (hunkEligible && loadFullFiles && patch !== null && /^@@\s/m.test(patch)
&& ('diff' in response && response.diff !== patch && !haveMatchingPatchVersions(response.diff, patch))) {
setCanonicalPatch(null);
throw new Error(t('diffView.hunk.unavailable'));
}
@@ -816,41 +725,8 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setDiffRetryNonce((nonce) => nonce + 1);
}, []);
const handleFileAction = React.useCallback(async (action: FileDiffAction) => {
if (!directory || mutationInFlight.current || fileAction !== null) {
return;
}
mutationInFlight.current = true;
const runtimeKey = getRuntimeKey();
setFileAction(action);
try {
if (action === 'stage') {
await git.stageGitFile(directory, file.path);
} else if (action === 'unstage') {
await git.unstageGitFile(directory, file.path);
} else {
await git.revertGitFile(directory, file.path, { scope: 'working' });
}
if (runtimeKey !== getRuntimeKey()) return;
invalidatePatch();
sessionEvents.requestGitRefresh({ directory, paths: [file.path] });
await fetchStatus(directory, git);
} catch (error) {
const fallbackKey = action === 'unstage'
? 'gitView.toast.unstageFileFailed'
: action === 'stage'
? 'gitView.toast.stageFileFailed'
: '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, invalidatePatch, t]);
const handleHunkAction = React.useCallback(async (hunkIndex: number, action: HunkDiffAction) => {
if (!directory || !hunkEligible || isLoading || diffLoadError || mutationInFlight.current || hunkAction !== null || fileAction !== null) {
if (!directory || !hunkEligible || isLoading || diffLoadError || mutationInFlight.current || hunkAction !== null) {
return;
}
@@ -887,7 +763,15 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
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]);
}, [actionPatch, hunkEligible, isLoading, diffLoadError, directory, fetchStatus, file.path, git, hunkAction, invalidatePatch, staged, t]);
const hunkAnchors = React.useMemo(() => hunkEligible && actionPatch !== null ? getPatchHunkAnchors(actionPatch) : [], [actionPatch, hunkEligible]);
const renderHunkActions = React.useCallback((index: number) => (
<HunkActions index={index} staged={staged} busyHunk={hunkAction}
disabled={isLoading || Boolean(diffLoadError)} onAction={handleHunkAction} />
), [diffLoadError, handleHunkAction, hunkAction, isLoading, staged]);
const diffHunkActions = React.useMemo<DiffHunkActions | undefined>(() => hunkAnchors.length > 0
? { anchors: hunkAnchors, render: renderHunkActions } : undefined, [hunkAnchors, renderHunkActions]);
return (
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
@@ -1053,30 +937,8 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
hunkActions={diffHunkActions}
/>
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
<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 || hunkAction !== null}
onAction={handleFileAction}
/>
) : null}
</div>
</div>
</>
) : null}
</div>
@@ -1,4 +1,5 @@
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
areFilesEqual,
areOptionsEqual,
@@ -29,6 +30,20 @@ import { getDefaultTheme } from '@/lib/theme/themes';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import type { PatchHunkAnchor } from '@/lib/diff/patchFileDiff';
export interface DiffHunkActions {
anchors: readonly PatchHunkAnchor[];
render: (index: number) => React.ReactNode;
}
type DiffAnnotation = PierreAnnotationData | { type: 'hunk-action'; index: number };
const EMPTY_HUNK_ANCHORS: readonly PatchHunkAnchor[] = [];
const HUNK_ACTION_OVERLAY_CSS = `
[data-gutter-buffer="annotation"] { min-height: 0; }
[data-code] { min-height: 2.5rem; align-content: start; }
`;
// Threshold (bytes) above which syntax highlighting is degraded for performance
@@ -44,6 +59,7 @@ interface PierreDiffViewerProps {
wrapLines?: boolean;
layout?: 'fill' | 'inline';
enableComments?: boolean;
hunkActions?: DiffHunkActions;
}
/**
@@ -444,7 +460,7 @@ function acquireSharedVirtualizer(container: HTMLElement): SharedVirtualizer | n
}
const wakeVirtualizer = (
instance: PierreFileDiff<PierreAnnotationData>,
instance: PierreFileDiff<DiffAnnotation>,
sharedVirtualizer: SharedVirtualizer | null,
forceUpdate: () => void,
): (() => void) => {
@@ -491,6 +507,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
wrapLines,
layout = 'fill',
enableComments = true,
hunkActions,
}) => {
const themeContext = useOptionalThemeSystem();
@@ -499,6 +516,12 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true);
const { isMobile } = useDeviceInfo();
const hunkAnchors = hunkActions?.anchors ?? EMPTY_HUNK_ANCHORS;
const [hunkTargets, setHunkTargets] = React.useState<{
fileDiff: FileDiffMetadata | undefined;
anchors: readonly PatchHunkAnchor[];
targets: ReadonlyMap<number, HTMLElement>;
}>(() => ({ fileDiff: undefined, anchors: EMPTY_HUNK_ANCHORS, targets: new Map() }));
const diffCommentController = useInlineCommentController<SelectedLineRange>({
source: 'diff',
@@ -587,10 +610,16 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
cancel();
}, [cancel]);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<PierreAnnotationData>) => {
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<DiffAnnotation>) => {
const div = document.createElement('div');
div.style.position = 'relative';
if (annotation.metadata.type === 'hunk-action') {
div.dataset.hunkActionTarget = String(annotation.metadata.index);
div.style.height = '0px';
return div;
}
const id = toPierreAnnotationId(annotation.metadata);
div.dataset.annotationId = id;
@@ -599,6 +628,52 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return div;
}, []);
const captureHunkTargets = useCallback<NonNullable<FileDiffOptions<DiffAnnotation>['onPostRender']>>((node, instance, phase) => {
const targets = new Map<number, HTMLElement>();
if (phase !== 'unmount') {
const capsuleHeight = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
const columns = new Map<HTMLElement, DOMRect>();
const placements: Array<{ target: HTMLElement; offset: number }> = [];
// Only mounted virtual rows have slots. Avoid creating React controls
// for off-screen hunks or measuring every line on a scroll event.
for (const slot of node.shadowRoot?.querySelectorAll('slot') ?? []) {
for (const wrapper of slot.assignedElements()) {
const target = wrapper.querySelector<HTMLElement>('[data-hunk-action-target]');
const index = Number(target?.dataset.hunkActionTarget);
if (!target || !Number.isInteger(index) || index < 0) continue;
targets.set(index, target);
const column = slot.closest<HTMLElement>('[data-code]');
if (!column) continue;
let bounds = columns.get(column);
if (!bounds) {
bounds = column.getBoundingClientRect();
columns.set(column, bounds);
}
const markerTop = target.getBoundingClientRect().top;
// Float over the following context. At EOF, lift the capsule inside
// the code column so its vertical clipping cannot hide the buttons.
const top = Math.max(bounds.top + 4, Math.min(markerTop + 4, bounds.bottom - capsuleHeight - 4));
placements.push({ target, offset: top - markerTop });
}
}
// Finish all geometry reads before writing offsets to avoid layout
// recalculation between neighboring hunks.
for (const { target, offset } of placements) {
const value = `${offset}px`;
if (target.style.getPropertyValue('--oc-hunk-action-offset') !== value) {
target.style.setProperty('--oc-hunk-action-offset', value);
}
}
}
const renderedDiff = instance.fileDiff;
setHunkTargets((previous) => {
if (previous.fileDiff === renderedDiff && previous.anchors === hunkAnchors
&& previous.targets.size === targets.size
&& [...targets].every(([index, target]) => previous.targets.get(index) === target)) return previous;
return { fileDiff: renderedDiff, anchors: hunkAnchors, targets };
});
}, [hunkAnchors]);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
saveComment(textToSave, rangeOverride ?? selection ?? undefined);
}, [saveComment, selection]);
@@ -851,11 +926,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const diffRootRef = useRef<HTMLDivElement | null>(null);
const diffContainerRef = useRef<HTMLDivElement | null>(null);
const diffInstanceRef = useRef<PierreFileDiff<PierreAnnotationData> | null>(null);
const diffInstanceRef = useRef<PierreFileDiff<DiffAnnotation> | null>(null);
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
const instanceVirtualizerRef = useRef<Virtualizer | null>(null);
const instanceWorkerPoolRef = useRef<unknown>(null);
const instanceVirtualHunkSeparatorsRef = useRef<FileDiffOptions<PierreAnnotationData>['hunkSeparators'] | undefined>(undefined);
const instanceVirtualHunkSeparatorsRef = useRef<FileDiffOptions<DiffAnnotation>['hunkSeparators'] | undefined>(undefined);
const instanceFileDiffRef = useRef<FileDiffMetadata | undefined>(undefined);
const instanceOldFileRef = useRef<FileContents | undefined>(undefined);
const instanceNewFileRef = useRef<FileContents | undefined>(undefined);
@@ -940,46 +1015,47 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
const options = useMemo(() => ({
const options = useMemo<FileDiffOptions<DiffAnnotation>>(() => ({
theme: {
dark: darkTheme.metadata.id,
light: lightTheme.metadata.id,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
diffIndicators: 'none' as const,
hunkSeparators: 'line-info-basic' as const,
themeType: isDark ? 'dark' : 'light',
diffStyle: renderSideBySide ? 'split' : 'unified',
diffIndicators: 'none',
hunkSeparators: 'line-info-basic',
// Perf: disable intra-line diff (word-level) globally.
lineDiffType: 'none' as const,
lineDiffType: 'none',
// Perf: degrade tokenization/highlighting for large files (>500KB)
maxLineDiffLength: isLargeContent ? 0 : 1000,
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
tokenizeMaxLineLength: isLargeContent ? 1 : 1000,
collapsedContextThreshold: 0,
expansionLineCount: 20,
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
overflow: wrapLines ? 'wrap' : 'scroll',
disableFileHeader: true,
enableLineSelection: enableComments,
enableGutterUtility: enableComments,
onGutterUtilityClick: enableComments ? handleGutterUtilityClick : undefined,
onLineClick: enableComments ? handleLineClick : undefined,
onLineSelected: enableComments ? handleSelectionChange : undefined,
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
renderAnnotation: enableComments ? renderAnnotation : undefined,
}), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]);
unsafeCSS: hunkAnchors.length > 0 ? `${WEBKIT_SCROLL_FIX_CSS}\n${HUNK_ACTION_OVERLAY_CSS}` : WEBKIT_SCROLL_FIX_CSS,
renderAnnotation: enableComments || hunkAnchors.length > 0 ? renderAnnotation : undefined,
onPostRender: hunkAnchors.length > 0 ? captureHunkTargets : undefined,
}), [captureHunkTargets, hunkAnchors.length, darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]);
const lineAnnotations = useMemo(() => {
if (!enableComments) {
return [];
}
return buildPierreLineAnnotations({
const lineAnnotations = useMemo<DiffLineAnnotation<DiffAnnotation>[]>(() => {
const annotations: DiffLineAnnotation<DiffAnnotation>[] = enableComments ? buildPierreLineAnnotations({
drafts: fileDrafts,
editingDraftId,
selection,
});
}, [editingDraftId, enableComments, fileDrafts, selection]);
}) : [];
for (const anchor of hunkAnchors) {
annotations.push({ side: anchor.side, lineNumber: anchor.lineNumber, metadata: { type: 'hunk-action', index: anchor.index } });
}
return annotations;
}, [editingDraftId, enableComments, fileDrafts, hunkAnchors, selection]);
const lineAnnotationsRef = useRef(lineAnnotations);
@@ -1068,17 +1144,17 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
: false;
if (!instance) {
instance = sharedVirtualizer
? new VirtualizedFileDiff<PierreAnnotationData>(
options as FileDiffOptions<PierreAnnotationData>,
? new VirtualizedFileDiff<DiffAnnotation>(
options,
sharedVirtualizer.virtualizer,
VIRTUAL_METRICS,
workerPool,
)
: new PierreFileDiff(options as FileDiffOptions<PierreAnnotationData>, workerPool);
: new PierreFileDiff(options, workerPool);
diffInstanceRef.current = instance;
lastAppliedSelectionRef.current = null;
} else {
instance.setOptions(options as FileDiffOptions<PierreAnnotationData>);
instance.setOptions(options);
}
instanceVirtualizerRef.current = virtualizer;
@@ -1291,6 +1367,12 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
/>
) : null;
// A new action snapshot must never land in annotation nodes belonging to
// the previously rendered diff, even for one frame before Pierre updates.
const hunkActionPortals = hunkActions && fileDiff && hunkTargets.fileDiff === fileDiff && hunkTargets.anchors === hunkAnchors
? [...hunkTargets.targets].map(([index, target]) => createPortal(hunkActions.render(index), target, `hunk-${index}`))
: null;
if (layout === 'fill') {
return (
<div className={cn("flex flex-col relative", "size-full")} data-diff-virtual-root>
@@ -1306,6 +1388,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
</div>
</ScrollableOverlay>
{commentOverlays}
{hunkActionPortals}
</div>
</div>
);
@@ -1318,6 +1401,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<div ref={diffContainerRef} className="w-full" />
</div>
{commentOverlays}
{hunkActionPortals}
</div>
);
};
@@ -1,18 +1,15 @@
import React, { act } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import type { HunkBusyState } from './HunkActions';
test('a long hunk menu counts header-like content and navigates to its last action', async () => {
test('each compact capsule acts on its own hunk and shares the mutation lock', 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,
window: dom, document: dom.document, navigator: dom.navigator,
Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node,
Event: dom.Event, MouseEvent: dom.MouseEvent, IS_REACT_ACT_ENVIRONMENT: true,
})) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
@@ -20,38 +17,39 @@ test('a long hunk menu counts header-like content and navigates to its last acti
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 }));
});
const actions: string[] = [];
const render = (staged = false, busyHunk: HunkBusyState = null) => act(async () => root.render(
<I18nProvider>{[0, 1, 2].map((index) => <HunkActions key={index} index={index} staged={staged}
busyHunk={busyHunk} disabled={false} onAction={(hunk, action) => actions.push(`${action}:${hunk}`)} />)}</I18nProvider>
));
const button = (label: string) => {
const target = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
if (!target) throw new Error(`Missing ${label}`);
return target;
};
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']);
await render();
expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(3);
expect(container.querySelector('[role="menu"]')).toBeNull();
expect(button('Stage hunk 2').closest('[data-hunk-actions]')?.getAttribute('data-hunk-actions')).toBe('1');
await act(async () => button('Stage hunk 2').click());
await act(async () => button('Discard hunk 3').click());
expect(actions).toEqual(['stage:1', 'discard:2']);
await render(false, { index: 1, action: 'stage' });
expect([...container.querySelectorAll('button')].every((entry) => entry.disabled)).toBe(true);
expect(button('Stage hunk 2').querySelector('.animate-spin')).not.toBeNull();
await act(async () => button('Discard hunk 1').click());
expect(actions).toHaveLength(2);
await render(true);
expect(container.querySelectorAll('button')).toHaveLength(3);
expect(container.querySelector('button[aria-label="Stage hunk 1"]')).toBeNull();
await act(async () => button('Unstage hunk 1').click());
expect(actions.at(-1)).toBe('unstage:0');
} finally {
await act(async () => root.unmount());
for (const [name, descriptor] of originals) {
@@ -1,17 +1,8 @@
import React, { useMemo } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import React from 'react';
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';
import { cn } from '@/lib/utils';
export type HunkDiffAction = 'stage' | 'unstage' | 'discard';
@@ -21,136 +12,50 @@ export type HunkBusyState = {
} | null;
interface HunkActionsProps {
filePath: string;
patch: string;
index: number;
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,
index, 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';
const actions: HunkDiffAction[] = staged ? ['unstage'] : ['discard', '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 });
<div className="pointer-events-none absolute right-3 z-20" style={{ top: 'var(--oc-hunk-action-offset, 0.25rem)' }} data-hunk-actions={index}>
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-sm backdrop-blur-md">
{actions.map((action) => {
const label = t(action === 'stage' ? 'diffView.hunk.stageTitle'
: action === 'unstage' ? 'diffView.hunk.unstageTitle' : 'diffView.hunk.discardTitle', { index: index + 1 });
const busy = busyHunk?.index === index && busyHunk.action === action;
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>
<Button
key={action}
variant="ghost"
size="xs"
className={cn(
'rounded-full bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground',
action === 'discard' && 'text-[var(--status-error)] hover:text-[var(--status-error)]',
action === 'stage' && 'text-[var(--status-success)] hover:text-[var(--status-success)]',
)}
disabled={disabled || busyHunk !== null}
title={label}
aria-label={label}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onAction(index, action);
}}
>
<Icon name={busy ? 'loader-4' : action === 'stage' ? 'add' : 'arrow-go-back'}
className={cn(action === 'stage' ? 'size-4' : 'size-3.5', busy && 'animate-spin')} />
</Button>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { extractHunkPatch, splitPatchIntoHunks, haveMatchingPatchVersions } from "./patchFileDiff";
import { extractHunkPatch, splitPatchIntoHunks, haveMatchingPatchVersions, getPatchHunkAnchors } from "./patchFileDiff";
const SAMPLE_PATCH = `diff --git a/foo.txt b/foo.txt
index 1111111..2222222 100644
@@ -97,3 +97,31 @@ describe("extractHunkPatch", () => {
expect(extractHunkPatch("", 0)).toBeNull();
});
});
describe('hunk action anchors', () => {
const header = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n';
test('anchors below trailing deletions rather than above them on the shorter new side', () => {
expect(getPatchHunkAnchors(header + '@@ -8,5 +8,3 @@\n line8\n line9\n line10\n-gone11\n-gone12\n')).toEqual([
{ index: 0, side: 'deletions', lineNumber: 12 },
]);
});
test('anchors before trailing context and preserves canonical hunk indices', () => {
expect(getPatchHunkAnchors(header + '@@ -1,2 +1,2 @@\n-old\n+new\n context\n@@ -20 +20 @@\n-before\n+after\n')).toEqual([
{ index: 0, side: 'additions', lineNumber: 1 },
{ index: 1, side: 'additions', lineNumber: 20 },
]);
});
test('supports added and fully deleted files with an empty opposite side', () => {
expect(getPatchHunkAnchors(header + '@@ -0,0 +1,2 @@\n+a\n+b\n')).toEqual([{ index: 0, side: 'additions', lineNumber: 2 }]);
expect(getPatchHunkAnchors(header + '@@ -1,2 +0,0 @@\n-a\n-b\n')).toEqual([{ index: 0, side: 'deletions', lineNumber: 2 }]);
});
test('ignores no-newline metadata when selecting the final row', () => {
expect(getPatchHunkAnchors(header + '@@ -1 +1 @@\n-before\r\n+after\r\n\\ No newline at end of file\n')).toEqual([
{ index: 0, side: 'additions', lineNumber: 1 },
]);
});
test('does not create controls for an empty or malformed patch', () => {
expect(getPatchHunkAnchors('')).toEqual([]);
expect(getPatchHunkAnchors(header + '@@ -0,0 +0,0 @@\n')).toEqual([]);
});
});
+33
View File
@@ -3,6 +3,7 @@ import {
parsePatchFiles,
processFile,
trimPatchContext,
type AnnotationSide,
type FileDiffMetadata,
} from '@pierre/diffs';
@@ -177,3 +178,35 @@ export const extractHunkPatch = (patch: string, hunkIndex: number): string | nul
const hunks = splitPatchIntoHunks(patch);
return hunks[hunkIndex] ?? null;
};
export interface PatchHunkAnchor {
index: number;
side: AnnotationSide;
lineNumber: number;
}
/** Anchor each action after the final changed row, before trailing context. */
export const getPatchHunkAnchors = (patch: string): PatchHunkAnchor[] => {
const anchors: PatchHunkAnchor[] = [];
for (const [index, hunk] of splitPatchIntoHunks(patch).entries()) {
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*(?:\n|$)/m.exec(hunk);
if (!header) continue;
let deletionLine = Number(header[1]);
let additionLine = Number(header[3]);
let anchor: PatchHunkAnchor | undefined;
for (const line of hunk.slice(header.index + header[0].length).split('\n')) {
if (line.startsWith('-')) {
anchor = { index, side: 'deletions', lineNumber: deletionLine++ };
} else if (line.startsWith('+')) {
anchor = { index, side: 'additions', lineNumber: additionLine++ };
} else if (line.startsWith(' ')) {
deletionLine += 1;
additionLine += 1;
}
}
if (anchor && Number.isSafeInteger(anchor.lineNumber) && anchor.lineNumber > 0) {
anchors.push(anchor);
}
}
return anchors;
};