mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
fix(diff): restore Monaco's focus and navigation behaviour, and repair the search and error paths
A code review found defects that thirteen prior review rounds missed, two of them regressions against the renderer this replaced. Every e2e spec clicks into a diff before exercising a shortcut, which is precisely the action that masked the first one. - The Pierre host was never focused on mount. Monaco's DiffViewer.handleMount called focus() on every single-file mount (combined rows did not), so opening a file from the sidebar left Cmd+F and F7 dead until the user clicked. autoFocusHost restores it for the single-file tab only. - F7 reset to the first hunk on every re-parse, because registerDiffNavigator always cleared the cursor and DiffViewer re-registers whenever fileDiff identity changes. The cursor now survives a re-registration of the same file and clamps if the hunk count shrank. - A highlight rejection that settled before the parse snapshot committed was overwritten by the success path, so once the worker pool latched failed every read-only diff rendered unhighlighted with no error and no Retry. - An edit scope stayed reserved when Pierre never emitted complete, permanently demoting that row to throwaway :concurrent: sessions and losing undo history. - Cmd+H forced the additions side without writing sideRef, so the next Cmd+F retargeted Original and tore down the replace row. - Pure deletions fell back to an old-file line number, so Original-side search expanded an unrelated collapsed hunk instead of revealing the match. - The search onPostRender identity lived in the options memo, and Pierre treats a changed onPostRender as forceRender, so every match step fully re-rendered the mounted range. - The error boundary resetKey was built from tab identity and path, neither of which moves when content changes, so a caught render throw stayed latched through saves and rewrites. It now uses the content-derived cacheKey, and the comment describes what the code does.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import DiffViewer from './DiffViewer'
|
||||
|
||||
let latestAutoFocusHost: boolean | undefined
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (s: object) => unknown) =>
|
||||
selector({
|
||||
settings: {},
|
||||
addDiffComment: () => {},
|
||||
deleteDiffComment: () => {},
|
||||
updateDiffComment: () => {}
|
||||
})
|
||||
}))
|
||||
vi.mock('./editor-shortcuts', () => ({ installEditorSaveShortcut: () => () => {} }))
|
||||
vi.mock('./diff-navigation-context', () => ({
|
||||
useDiffNavigatorRegistration: () => ({
|
||||
registerDiffNavigator: () => {},
|
||||
unregisterDiffNavigator: () => {}
|
||||
})
|
||||
}))
|
||||
vi.mock('./pierre-diff/use-pierre-file-diff', () => ({
|
||||
usePierreFileDiff: () => ({
|
||||
fileDiff: { name: 'file.ts', hunks: [] },
|
||||
error: null,
|
||||
retry: () => {},
|
||||
markEdited: () => {},
|
||||
editReady: true
|
||||
})
|
||||
}))
|
||||
vi.mock('./pierre-diff/PierreDiffProviders', () => ({
|
||||
PierreDiffProviders: ({ children }: { children: React.ReactNode }) => children
|
||||
}))
|
||||
vi.mock('./pierre-diff/PierreDiffSurface', () => ({
|
||||
PierreDiffSurface: (props: { autoFocusHost?: boolean }) => {
|
||||
latestAutoFocusHost = props.autoFocusHost
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
latestAutoFocusHost = undefined
|
||||
})
|
||||
|
||||
it('asks the Pierre host to take focus the way Monaco did on DiffViewer mount', () => {
|
||||
render(
|
||||
<DiffViewer
|
||||
modelKey="file"
|
||||
originalContent="old"
|
||||
modifiedContent="new"
|
||||
relativePath="file.ts"
|
||||
filePath="file.ts"
|
||||
language="typescript"
|
||||
sideBySide={false}
|
||||
/>
|
||||
)
|
||||
expect(latestAutoFocusHost).toBe(true)
|
||||
})
|
||||
@@ -118,6 +118,7 @@ export default function DiffViewer({
|
||||
return
|
||||
}
|
||||
const navigator: DiffNavigator = {
|
||||
id: modelKey,
|
||||
changeLines,
|
||||
container,
|
||||
scrollToChange: ({ lineNumber, hunkIndex, hunkCount }) => {
|
||||
@@ -140,6 +141,7 @@ export default function DiffViewer({
|
||||
}, [
|
||||
changeLines,
|
||||
changeTargets,
|
||||
modelKey,
|
||||
registerDiffNavigator,
|
||||
renderLimit.limited,
|
||||
unregisterDiffNavigator
|
||||
@@ -275,6 +277,7 @@ export default function DiffViewer({
|
||||
isEditable={Boolean(editable) && editReady}
|
||||
editStateKey={modelKey}
|
||||
collapseUnchanged={false}
|
||||
autoFocusHost
|
||||
worktreeId={worktreeId ?? ''}
|
||||
filePath={relativePath}
|
||||
language={language}
|
||||
|
||||
@@ -15,8 +15,9 @@ type FakeNavigator = DiffNavigator & {
|
||||
scrollToChange: DiffNavigator['scrollToChange'] & { mock: unknown }
|
||||
}
|
||||
|
||||
function createFakeNavigator(changeLines: number[]): FakeNavigator {
|
||||
function createFakeNavigator(changeLines: number[], id = 'file'): FakeNavigator {
|
||||
return {
|
||||
id,
|
||||
changeLines,
|
||||
container: document.createElement('div'),
|
||||
scrollToChange: vi.fn<DiffNavigator['scrollToChange']>()
|
||||
@@ -147,6 +148,45 @@ describe('DiffNavigationProvider', () => {
|
||||
expect(registrationRenderCount).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the F7 cursor across a same-file unregister/re-register (re-parse)', () => {
|
||||
mount()
|
||||
const first = createFakeNavigator([4, 20, 61, 80], 'a.ts')
|
||||
act(() => registration?.registerDiffNavigator(first))
|
||||
act(() => captured?.goToNextDiff())
|
||||
act(() => captured?.goToNextDiff())
|
||||
act(() => captured?.goToNextDiff())
|
||||
|
||||
const parsed = createFakeNavigator([4, 20, 61, 80], 'a.ts')
|
||||
act(() => registration?.unregisterDiffNavigator(first))
|
||||
act(() => registration?.registerDiffNavigator(parsed))
|
||||
act(() => captured?.goToNextDiff())
|
||||
|
||||
expect(parsed.scrollToChange).toHaveBeenLastCalledWith({
|
||||
lineNumber: 80,
|
||||
hunkIndex: 3,
|
||||
hunkCount: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('resets the F7 cursor when a different file registers', () => {
|
||||
mount()
|
||||
const first = createFakeNavigator([4, 20, 61, 80], 'a.ts')
|
||||
act(() => registration?.registerDiffNavigator(first))
|
||||
act(() => captured?.goToNextDiff())
|
||||
act(() => captured?.goToNextDiff())
|
||||
act(() => captured?.goToNextDiff())
|
||||
|
||||
const nextFile = createFakeNavigator([1, 2, 3, 4], 'b.ts')
|
||||
act(() => registration?.registerDiffNavigator(nextFile))
|
||||
act(() => captured?.goToNextDiff())
|
||||
|
||||
expect(nextFile.scrollToChange).toHaveBeenCalledWith({
|
||||
lineNumber: 1,
|
||||
hunkIndex: 0,
|
||||
hunkCount: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('installs a capture-phase key listener on register and removes it on unregister', () => {
|
||||
mount()
|
||||
const navigator = createFakeNavigator([2])
|
||||
|
||||
@@ -3,6 +3,8 @@ import { installDiffChangeNavigationShortcut } from './editor-shortcuts'
|
||||
|
||||
/** A mounted diff that can report its changes and scroll to one. */
|
||||
export type DiffNavigator = {
|
||||
/** File identity. Re-parses keep this so F7's cursor is not reset. */
|
||||
id: string
|
||||
/** Modified-side start line of every hunk, in document order. */
|
||||
changeLines: readonly number[]
|
||||
scrollToChange: (args: { lineNumber: number; hunkIndex: number; hunkCount: number }) => void
|
||||
@@ -46,6 +48,9 @@ export function DiffNavigationProvider({
|
||||
// Why: the cursor is provider-owned because the renderer no longer tracks a
|
||||
// "current change" of its own the way Monaco's goToDiff did.
|
||||
const cursorRef = useRef(-1)
|
||||
// Why: React effect re-registers by unregistering first, so identity must
|
||||
// outlive navigatorRef or a same-file re-parse would look like a new file.
|
||||
const fileIdRef = useRef<string | null>(null)
|
||||
// Why: changeCount must be state, not a ref — the header is a sibling consumer
|
||||
// and only re-renders (enabling the buttons) when the value identity changes.
|
||||
const [changeCount, setChangeCount] = useState(0)
|
||||
@@ -78,8 +83,16 @@ export function DiffNavigationProvider({
|
||||
|
||||
const registerDiffNavigator = useCallback(
|
||||
(navigator: DiffNavigator) => {
|
||||
if (fileIdRef.current !== navigator.id) {
|
||||
cursorRef.current = -1
|
||||
} else {
|
||||
const total = navigator.changeLines.length
|
||||
if (cursorRef.current >= total) {
|
||||
cursorRef.current = total === 0 ? -1 : total - 1
|
||||
}
|
||||
}
|
||||
fileIdRef.current = navigator.id
|
||||
navigatorRef.current = navigator
|
||||
cursorRef.current = -1
|
||||
// Hold at most one keyboard listener; replace any prior navigator's.
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = installDiffChangeNavigationShortcut(
|
||||
@@ -100,7 +113,6 @@ export function DiffNavigationProvider({
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = null
|
||||
navigatorRef.current = null
|
||||
cursorRef.current = -1
|
||||
setChangeCount(0)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { FileDiffMetadata } from '@pierre/diffs'
|
||||
import { PierreDiffSurface } from './PierreDiffSurface'
|
||||
|
||||
vi.mock('@pierre/diffs/react', () => ({ FileDiff: () => null }))
|
||||
vi.mock('./pierre-diff-context-copy', () => ({ installPierreContextualCopy: () => () => {} }))
|
||||
vi.mock('./use-pierre-diff-find', () => ({
|
||||
usePierreDiffFind: () => ({
|
||||
searchBar: null,
|
||||
handleContainerKeyDown: () => {},
|
||||
onPointerDown: () => {},
|
||||
onPostRender: () => {},
|
||||
onEditChange: () => {}
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-note-navigation', () => ({
|
||||
usePierreDiffNoteNavigation: () => () => {}
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-shift-wheel', () => ({
|
||||
usePierreDiffShiftWheel: () => () => {}
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-native-view', () => ({
|
||||
usePierreDiffNativeView: () => () => {}
|
||||
}))
|
||||
vi.mock('@/store', () => {
|
||||
const state = {
|
||||
editorFontZoomLevel: 0,
|
||||
clearDeliveredDiffComments: () => {},
|
||||
activeGroupIdByWorktree: {},
|
||||
scrollToDiffCommentId: null,
|
||||
keybindings: {}
|
||||
}
|
||||
const useAppStore = (selector: (s: typeof state) => unknown) => selector(state)
|
||||
useAppStore.getState = () => state
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
const fileDiff = { name: 'file.ts', hunks: [] } as unknown as FileDiffMetadata
|
||||
|
||||
function renderSurface(autoFocusHost?: boolean) {
|
||||
return render(
|
||||
<PierreDiffSurface
|
||||
fileDiff={fileDiff}
|
||||
sideBySide={false}
|
||||
isEditable={false}
|
||||
collapseUnchanged
|
||||
autoFocusHost={autoFocusHost}
|
||||
worktreeId="wt"
|
||||
filePath="file.ts"
|
||||
comments={[]}
|
||||
onDeleteComment={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('focuses the keyboard host on mount for the single-file tab', () => {
|
||||
const sidebar = document.createElement('button')
|
||||
document.body.append(sidebar)
|
||||
sidebar.focus()
|
||||
expect(document.activeElement).toBe(sidebar)
|
||||
|
||||
const { container } = renderSurface(true)
|
||||
const host = container.querySelector<HTMLElement>('[data-editor-keyboard-scope]')
|
||||
expect(host).not.toBeNull()
|
||||
expect(document.activeElement).toBe(host)
|
||||
})
|
||||
|
||||
it('does not steal focus on mount for combined-diff rows', () => {
|
||||
const sidebar = document.createElement('button')
|
||||
document.body.append(sidebar)
|
||||
sidebar.focus()
|
||||
|
||||
renderSurface()
|
||||
expect(document.activeElement).toBe(sidebar)
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { FileDiffMetadata, PostRenderPhase } from '@pierre/diffs'
|
||||
import type { PierreDiffInstance } from './PierreDiffSurface'
|
||||
import { PierreDiffSurface } from './PierreDiffSurface'
|
||||
|
||||
const captured = vi.hoisted(() => ({
|
||||
options: [] as { onPostRender?: (...args: unknown[]) => void }[]
|
||||
}))
|
||||
const consumers = vi.hoisted(() => ({
|
||||
height: vi.fn(),
|
||||
note: vi.fn(),
|
||||
search: vi.fn(),
|
||||
wheel: vi.fn(),
|
||||
native: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@pierre/diffs/react', () => ({
|
||||
FileDiff: (props: { options: { onPostRender?: (...args: unknown[]) => void } }) => {
|
||||
captured.options.push(props.options)
|
||||
return <div data-testid="file-diff" />
|
||||
}
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: object) => unknown) =>
|
||||
selector({
|
||||
editorFontZoomLevel: 0,
|
||||
clearDeliveredDiffComments: vi.fn(),
|
||||
activeGroupIdByWorktree: {}
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/error-boundaries/RecoverableRenderErrorBoundary', () => ({
|
||||
RecoverableRenderErrorBoundary: ({ children }: { children: React.ReactNode }) => children
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-find', () => ({
|
||||
usePierreDiffFind: () => ({
|
||||
searchBar: null,
|
||||
handleContainerKeyDown: () => {},
|
||||
onPointerDown: () => {},
|
||||
onPostRender: (...args: unknown[]) => consumers.search(...args),
|
||||
onEditChange: () => {}
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-note-navigation', () => ({
|
||||
usePierreDiffNoteNavigation: () => consumers.note
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-shift-wheel', () => ({
|
||||
usePierreDiffShiftWheel: () => consumers.wheel
|
||||
}))
|
||||
vi.mock('./use-pierre-diff-native-view', () => ({
|
||||
usePierreDiffNativeView: () => consumers.native
|
||||
}))
|
||||
vi.mock('./pierre-diff-context-copy', () => ({
|
||||
installPierreContextualCopy: () => () => {}
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
captured.options.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderSurface(onPostRender = consumers.height) {
|
||||
return render(
|
||||
<PierreDiffSurface
|
||||
fileDiff={{ name: 'file.ts', hunks: [] } as unknown as FileDiffMetadata}
|
||||
sideBySide={false}
|
||||
isEditable={false}
|
||||
collapseUnchanged
|
||||
worktreeId="workspace"
|
||||
filePath="file.ts"
|
||||
comments={[]}
|
||||
onDeleteComment={() => {}}
|
||||
onPostRender={onPostRender}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps Pierre onPostRender stable across search-only rerenders and still chains consumers', () => {
|
||||
const view = renderSurface()
|
||||
const first = captured.options.at(-1)?.onPostRender
|
||||
expect(first).toEqual(expect.any(Function))
|
||||
const node = document.createElement('div')
|
||||
const instance = {} as PierreDiffInstance
|
||||
first?.(node, instance, 'mount' as PostRenderPhase)
|
||||
expect(consumers.height).toHaveBeenCalledWith(node, 'mount', instance)
|
||||
expect(consumers.note).toHaveBeenCalledWith(node, 'mount', instance)
|
||||
expect(consumers.search).toHaveBeenCalledWith(node, 'mount', instance)
|
||||
expect(consumers.wheel).toHaveBeenCalledWith(node, 'mount', instance)
|
||||
expect(consumers.native).toHaveBeenCalledWith(node, 'mount', instance)
|
||||
view.rerender(
|
||||
<PierreDiffSurface
|
||||
fileDiff={{ name: 'file.ts', hunks: [] } as unknown as FileDiffMetadata}
|
||||
sideBySide={false}
|
||||
isEditable={false}
|
||||
collapseUnchanged
|
||||
worktreeId="workspace"
|
||||
filePath="file.ts"
|
||||
comments={[]}
|
||||
onDeleteComment={() => {}}
|
||||
onPostRender={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(captured.options.at(-1)?.onPostRender).toBe(first)
|
||||
const latestHeight = vi.fn()
|
||||
view.rerender(
|
||||
<PierreDiffSurface
|
||||
fileDiff={{ name: 'file.ts', hunks: [] } as unknown as FileDiffMetadata}
|
||||
sideBySide={false}
|
||||
isEditable={false}
|
||||
collapseUnchanged
|
||||
worktreeId="workspace"
|
||||
filePath="file.ts"
|
||||
comments={[]}
|
||||
onDeleteComment={() => {}}
|
||||
onPostRender={latestHeight}
|
||||
/>
|
||||
)
|
||||
expect(captured.options.at(-1)?.onPostRender).toBe(first)
|
||||
first?.(node, instance, 'update' as PostRenderPhase)
|
||||
expect(latestHeight).toHaveBeenCalledWith(node, 'update', instance)
|
||||
expect(consumers.note).toHaveBeenCalledWith(node, 'update', instance)
|
||||
expect(consumers.search).toHaveBeenCalledWith(node, 'update', instance)
|
||||
expect(consumers.wheel).toHaveBeenCalledWith(node, 'update', instance)
|
||||
expect(consumers.native).toHaveBeenCalledWith(node, 'update', instance)
|
||||
})
|
||||
@@ -47,6 +47,8 @@ export type PierreDiffSurfaceProps = {
|
||||
editStateKey?: string
|
||||
/** Collapse unchanged context. Combined diffs do; the single-file tab does not. */
|
||||
collapseUnchanged: boolean
|
||||
/** Single-file tab only. Combined rows must not steal focus on mount. */
|
||||
autoFocusHost?: boolean
|
||||
worktreeId: string
|
||||
filePath: string
|
||||
comments: readonly DecoratedDiffComment[]
|
||||
@@ -82,6 +84,7 @@ export function PierreDiffSurface({
|
||||
isEditable,
|
||||
editStateKey,
|
||||
collapseUnchanged,
|
||||
autoFocusHost = false,
|
||||
worktreeId,
|
||||
filePath,
|
||||
comments,
|
||||
@@ -107,6 +110,14 @@ export function PierreDiffSurface({
|
||||
)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<Editor<'file-diff', PierreDiffAnnotationData, undefined> | null>(null)
|
||||
// Why: Monaco focused the single-file DiffEditor on mount so Cmd+F/F7 worked
|
||||
// without a click. Combined DiffSectionItem did not — do not steal there.
|
||||
useLayoutEffect(() => {
|
||||
if (!autoFocusHost) {
|
||||
return
|
||||
}
|
||||
containerRef.current?.focus({ preventScroll: true })
|
||||
}, [autoFocusHost])
|
||||
const onEditChangeRef = useRef(onEditChange)
|
||||
const {
|
||||
searchBar,
|
||||
@@ -131,6 +142,31 @@ export function PierreDiffSurface({
|
||||
editorRef
|
||||
)
|
||||
const navigateToNote = usePierreDiffNoteNavigation({ worktreeId, filePath, comments })
|
||||
const postRenderRef = useRef({
|
||||
onPostRender,
|
||||
navigateToNote,
|
||||
searchPostRender,
|
||||
shiftWheelPostRender,
|
||||
nativeViewPostRender
|
||||
})
|
||||
postRenderRef.current = {
|
||||
onPostRender,
|
||||
navigateToNote,
|
||||
searchPostRender,
|
||||
shiftWheelPostRender,
|
||||
nativeViewPostRender
|
||||
}
|
||||
const handlePostRender = useCallback(
|
||||
(node: HTMLElement, instance: PierreDiffInstance, phase: PostRenderPhase) => {
|
||||
const chain = postRenderRef.current
|
||||
chain.onPostRender?.(node, phase, instance)
|
||||
chain.navigateToNote(node, phase, instance)
|
||||
chain.searchPostRender(node, phase, instance)
|
||||
chain.shiftWheelPostRender(node, phase, instance)
|
||||
chain.nativeViewPostRender(node, phase, instance)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const commentableLines = useMemo(
|
||||
() => (commentableLineNumbers ? new Set(commentableLineNumbers) : null),
|
||||
[commentableLineNumbers]
|
||||
@@ -173,24 +209,14 @@ export function PierreDiffSurface({
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
onPostRender: (node: HTMLElement, instance: PierreDiffInstance, phase: PostRenderPhase) => {
|
||||
onPostRender?.(node, phase, instance)
|
||||
navigateToNote(node, phase, instance)
|
||||
searchPostRender(node, phase, instance)
|
||||
shiftWheelPostRender(node, phase, instance)
|
||||
nativeViewPostRender(node, phase, instance)
|
||||
}
|
||||
onPostRender: handlePostRender
|
||||
}),
|
||||
[
|
||||
settings,
|
||||
sideBySide,
|
||||
collapseUnchanged,
|
||||
onPostRender,
|
||||
onAddComment,
|
||||
navigateToNote,
|
||||
searchPostRender,
|
||||
shiftWheelPostRender,
|
||||
nativeViewPostRender,
|
||||
handlePostRender,
|
||||
commentableLines,
|
||||
addCommentLabel
|
||||
]
|
||||
@@ -302,9 +328,11 @@ export function PierreDiffSurface({
|
||||
boundaryId="editor.pierre-diff-surface"
|
||||
surface="page"
|
||||
compact
|
||||
// Why: include the remount identity, not just the name — a caught render throw
|
||||
// otherwise stays latched until the row unmounts, even after content changes.
|
||||
resetKey={`${editStateKey ?? fileDiff.name}:${fileDiff.name}`}
|
||||
// Why cacheKey and not just the name: editStateKey is tab/section identity and name is the
|
||||
// path, so neither moves when only the contents change. A caught render throw would stay
|
||||
// latched through a save, an agent rewrite or a refetch, because the same surface stays
|
||||
// mounted while `sameFile` holds. cacheKey is derived from the content.
|
||||
resetKey={`${editStateKey ?? fileDiff.name}:${fileDiff.cacheKey ?? fileDiff.name}`}
|
||||
title={translate('editor.diff.renderFailed', 'This diff could not be rendered')}
|
||||
description={translate(
|
||||
'editor.diff.renderRetry',
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@pierre/diffs/edit', () => ({
|
||||
edit() {
|
||||
return () => this.options.onComplete?.({})
|
||||
}
|
||||
cleanUp(_reason?: 'discard' | 'recycle' | 'complete') {}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -109,6 +110,39 @@ it('keeps simultaneous copies of a scope independent', () => {
|
||||
reopened.finish()
|
||||
})
|
||||
|
||||
it('releases the scope when cleanup finishes without emitting complete', () => {
|
||||
const editor = createPierreEditor(
|
||||
'file-diff',
|
||||
withPierreDiffEditState({}, 'orphaned', {
|
||||
type: 'change',
|
||||
deletionLines: ['old\n'],
|
||||
additionLines: ['new\n']
|
||||
} as FileDiffMetadata)
|
||||
)
|
||||
editor.edit({} as never)
|
||||
editor.cleanUp('discard')
|
||||
const reopened = create('orphaned')
|
||||
expect(reopened.key).toBe('orphaned')
|
||||
reopened.finish()
|
||||
})
|
||||
|
||||
it('keeps the scope reserved across a recycle so a remount cannot share the key', () => {
|
||||
const editor = createPierreEditor(
|
||||
'file-diff',
|
||||
withPierreDiffEditState({}, 'recycled', {
|
||||
type: 'change',
|
||||
deletionLines: ['old\n'],
|
||||
additionLines: ['new\n']
|
||||
} as FileDiffMetadata)
|
||||
)
|
||||
editor.edit({} as never)
|
||||
editor.cleanUp('recycle')
|
||||
const concurrent = create('recycled')
|
||||
expect(concurrent.key).not.toBe('recycled')
|
||||
concurrent.finish()
|
||||
editor.cleanUp('discard')
|
||||
})
|
||||
|
||||
it('evicts dormant document history when retained text exceeds the budget', () => {
|
||||
const editor = create('oversized-history')
|
||||
const state = stored()
|
||||
|
||||
@@ -96,6 +96,17 @@ export const createPierreEditor: EditorFactory<PierreDiffAnnotationData, undefin
|
||||
EditStateManager.clear('file-diff', key)
|
||||
}
|
||||
active.add(key)
|
||||
const release = (): void => {
|
||||
if (!active.has(key)) {
|
||||
return
|
||||
}
|
||||
active.delete(key)
|
||||
if (duplicate) {
|
||||
EditStateManager.clear('file-diff', key)
|
||||
} else {
|
||||
retainDormant(key)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const editor = new Editor(
|
||||
editorType,
|
||||
@@ -103,18 +114,14 @@ export const createPierreEditor: EditorFactory<PierreDiffAnnotationData, undefin
|
||||
...options,
|
||||
initialState: options.initialState ?? (restored as typeof options.initialState),
|
||||
onComplete: (event) => {
|
||||
active.delete(key)
|
||||
if (duplicate) {
|
||||
EditStateManager.clear('file-diff', key)
|
||||
} else {
|
||||
retainDormant(key)
|
||||
}
|
||||
release()
|
||||
options.onComplete?.(event)
|
||||
}
|
||||
},
|
||||
key
|
||||
)
|
||||
const edit = editor.edit.bind(editor)
|
||||
const cleanUp = editor.cleanUp.bind(editor)
|
||||
editor.edit = (instance) => {
|
||||
try {
|
||||
return edit(instance)
|
||||
@@ -123,6 +130,17 @@ export const createPierreEditor: EditorFactory<PierreDiffAnnotationData, undefin
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Why: settleEditSession can return or throw before onComplete, which used to pin
|
||||
// the scope in `active` and mint throwaway concurrent keys on the next remount.
|
||||
editor.cleanUp = (reason) => {
|
||||
try {
|
||||
cleanUp(reason)
|
||||
} finally {
|
||||
if (reason !== 'recycle') {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
return editor
|
||||
} catch (error) {
|
||||
active.delete(key)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { pierreSearchRevealLine } from './pierre-diff-search-view'
|
||||
import { buildPierreFileDiff } from './pierre-diff-metadata'
|
||||
|
||||
const input = {
|
||||
path: 'file.ts',
|
||||
status: 'modified',
|
||||
cacheKey: 'search-view',
|
||||
parseDiffOptions: {}
|
||||
}
|
||||
|
||||
it('passes addition-side matches through unchanged', () => {
|
||||
const diff = buildPierreFileDiff({
|
||||
...input,
|
||||
originalContent: 'a\n',
|
||||
modifiedContent: 'b\n'
|
||||
})
|
||||
expect(pierreSearchRevealLine(diff, 1, 'additions')).toBe(1)
|
||||
})
|
||||
|
||||
it('does not feed a pure-deletion line number to revealLine', () => {
|
||||
const diff = buildPierreFileDiff({
|
||||
...input,
|
||||
originalContent: `${'keep\n'.repeat(40)}${'drop\n'.repeat(10)}GONE\n${'keep\n'.repeat(40)}old\n`,
|
||||
modifiedContent: `${'keep\n'.repeat(80)}new\n`
|
||||
})
|
||||
const goneLine = 51
|
||||
const hunk = diff.hunks[0]
|
||||
const additionEnd = hunk.additionStart + hunk.additionCount
|
||||
expect(goneLine).toBeGreaterThan(additionEnd)
|
||||
const revealed = pierreSearchRevealLine(diff, goneLine, 'deletions')
|
||||
expect(revealed).not.toBe(goneLine)
|
||||
expect(revealed).toBeGreaterThanOrEqual(hunk.additionStart)
|
||||
expect(revealed).toBeLessThanOrEqual(additionEnd)
|
||||
})
|
||||
@@ -146,17 +146,21 @@ export function pierreSearchRevealLine(
|
||||
if (side === 'additions') {
|
||||
return lineNumber
|
||||
}
|
||||
let modifiedLine = lineNumber
|
||||
// revealLine is new-file only; never pass a pure-deletion old-file number.
|
||||
let modifiedLine = 0
|
||||
let found = false
|
||||
iterateOverDiff({
|
||||
diff,
|
||||
diffStyle: 'unified',
|
||||
expandedHunks: true,
|
||||
callback: ({ deletionLine, additionLine }) => {
|
||||
if (deletionLine?.lineNumber !== lineNumber) {
|
||||
return
|
||||
callback: ({ deletionLine, additionLine }): boolean => {
|
||||
if (additionLine) {
|
||||
modifiedLine = additionLine.lineNumber
|
||||
}
|
||||
modifiedLine = additionLine?.lineNumber ?? lineNumber
|
||||
return true
|
||||
if (deletionLine?.lineNumber === lineNumber) {
|
||||
found = true
|
||||
}
|
||||
return found && additionLine != null
|
||||
}
|
||||
})
|
||||
return modifiedLine
|
||||
|
||||
@@ -76,6 +76,27 @@ it('searches original content and restores its native selection on close', () =>
|
||||
expect(editor.setDeletedTextSelectionActive).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('keeps replace targeting additions after Cmd+H then Cmd+F', () => {
|
||||
results.mockReturnValue(null)
|
||||
const { result, find } = setup(true)
|
||||
const deleted = document.createElement('div')
|
||||
deleted.setAttribute('data-code', '')
|
||||
deleted.setAttribute('data-deletions', '')
|
||||
act(() =>
|
||||
result.current.onPointerDown({
|
||||
nativeEvent: { composedPath: () => [deleted] }
|
||||
} as unknown as React.PointerEvent<HTMLElement>)
|
||||
)
|
||||
find('h')
|
||||
expect(result.current.searchBar?.side).toBe('additions')
|
||||
expect(result.current.searchBar?.canReplace).toBe(true)
|
||||
expect(result.current.searchBar?.replaceOpen).toBe(true)
|
||||
find('f')
|
||||
expect(result.current.searchBar?.side).toBe('additions')
|
||||
expect(result.current.searchBar?.canReplace).toBe(true)
|
||||
expect(results.mock.lastCall?.[0].text).toBe('modified')
|
||||
})
|
||||
|
||||
it('fences replacement against edits made after async search started', () => {
|
||||
results.mockReturnValue({
|
||||
matches: [
|
||||
|
||||
@@ -144,6 +144,7 @@ export function usePierreDiffFind({
|
||||
return
|
||||
}
|
||||
const nextSide = replace && isEditable ? 'additions' : sideRef.current
|
||||
sideRef.current = nextSide
|
||||
setSide(nextSide)
|
||||
if (replace && isEditable && nextSide === 'additions') {
|
||||
setReplaceOpen(true)
|
||||
|
||||
@@ -130,6 +130,19 @@ it('surfaces a detached highlight failure so the retry affordance still appears'
|
||||
expect(result.current.fileDiff).toBe(diff)
|
||||
})
|
||||
|
||||
it('surfaces a highlight failure that settles before the parse snapshot commits', async () => {
|
||||
// Why: !pool.isWorkingPool() rejects synchronously, so highlight.catch runs before
|
||||
// requestPierreFileDiff's caller commits { error: null }.
|
||||
vi.mocked(requestPierreFileDiff).mockImplementation(async (_input, _signal, _block, onError) => {
|
||||
onError?.(new Error('worker died'))
|
||||
return diff
|
||||
})
|
||||
const { result } = renderHook(() => usePierreFileDiff(input))
|
||||
await act(async () => vi.runOnlyPendingTimers())
|
||||
expect(result.current.error).toBe('worker died')
|
||||
expect(result.current.fileDiff).toBe(diff)
|
||||
})
|
||||
|
||||
it('does not re-parse when only editability flips, but primes the highlight', async () => {
|
||||
vi.mocked(requestPierreFileDiff).mockResolvedValue(diff)
|
||||
let finishPrime: (value: void) => void = () => {}
|
||||
|
||||
@@ -37,21 +37,26 @@ export function usePierreFileDiff(input: PierreDiffInput | null, editable = fals
|
||||
// Edits already paint through Pierre; coalesce parent echoes before recomputing.
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
// Why: an already-rejected highlight enqueues onHighlightError before this then
|
||||
// commits the snapshot, so the error must be captured for both orderings.
|
||||
let highlightError: string | null = null
|
||||
void requestPierreFileDiff(
|
||||
input,
|
||||
controller.signal,
|
||||
editableRef.current,
|
||||
(error: unknown) =>
|
||||
(error: unknown) => {
|
||||
highlightError = error instanceof Error ? error.message : String(error)
|
||||
setSnapshot((previous) =>
|
||||
previous && previous.input === input
|
||||
? { ...previous, error: error instanceof Error ? error.message : String(error) }
|
||||
? { ...previous, error: highlightError }
|
||||
: previous
|
||||
)
|
||||
}
|
||||
).then(
|
||||
(diff) => {
|
||||
if (!controller.signal.aborted) {
|
||||
renderedScopeRef.current = JSON.stringify([input.cacheKey, input.path])
|
||||
setSnapshot({ input, diff, error: null })
|
||||
setSnapshot({ input, diff, error: highlightError })
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user