From b267b995a5cab5cbbdc11927e7a519b9879af99d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 22:36:15 +0300 Subject: [PATCH] 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. --- .../views/DiffView.hunks.fixture.tsx | 145 +++++++++++---- packages/ui/src/components/views/DiffView.tsx | 174 ++---------------- .../src/components/views/PierreDiffViewer.tsx | 136 +++++++++++--- .../components/views/git/HunkActions.test.tsx | 70 ++++--- .../src/components/views/git/HunkActions.tsx | 163 ++++------------ .../ui/src/lib/diff/patchFileDiff.test.ts | 30 ++- packages/ui/src/lib/diff/patchFileDiff.ts | 33 ++++ packages/web/server/lib/git/DOCUMENTATION.md | 21 ++- packages/web/src/DiffView.hunks.test.ts | 8 +- 9 files changed, 386 insertions(+), 394 deletions(-) diff --git a/packages/ui/src/components/views/DiffView.hunks.fixture.tsx b/packages/ui/src/components/views/DiffView.hunks.fixture.tsx index 339e22c6..d1fefafa 100644 --- a/packages/ui/src/components/views/DiffView.hunks.fixture.tsx +++ b/packages/ui/src/components/views/DiffView.hunks.fixture.tsx @@ -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(); +const pendingHighlightRequests = new Set(); +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(); 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( - {}} 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('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(`[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); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index fcca5de9..dc589221 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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(({ @@ -472,6 +473,7 @@ const InlineDiffViewer = React.memo(({ diff, renderSideBySide, wrapLines, + hunkActions, }) => { const language = React.useMemo( () => getLanguageFromExtension(filePath) || 'text', @@ -503,104 +505,12 @@ const InlineDiffViewer = React.memo(({ renderSideBySide={renderSideBySide} wrapLines={wrapLines} layout="inline" + hunkActions={hunkActions} /> ); }); -type FileDiffAction = 'stage' | 'unstage' | 'discard'; - -interface FileDiffActionsProps { - filePath: string; - staged: boolean; - busyAction: FileDiffAction | null; - disabled: boolean; - onAction: (action: FileDiffAction) => void; -} - -const FileDiffActions = React.memo(({ - filePath, - staged, - busyAction, - disabled, - onAction, -}) => { - const { t } = useI18n(); - return ( -
- {staged ? ( - onAction('unstage')} - /> - ) : ( - <> - onAction('discard')} - /> - onAction('stage')} - /> - - )} -
- ); -}); - -interface FileDiffActionButtonProps { - label: string; - icon: 'add' | 'arrow-go-back'; - loading: boolean; - disabled: boolean; - tone?: 'failure' | 'success'; - onClick: () => void; -} - -const FileDiffActionButton: React.FC = ({ - label, - icon, - loading, - disabled, - tone, - onClick, -}) => ( - -); - interface MultiFileDiffEntryProps { directory: string; file: FileEntry; @@ -664,7 +574,6 @@ export const MultiFileDiffEntry = React.memo(({ 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(null); const [hunkAction, setHunkAction] = React.useState(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(({ 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(({ 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(({ 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) => ( + + ), [diffLoadError, handleHunkAction, hunkAction, isLoading, staged]); + const diffHunkActions = React.useMemo(() => hunkAnchors.length > 0 + ? { anchors: hunkAnchors, render: renderHunkActions } : undefined, [hunkAnchors, renderHunkActions]); return (
@@ -1053,30 +937,8 @@ export const MultiFileDiffEntry = React.memo(({ diff={diffData} renderSideBySide={renderSideBySide} wrapLines={wrapLines} + hunkActions={diffHunkActions} /> -
-
- {hunkEligible && actionPatch !== null ? ( - - ) : null} - {!readOnlyActions ? ( - - ) : null} -
-
) : null}
diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index a1009bff..767fa130 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -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, + instance: PierreFileDiff, sharedVirtualizer: SharedVirtualizer | null, forceUpdate: () => void, ): (() => void) => { @@ -491,6 +507,7 @@ export const PierreDiffViewer: React.FC = ({ wrapLines, layout = 'fill', enableComments = true, + hunkActions, }) => { const themeContext = useOptionalThemeSystem(); @@ -499,6 +516,12 @@ export const PierreDiffViewer: React.FC = ({ 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; + }>(() => ({ fileDiff: undefined, anchors: EMPTY_HUNK_ANCHORS, targets: new Map() })); const diffCommentController = useInlineCommentController({ source: 'diff', @@ -587,10 +610,16 @@ export const PierreDiffViewer: React.FC = ({ cancel(); }, [cancel]); - const renderAnnotation = useCallback((annotation: DiffLineAnnotation) => { + const renderAnnotation = useCallback((annotation: DiffLineAnnotation) => { 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 = ({ return div; }, []); + const captureHunkTargets = useCallback['onPostRender']>>((node, instance, phase) => { + const targets = new Map(); + if (phase !== 'unmount') { + const capsuleHeight = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) * 2; + const columns = new Map(); + 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('[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('[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 = ({ const diffRootRef = useRef(null); const diffContainerRef = useRef(null); - const diffInstanceRef = useRef | null>(null); + const diffInstanceRef = useRef | null>(null); const sharedVirtualizerRef = useRef(null); const instanceVirtualizerRef = useRef(null); const instanceWorkerPoolRef = useRef(null); - const instanceVirtualHunkSeparatorsRef = useRef['hunkSeparators'] | undefined>(undefined); + const instanceVirtualHunkSeparatorsRef = useRef['hunkSeparators'] | undefined>(undefined); const instanceFileDiffRef = useRef(undefined); const instanceOldFileRef = useRef(undefined); const instanceNewFileRef = useRef(undefined); @@ -940,46 +1015,47 @@ export const PierreDiffViewer: React.FC = ({ }, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]); - const options = useMemo(() => ({ + const options = useMemo>(() => ({ 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[]>(() => { + const annotations: DiffLineAnnotation[] = 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 = ({ : false; if (!instance) { instance = sharedVirtualizer - ? new VirtualizedFileDiff( - options as FileDiffOptions, + ? new VirtualizedFileDiff( + options, sharedVirtualizer.virtualizer, VIRTUAL_METRICS, workerPool, ) - : new PierreFileDiff(options as FileDiffOptions, workerPool); + : new PierreFileDiff(options, workerPool); diffInstanceRef.current = instance; lastAppliedSelectionRef.current = null; } else { - instance.setOptions(options as FileDiffOptions); + instance.setOptions(options); } instanceVirtualizerRef.current = virtualizer; @@ -1291,6 +1367,12 @@ export const PierreDiffViewer: React.FC = ({ /> ) : 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 (
@@ -1306,6 +1388,7 @@ export const PierreDiffViewer: React.FC = ({
{commentOverlays} + {hunkActionPortals} ); @@ -1318,6 +1401,7 @@ export const PierreDiffViewer: React.FC = ({
{commentOverlays} + {hunkActionPortals} ); }; diff --git a/packages/ui/src/components/views/git/HunkActions.test.tsx b/packages/ui/src/components/views/git/HunkActions.test.tsx index ac15c338..75fbf960 100644 --- a/packages/ui/src/components/views/git/HunkActions.test.tsx +++ b/packages/ui/src/components/views/git/HunkActions.test.tsx @@ -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(); 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( + {[0, 1, 2].map((index) => actions.push(`${action}:${hunk}`)} />)} + )); + const button = (label: string) => { + const target = container.querySelector(`button[aria-label="${label}"]`); + if (!target) throw new Error(`Missing ${label}`); + return target; + }; try { - await act(async () => root.render( actions.push(`${action}:${index}`)} />)); - const trigger = container.querySelector('button[aria-label="Hunks"]'); - if (!trigger) throw new Error('Missing hunk trigger'); - trigger.focus(); - await key('ArrowDown'); - const menu = document.querySelector('[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) { diff --git a/packages/ui/src/components/views/git/HunkActions.tsx b/packages/ui/src/components/views/git/HunkActions.tsx index bc6e5e1e..32315087 100644 --- a/packages/ui/src/components/views/git/HunkActions.tsx +++ b/packages/ui/src/components/views/git/HunkActions.tsx @@ -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 ( - - {insertions > 0 ? ( - +{insertions} - ) : null} - {insertions > 0 && deletions > 0 ? ( - / - ) : null} - {deletions > 0 ? ( - -{deletions} - ) : null} - - ); -}); - export const HunkActions = React.memo(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 ( - - - - - - - {t('diffView.hunk.label')} · {filePath} - - - {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 }); +
+
+ {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 ( - - {index > 0 ? : null} - onAction(index, primaryAction)} - aria-label={primaryTitle} - title={primaryTitle} - > - {busyPrimary ? ( - - ) : ( - - )} - {primaryTitle} - - - {!staged ? ( - onAction(index, 'discard')} - aria-label={discardTitle} - title={discardTitle} - > - {busyDiscard ? ( - - ) : ( - - )} - {discardTitle} - - ) : null} - + ); })} - - +
+
); }); diff --git a/packages/ui/src/lib/diff/patchFileDiff.test.ts b/packages/ui/src/lib/diff/patchFileDiff.test.ts index 90294e88..8675fff6 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.test.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.test.ts @@ -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([]); + }); +}); diff --git a/packages/ui/src/lib/diff/patchFileDiff.ts b/packages/ui/src/lib/diff/patchFileDiff.ts index 1d0ece4c..8d64ec79 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.ts @@ -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; +}; diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index e30d970e..0341b56e 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -137,15 +137,24 @@ 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 +- Desktop Changes floats a compact action capsule after each hunk's last changed row, + including single-hunk files. Whole-file controls remain in the Git panel. + `getPatchHunkAnchors` uses the canonical patch's final changed row and side, so a hunk + ending in deletions is anchored after those deletions rather than above them. + Zero-height Pierre annotation slots anchor the capsule over following context + without a separate band. At EOF the capsule lifts inside the code column; + a one-line code column has a minimum hit-target height. React controls mount only + for currently rendered slots and only after their rendered diff and anchor + identities match the current props. Comment annotations remain independent. +- The canonical three-line-context action patch stays separate from the full-file + display patch. Their bytes must be identical, or their file headers and full + blob identities must match, including when reusing a cached action patch. + Mismatch leaves actions unavailable until Retry obtains a matching pair. + Successful 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. + surface and VS Code does not mount these controls. - 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. diff --git a/packages/web/src/DiffView.hunks.test.ts b/packages/web/src/DiffView.hunks.test.ts index 412b5d79..ea702fa3 100644 --- a/packages/web/src/DiffView.hunks.test.ts +++ b/packages/web/src/DiffView.hunks.test.ts @@ -1,8 +1,12 @@ -import { test } from 'vitest'; -import { exerciseDiffHunkActions } from '@openchamber/ui/components/views/DiffView.hunks.fixture'; +import { afterAll, test } from 'vitest'; +import { closeDiffHunkWorkers, exerciseDiffHunkActions } from '@openchamber/ui/components/views/DiffView.hunks.fixture'; + +afterAll(closeDiffHunkWorkers); // 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')); +test('single-hunk inline actions require matching display and action versions too', () => exerciseDiffHunkActions('cold-single')); +test('split-view hunk controls occupy the matching rendered annotation rows', () => exerciseDiffHunkActions(undefined, 'side-by-side'));