diff --git a/config/patches/@pierre__diffs@1.4.1.patch b/config/patches/@pierre__diffs@1.4.1.patch index 8dfaca27f8b..38407e77c3a 100644 --- a/config/patches/@pierre__diffs@1.4.1.patch +++ b/config/patches/@pierre__diffs@1.4.1.patch @@ -174,3 +174,29 @@ index 4e6aeb4cd0a5dc1f9dd094b874f338cafceff29b..fc3bcf89f79ea56dba0a8a84b507fc97 } return this.renderCache.result != null ? this.processDiffResult(this.renderCache.diff, renderRange, this.renderCache.result) : void 0; } +diff --git a/dist/editor/editor.js b/dist/editor/editor.js +--- a/dist/editor/editor.js ++++ b/dist/editor/editor.js +@@ -408,6 +408,10 @@ + } else if (this.#ownsVerticalViewport) this.#scrollToPrimaryCaret(); + this.#checkpointEditSessionState(); + } ++ setDeletedTextSelectionActive(active) { ++ this.#setDeletedTextSelectionActive(active); ++ if (active && this.#selections !== void 0) this.#updateSelections([]); ++ } + setSelections(selections) { + const textDocument = this.#editSession?.document; + if (textDocument === void 0) throw new Error("Editor.setSelections: Text document is not initialized"); +diff --git a/dist/editor/editor.d.ts b/dist/editor/editor.d.ts +--- a/dist/editor/editor.d.ts ++++ b/dist/editor/editor.d.ts +@@ -184,6 +184,8 @@ + selections, + view + }: EditorViewState): void; ++ /** Select original-side native text without simulating a pointer gesture. */ ++ setDeletedTextSelectionActive(active: boolean): void; + setSelections(selections: (Range & { + direction: 'none' | 'backward' | 'forward'; + })[]): void; diff --git a/config/patches/pierre-diffs.md b/config/patches/pierre-diffs.md index b8c743304f8..99d72a5869b 100644 --- a/config/patches/pierre-diffs.md +++ b/config/patches/pierre-diffs.md @@ -28,3 +28,7 @@ the patch when an upstream release provides the same behavior. The package entrypoint also exposes its existing `iterateOverDiff` iterator. Search uses it to map original/context line numbers onto virtualized split and unified rows, reusing Pierre's hunk logic without copying its implementation. + +`Editor.setDeletedTextSelectionActive` exposes the existing original-side selection +mode. Native selection restoration and search closing use it to clear modified-side +carets and keep the original selection visible without simulating pointer input. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a926a3dc272..341492b0e69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,7 +109,7 @@ overrides: monaco-editor>dompurify: 3.4.13 patchedDependencies: - '@pierre/diffs@1.4.1': cf3934292c1f82103136fce3b19bfe68097c39175218fa78c2ac3bbc3f332b0d + '@pierre/diffs@1.4.1': d04bc1a060d8e2334c8f78c17878a1ae9fa32a8849a4defc0535b750bc488284 '@vscode/windows-process-tree@0.8.0': e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 @@ -143,7 +143,7 @@ importers: version: 2.5.6 '@pierre/diffs': specifier: 1.4.1 - version: 1.4.1(patch_hash=cf3934292c1f82103136fce3b19bfe68097c39175218fa78c2ac3bbc3f332b0d)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.4.1(patch_hash=d04bc1a060d8e2334c8f78c17878a1ae9fa32a8849a4defc0535b750bc488284)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) @@ -8280,7 +8280,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.4.1(patch_hash=cf3934292c1f82103136fce3b19bfe68097c39175218fa78c2ac3bbc3f332b0d)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@pierre/diffs@1.4.1(patch_hash=d04bc1a060d8e2334c8f78c17878a1ae9fa32a8849a4defc0535b750bc488284)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9541d2a8bb2..f22808d6060 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -155,6 +155,8 @@ --orca-security-ring: #a1a1a1; --background: #fff; --editor-surface: #ffffff; + /* Match Monaco's default text-selection colors across editor surfaces. */ + --editor-selection-background: #add6ff; --foreground: #0a0a0a; --card: #fff; --card-foreground: #0a0a0a; @@ -273,6 +275,7 @@ --orca-security-ring: #737373; --background: #0a0a0a; --editor-surface: #1e1e1e; + --editor-selection-background: #264f78; --foreground: #fafafa; --card: #171717; --card-foreground: #fafafa; diff --git a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx index c31cd4867a4..2e0192460d3 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx @@ -67,11 +67,13 @@ export function DiffCommentPopover({ // Why: mirror `top` into a ref so the measure callback stays stable and the ResizeObserver isn't re-mounted each scroll frame. const topRef = useRef(top ?? 0) - topRef.current = top ?? 0 const lineHeightRef = useRef(lineHeight) - lineHeightRef.current = lineHeight const layoutRef = useRef(layout) - layoutRef.current = layout + useLayoutEffect(() => { + topRef.current = top ?? 0 + lineHeightRef.current = lineHeight + layoutRef.current = layout + }, [top, lineHeight, layout]) const measureResolvedTop = useCallback((): void => { if (layoutRef.current === 'inline') { diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 4e423769c4a..183f593ac52 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -240,6 +240,7 @@ export function DiffSectionItem({ () => fileDiff ? ( { + saveRef.current = saveSection + }, [saveSection]) return saveRef } diff --git a/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx b/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx index 0f535cc7fa8..1de541265ca 100644 --- a/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx +++ b/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { FileDiff } from '@pierre/diffs/react' import type { FileDiff as PierreFileDiff, @@ -27,6 +27,7 @@ import { import { usePierreDiffFind } from './use-pierre-diff-find' import { PierreDiffSearchBar } from './PierreDiffSearchBar' import { usePierreDiffShiftWheel } from './use-pierre-diff-shift-wheel' +import { usePierreDiffNativeView } from './use-pierre-diff-native-view' import { installPierreContextualCopy } from './pierre-diff-context-copy' import { editorShortcutMatches } from '../editor-shortcuts' import { usePierreDiffNoteNavigation } from './use-pierre-diff-note-navigation' @@ -112,11 +113,21 @@ export function PierreDiffSurface({ onPostRender: searchPostRender, onEditChange: searchEditChange } = usePierreDiffFind({ isEditable, containerRef, editorRef, fileDiff }) - onEditChangeRef.current = (file) => { - onEditChange?.(file) - searchEditChange() - } + useLayoutEffect(() => { + onEditChangeRef.current = (file) => { + onEditChange?.(file) + searchEditChange() + } + }, [onEditChange, searchEditChange]) const shiftWheelPostRender = usePierreDiffShiftWheel() + const nativeViewPostRender = usePierreDiffNativeView( + editStateKey, + fileDiff, + isEditable, + containerRef, + activeGroupId, + editorRef + ) const navigateToNote = usePierreDiffNoteNavigation({ worktreeId, filePath, comments }) const commentableLines = useMemo( () => (commentableLineNumbers ? new Set(commentableLineNumbers) : null), @@ -125,7 +136,9 @@ export function PierreDiffSurface({ // Why: Monaco's diff panes owned `editor.copyContext`; restore it for Pierre rows. const fileInfoRef = useRef({ relativePath: filePath, language: language ?? '' }) - fileInfoRef.current = { relativePath: filePath, language: language ?? '' } + useLayoutEffect(() => { + fileInfoRef.current = { relativePath: filePath, language: language ?? '' } + }, [filePath, language]) useEffect(() => { const node = containerRef.current if (!node) { @@ -161,6 +174,7 @@ export function PierreDiffSurface({ navigateToNote(node, phase, instance) searchPostRender(node, phase, instance) shiftWheelPostRender(node, phase, instance) + nativeViewPostRender(node, phase, instance) } }), [ @@ -172,6 +186,7 @@ export function PierreDiffSurface({ navigateToNote, searchPostRender, shiftWheelPostRender, + nativeViewPostRender, commentableLines, addCommentLabel ] diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-native-view-state.test.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-native-view-state.test.ts new file mode 100644 index 00000000000..b42c4ae1a25 --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-native-view-state.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { buildPierreFileDiff } from './pierre-diff-metadata' +import { + readPierreNativeSelection, + restorePierreNativeSelection +} from './pierre-diff-native-view-state' +import type { PierreDiffInstance } from './PierreDiffSurface' + +const diff = buildPierreFileDiff({ + path: 'test.txt', + status: 'modified', + parseDiffOptions: { ignoreWhitespace: true }, + originalContent: 'old one\nold two\n', + modifiedContent: 'new one\nnew two\n' +}) +const instance = { revealLine: () => false } as unknown as PierreDiffInstance +function host() { + const host = document.createElement('diffs-container') + const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) + root.innerHTML = + '
old one\n
old two\n
' + document.body.append(host) + const nodes = [...root.querySelectorAll('[data-line]')].map((row) => row.firstChild!) + const range = document.createRange() + range.setStart(nodes[0], 1) + range.setEnd(nodes[1], 3) + const selection = { + anchorNode: nodes[1], + anchorOffset: 3, + focusNode: nodes[0], + focusOffset: 1, + rangeCount: 1, + getRangeAt: () => range, + setBaseAndExtent: vi.fn() + } + Object.defineProperty(root, 'getSelection', { value: () => selection }) + return { host, root, nodes, selection } +} +afterEach(() => document.body.replaceChildren()) + +it('restores a backward original selection into new token nodes', () => { + const first = host() + const saved = readPierreNativeSelection(first.host, diff, true)! + expect(saved.side).toBe('deletions') + expect(saved.backward).toBe(true) + const next = host() + expect(restorePierreNativeSelection(next.host, diff, saved, instance)).toBe(true) + expect(next.selection.setBaseAndExtent).toHaveBeenCalledWith(next.nodes[1], 3, next.nodes[0], 1) +}) + +it('does not restore offsets over changed original content', () => { + const view = host() + const saved = readPierreNativeSelection(view.host, diff, false)! + expect( + restorePierreNativeSelection( + view.host, + { ...diff, deletionLines: ['external change\n'] }, + saved, + instance + ) + ).toBe(true) + expect(view.selection.setBaseAndExtent).not.toHaveBeenCalled() +}) + +it('waits for both virtualized endpoints rather than selecting a partial range', () => { + const view = host() + const saved = readPierreNativeSelection(view.host, diff, false)! + view.nodes[1].parentElement!.remove() + expect(restorePierreNativeSelection(view.host, diff, saved, instance)).toBe(false) + expect(view.selection.setBaseAndExtent).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-native-view-state.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-native-view-state.ts new file mode 100644 index 00000000000..e56702c36ed --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-native-view-state.ts @@ -0,0 +1,117 @@ +import type { FileDiffMetadata } from '@pierre/diffs' +import type { Editor } from '@pierre/diffs/edit' +import { getDiffContentSignature } from '../diff-content-signature' +import { setWithLRU } from '@/lib/scroll-cache' +import { getPierreSelectionRange, selectionSide } from './pierre-diff-selection' +import { getPierreSearchRanges, pierreSearchRevealLine } from './pierre-diff-search-view' +import type { PierreDiffInstance } from './PierreDiffSurface' +import type { DiffSearchMatch } from './pierre-diff-search' +import type { DiffSearchSide } from './PierreDiffSearchBar' + +export type PierreNativeViewState = { + scrollLeft: number + selection?: { + range: NonNullable> + side: DiffSearchSide + signature: string + backward: boolean + } +} +const cache = new Map() +export function rememberPierreNativeView(key: string, state: PierreNativeViewState) { + setWithLRU(cache, key, state) +} +export function getPierreNativeView(key: string) { + return cache.get(key) +} + +function sourceSignature(diff: FileDiffMetadata, side: DiffSearchSide): string { + return getDiffContentSignature( + (side === 'deletions' ? diff.deletionLines : diff.additionLines).join('') + ) +} +export function readPierreNativeSelection( + host: HTMLElement, + diff: FileDiffMetadata, + editable: boolean +): PierreNativeViewState['selection'] { + const root = host.shadowRoot as ShadowRoot & { getSelection?: () => Selection | null } + const selection = root?.getSelection?.() + if ( + !root?.contains(selection?.anchorNode ?? null) || + !root.contains(selection?.focusNode ?? null) + ) { + return + } + const range = getPierreSelectionRange(selection ?? null) + if (!range || !selection) { + return + } + const nativeRange = selection.getRangeAt(0) + const side = selectionSide(nativeRange.startContainer) ?? 'additions' + if (editable && side === 'additions') { + return + } + return { + range, + side, + signature: sourceSignature(diff, side), + backward: + selection.anchorNode === nativeRange.endContainer && + selection.anchorOffset === nativeRange.endOffset + } +} + +export function restorePierreNativeSelection( + host: HTMLElement, + diff: FileDiffMetadata, + saved: NonNullable, + instance: PierreDiffInstance, + editor?: Pick | null +): boolean { + if (saved.signature !== sourceSignature(diff, saved.side)) { + return true + } + const { range, side } = saved + if ( + instance.revealLine(pierreSearchRevealLine(diff, range.startLineNumber, side)) || + instance.revealLine(pierreSearchRevealLine(diff, range.endLineNumber, side)) + ) { + return false + } + const match: DiffSearchMatch = { + start: 0, + end: 0, + replacement: '', + range: { + start: { line: range.startLineNumber - 1, character: range.startColumn - 1 }, + end: { line: range.endLineNumber - 1, character: range.endColumn - 1 } + } + } + const { activeStart: first, activeEnd: last } = getPierreSearchRanges( + host, + diff, + side, + [match], + match + ) + if (!first || !last) { + return false + } + const selection = ( + host.shadowRoot as ShadowRoot & { getSelection?: () => Selection | null } + )?.getSelection?.() + if (!selection) { + return false + } + const start = { node: first.startContainer, offset: first.startOffset } + const end = { node: last.endContainer, offset: last.endOffset } + editor?.setDeletedTextSelectionActive(side === 'deletions') + selection.setBaseAndExtent( + saved.backward ? end.node : start.node, + saved.backward ? end.offset : start.offset, + saved.backward ? start.node : end.node, + saved.backward ? start.offset : end.offset + ) + return true +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts index d395397c4ea..87a1b5a32f3 100644 --- a/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts @@ -92,6 +92,7 @@ export function buildPierreDiffStyle( // drop all the way to the default serif face. Reuse the terminal's // cross-platform monospace chain, which always ends in `monospace`. '--diffs-font-family': buildFontFamily(resolveEditorFontFamily(settings)), + '--diffs-editor-selection-bg': 'var(--editor-selection-background)', '--diffs-font-size': `${fontSize}px`, '--diffs-line-height': `${buildPierreDiffMetrics(settings, editorFontZoomLevel).lineHeight}px` } as CSSProperties diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-search-view.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-search-view.ts index 7162d3dc7aa..0a037529592 100644 --- a/src/renderer/src/components/editor/pierre-diff/pierre-diff-search-view.ts +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-search-view.ts @@ -90,7 +90,10 @@ export function getPierreSearchRanges( } }) } - const result: { matches: Range[]; active: Range[] } = { matches: [], active: [] } + const result: { matches: Range[]; active: Range[]; activeStart?: Range; activeEnd?: Range } = { + matches: [], + active: [] + } for (const row of rows) { if (split && !row.closest(`[data-code][data-${side}]`)) { continue @@ -122,6 +125,12 @@ export function getPierreSearchRanges( result.matches.push(range) if (match === active) { result.active.push(range) + if (match.range.start.line === line - 1) { + result.activeStart = range + } + if (match.range.end.line === line - 1) { + result.activeEnd = range + } } } } diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-selection.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-selection.ts index be5c7f8e388..6bc0b5337b7 100644 --- a/src/renderer/src/components/editor/pierre-diff/pierre-diff-selection.ts +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-selection.ts @@ -1,6 +1,6 @@ import type { IRange } from 'monaco-editor' -function selectionSide(node: Node): 'additions' | 'deletions' | null { +export function selectionSide(node: Node): 'additions' | 'deletions' | null { const element = node instanceof Element ? node : node.parentElement const code = element?.closest('[data-code]') const type = element?.closest('[data-line]')?.getAttribute('data-line-type') diff --git a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.test.tsx b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.test.tsx index f859817e385..e2e6912597f 100644 --- a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.test.tsx +++ b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.test.tsx @@ -6,7 +6,9 @@ import { usePierreDiffFind } from './use-pierre-diff-find' const { results } = vi.hoisted(() => ({ results: vi.fn() })) vi.mock('./use-pierre-diff-search-results', () => ({ usePierreDiffSearchResults: results })) -vi.mock('./use-pierre-diff-search-view', () => ({ usePierreDiffSearchView: () => vi.fn() })) +vi.mock('./use-pierre-diff-search-view', () => ({ + usePierreDiffSearchView: () => ({ onPostRender: vi.fn(), selectActive: vi.fn() }) +})) vi.mock('../editor-shortcuts', () => ({ editorShortcutMatches: (action: string, event: KeyboardEvent) => event.key === (action === 'editor.find' ? 'f' : 'h') && event.ctrlKey @@ -25,6 +27,7 @@ function setup(isEditable: boolean) { getText: vi.fn(() => 'modified'), applyEdits: vi.fn(), setSelections: vi.fn(), + setDeletedTextSelectionActive: vi.fn(), focus: vi.fn() } const fileDiff = { additionLines: ['modified'], deletionLines: ['original'] } as FileDiffMetadata @@ -62,13 +65,15 @@ it('opens find on the first press without creating a writable read-only session' expect(result.current.searchBar).toBeNull() }) -it('searches original content and never replaces on that side', () => { +it('searches original content and restores its native selection on close', () => { results.mockReturnValue(null) - const { result, find } = setup(true) + const { result, find, editor } = setup(true) find() act(() => result.current.searchBar?.onSide('deletions')) expect(results.mock.lastCall?.[0].text).toBe('original') expect(result.current.searchBar?.canReplace).toBe(false) + act(() => result.current.searchBar?.onClose()) + expect(editor.setDeletedTextSelectionActive).toHaveBeenCalledWith(true) }) it('fences replacement against edits made after async search started', () => { diff --git a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts index 199f8f90e57..2064f7270b0 100644 --- a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts +++ b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts @@ -56,7 +56,12 @@ export function usePierreDiffFind({ const matches = result?.matches const index = selection?.request === request ? selection.index : 0 const active = matches?.[index] - const onPostRender = usePierreDiffSearchView({ fileDiff, side, matches, active }) + const { onPostRender, selectActive } = usePierreDiffSearchView({ + fileDiff, + side, + matches, + active + }) const canReplace = isEditable && side === 'additions' useEffect(() => { if (canReplace && active) { @@ -68,9 +73,11 @@ export function usePierreDiffFind({ if (isEditable && side === 'additions') { editorRef.current?.focus({ preventScroll: true }) } else { + editorRef.current?.setDeletedTextSelectionActive(side === 'deletions') + selectActive() containerRef.current?.focus({ preventScroll: true }) } - }, [isEditable, side, editorRef, containerRef]) + }, [isEditable, side, editorRef, containerRef, selectActive]) const navigate = useCallback( (direction: 1 | -1) => { if (!request || !matches?.length) { diff --git a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-native-view.test.tsx b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-native-view.test.tsx new file mode 100644 index 00000000000..03ca22a412b --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-native-view.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { FileDiffMetadata } from '@pierre/diffs' +import type { PierreDiffInstance } from './PierreDiffSurface' +import { usePierreDiffNativeView } from './use-pierre-diff-native-view' + +const state = vi.hoisted(() => ({ + read: vi.fn(), + restore: vi.fn(), + remember: vi.fn(), + saved: { + scrollLeft: 120, + selection: { + range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 5 }, + side: 'deletions', + signature: 'same', + backward: false + } + } +})) +vi.mock('./pierre-diff-native-view-state', () => ({ + getPierreNativeView: () => state.saved, + readPierreNativeSelection: state.read, + restorePierreNativeSelection: state.restore, + rememberPierreNativeView: state.remember +})) + +afterEach(() => { + cleanup() + document.body.replaceChildren() + vi.resetAllMocks() + vi.unstubAllGlobals() +}) + +function setup(activeGroup = 'left') { + const frames = new Map() + let id = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)) + const container = document.createElement('div') + container.dataset.tabGroupBodyId = 'left' + const host = document.createElement('div') + container.append(host) + document.body.append(container) + const instance = { + getCodeScrollLeft: () => state.saved.scrollLeft, + setCodeScrollLeft: vi.fn() + } as unknown as PierreDiffInstance + const editorRef = { current: { setDeletedTextSelectionActive: vi.fn() } } + const containerRef = { current: container } + state.restore.mockReturnValue(true) + const hook = renderHook( + ({ activeGroup }) => + usePierreDiffNativeView( + 'file', + {} as FileDiffMetadata, + true, + containerRef, + activeGroup, + editorRef + ), + { initialProps: { activeGroup } } + ) + act(() => hook.result.current(host, 'mount', instance)) + const tick = () => + act(() => { + const pending = [...frames.values()] + frames.clear() + for (const callback of pending) { + callback(0) + } + }) + return { ...hook, tick, container, editorRef } +} + +it('reapplies native selection when editor initialization replaces the selected nodes', () => { + const { tick } = setup() + tick() + expect(state.restore).toHaveBeenCalledTimes(1) + tick() + expect(state.restore).toHaveBeenCalledTimes(2) + state.read.mockReturnValue(state.saved.selection) + tick() + tick() + expect(state.restore).toHaveBeenCalledTimes(2) +}) + +it('does not replace selection in another active split group', () => { + const { tick, rerender } = setup('right') + tick() + expect(state.restore).not.toHaveBeenCalled() + rerender({ activeGroup: 'left' }) + tick() + expect(state.restore).toHaveBeenCalledOnce() +}) + +it('cancels delayed restoration when the user interacts elsewhere', () => { + const { tick } = setup() + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })) + tick() + expect(state.restore).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-native-view.ts b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-native-view.ts new file mode 100644 index 00000000000..2040445aea2 --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-native-view.ts @@ -0,0 +1,174 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type { FileDiffMetadata, PostRenderPhase } from '@pierre/diffs' +import type { Editor } from '@pierre/diffs/edit' +import type { PierreDiffInstance } from './PierreDiffSurface' +import { scrollPierreDiffToLine } from './pierre-diff-scroll' +import { + getPierreNativeView, + type PierreNativeViewState, + readPierreNativeSelection, + rememberPierreNativeView, + restorePierreNativeSelection +} from './pierre-diff-native-view-state' + +export function usePierreDiffNativeView( + key: string | undefined, + fileDiff: FileDiffMetadata, + editable: boolean, + containerRef: React.RefObject, + activeGroupId: string, + editorRef: React.RefObject | null> +) { + const view = useRef<{ host: HTMLElement; instance: PierreDiffInstance } | null>(null) + const latest = useRef({ fileDiff, editable, activeGroupId }) + useLayoutEffect(() => { + latest.current = { fileDiff, editable, activeGroupId } + }, [fileDiff, editable, activeGroupId]) + const pending = useRef(key ? getPierreNativeView(key) : undefined) + const frame = useRef(null) + const attempts = useRef(0) + const lastSnapshot = useRef(undefined) + const schedule = useCallback(() => { + if (frame.current !== null || !pending.current || attempts.current >= 8) { + return + } + frame.current = requestAnimationFrame(() => { + frame.current = null + const current = view.current, + saved = pending.current + if (!current || !saved || !current.host.isConnected) { + return + } + if (latest.current.editable && !editorRef.current) { + attempts.current++ + schedule() + return + } + const group = current.host.closest('[data-tab-group-body-id]') + if ( + saved.selection && + group && + group.dataset.tabGroupBodyId !== latest.current.activeGroupId + ) { + current.instance.setCodeScrollLeft(saved.scrollLeft) + return + } + const selected = readPierreNativeSelection( + current.host, + latest.current.fileDiff, + latest.current.editable + ) + if (saved.selection?.side === 'deletions') { + editorRef.current?.setDeletedTextSelectionActive(true) + } + if ( + current.instance.getCodeScrollLeft() === saved.scrollLeft && + JSON.stringify(selected) === JSON.stringify(saved.selection) + ) { + pending.current = undefined + return + } + current.instance.setCodeScrollLeft(saved.scrollLeft) + attempts.current++ + if ( + !saved.selection || + restorePierreNativeSelection( + current.host, + latest.current.fileDiff, + saved.selection, + current.instance, + editorRef.current + ) + ) { + // Editor initialization may replace the selected nodes on its next frame. + schedule() + } else { + const { range, side } = saved.selection + scrollPierreDiffToLine({ + host: current.host, + container: current.host.closest('.scrollbar-editor'), + side, + lineNumber: range.startLineNumber, + linePosition: current.instance.getLinePosition?.(range.startLineNumber, side), + hunkIndex: 0, + hunkCount: 0 + }) + schedule() + } + }) + }, [editorRef]) + useLayoutEffect(() => schedule(), [activeGroupId, schedule]) + useLayoutEffect(() => { + const container = containerRef.current + const cancel = () => { + pending.current = undefined + } + const capture = (requireOwnership = false) => { + const current = view.current + if (!key || !current) { + return + } + const selection = readPierreNativeSelection( + current.host, + latest.current.fileDiff, + latest.current.editable + ) + if (requireOwnership && !selection && !container?.contains(document.activeElement)) { + return + } + lastSnapshot.current = { + scrollLeft: current.instance.getCodeScrollLeft(), + selection + } + rememberPierreNativeView(key, lastSnapshot.current) + } + // Capture before a tab/header click clears the native selection and disposes Pierre. + const captureBeforePointerDown = (event: PointerEvent) => { + if (container && !event.composedPath().includes(container)) { + capture(true) + } else { + lastSnapshot.current = undefined + } + } + const captureBeforeKeyDown = (event: KeyboardEvent) => { + if ( + container && + event.composedPath().includes(container) && + (event.getModifierState('Control') || event.getModifierState('Meta')) + ) { + capture(true) + } + } + const captureOnBlur = () => capture(true) + document.addEventListener('pointerdown', captureBeforePointerDown, true) + document.addEventListener('keydown', captureBeforeKeyDown, true) + window.addEventListener('blur', captureOnBlur) + document.addEventListener('pointerdown', cancel, true) + document.addEventListener('wheel', cancel, true) + document.addEventListener('keydown', cancel, true) + return () => { + if (frame.current !== null) { + cancelAnimationFrame(frame.current) + } + frame.current = null + document.removeEventListener('pointerdown', cancel, true) + document.removeEventListener('wheel', cancel, true) + document.removeEventListener('keydown', cancel, true) + document.removeEventListener('pointerdown', captureBeforePointerDown, true) + document.removeEventListener('keydown', captureBeforeKeyDown, true) + window.removeEventListener('blur', captureOnBlur) + if (!lastSnapshot.current) { + capture() + } + } + }, [key, containerRef]) + return useCallback( + (host: HTMLElement, phase: PostRenderPhase, instance: PierreDiffInstance) => { + if (phase !== 'unmount') { + view.current = { host, instance } + schedule() + } + }, + [schedule] + ) +} diff --git a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-search-view.ts b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-search-view.ts index cad3e2992ed..ec23cc22d71 100644 --- a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-search-view.ts +++ b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-search-view.ts @@ -25,6 +25,7 @@ export function usePierreDiffSearchView({ const owner = useRef({}) const frame = useRef(null) const pending = useRef(false) + const nativeRange = useRef<{ start: Range; end: Range } | null>(null) const schedule = useCallback(() => { if (frame.current !== null) { return @@ -54,6 +55,10 @@ export function usePierreDiffSearchView({ }) } const ranges = getPierreSearchRanges(host, fileDiff, side, matches, active) + nativeRange.current = + ranges.activeStart && ranges.activeEnd + ? { start: ranges.activeStart, end: ranges.activeEnd } + : null paintPierreSearchHighlights(owner.current, ranges) if (pending.current && ranges.active.length) { const range = ranges.active[0] @@ -78,10 +83,11 @@ export function usePierreDiffSearchView({ cancelAnimationFrame(frame.current) } frame.current = null + nativeRange.current = null paintPierreSearchHighlights(token) } }, [schedule, active]) - return useCallback( + const onPostRender = useCallback( (host: HTMLElement, phase: PostRenderPhase, instance: PierreDiffInstance) => { if (phase === 'unmount') { viewRef.current = null @@ -93,4 +99,22 @@ export function usePierreDiffSearchView({ }, [schedule] ) + const selectActive = useCallback(() => { + const range = nativeRange.current + if (!range?.start.startContainer.isConnected || !range.end.endContainer.isConnected) { + return + } + const root = range.start.startContainer.getRootNode() as ShadowRoot & { + getSelection?: () => Selection | null + } + root + .getSelection?.() + ?.setBaseAndExtent( + range.start.startContainer, + range.start.startOffset, + range.end.endContainer, + range.end.endOffset + ) + }, []) + return { onPostRender, selectActive } } diff --git a/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts b/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts index cb11acf25f3..8475301be5b 100644 --- a/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts +++ b/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts @@ -88,10 +88,14 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { let editorCount = 0 while (performance.now() - startedAt < 30_000) { await new Promise((resolve) => window.setTimeout(resolve, 50)) - editorCount = document.querySelectorAll('.monaco-diff-editor').length + editorCount = Array.from(document.querySelectorAll('diffs-container')).filter((host) => + host.shadowRoot?.querySelector('[data-content] [data-line]') + ).length if (editorCount > 0) { await new Promise((resolve) => window.setTimeout(resolve, 1_500)) - editorCount = document.querySelectorAll('.monaco-diff-editor').length + editorCount = Array.from(document.querySelectorAll('diffs-container')).filter( + (host) => host.shadowRoot?.querySelector('[data-content] [data-line]') + ).length break } } @@ -148,7 +152,9 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { maxLagMs, p95LagMs: sorted.length ? sorted[Math.floor(sorted.length * 0.95)] : 0, sampleCount: samples.length, - editorCount: document.querySelectorAll('.monaco-diff-editor').length, + editorCount: Array.from(document.querySelectorAll('diffs-container')).filter((host) => + host.shadowRoot?.querySelector('[data-content] [data-line]') + ).length, loadingRowCount: Array.from( document.querySelectorAll('[data-combined-diff-section-row]') ).filter((row) => row.textContent?.includes('Loading diff')).length, @@ -175,7 +181,7 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { test.setTimeout(240_000) await waitForSessionReady(orcaPage) // Why: few but very large sections — the reported freeze is a *large* diff view, - // where every remount re-runs Monaco's diff over thousands of changed lines. + // where every remount recomputes thousands of changed lines. const fixture = createIsolatedManyFileStagedDiffRepo(8, 15_000) try { @@ -196,10 +202,14 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { let editorCount = 0 while (performance.now() - startedAt < 30_000) { await new Promise((resolve) => window.setTimeout(resolve, 50)) - editorCount = document.querySelectorAll('.monaco-diff-editor').length + editorCount = Array.from(document.querySelectorAll('diffs-container')).filter((host) => + host.shadowRoot?.querySelector('[data-content] [data-line]') + ).length if (editorCount > 0) { await new Promise((resolve) => window.setTimeout(resolve, 1_500)) - editorCount = document.querySelectorAll('.monaco-diff-editor').length + editorCount = Array.from(document.querySelectorAll('diffs-container')).filter( + (host) => host.shadowRoot?.querySelector('[data-content] [data-line]') + ).length break } } @@ -235,7 +245,7 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { } } - // Why: opening 8 huge Monaco diffs is itself expensive. Wait for the main thread to go + // Why: opening 8 huge diffs is itself expensive. Wait for the main thread to go // quiet first, so the burst window reports invalidation cost and not open cost. const stopSettle = startLagMeter() const settleStartedAt = performance.now() @@ -250,7 +260,7 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { const settle = { ...stopSettle(), settleWindows } // Why: settling still leaves occasional multi-hundred-ms stalls from the 8 mounted - // 15k-line Monaco editors. Measure an identical idle window so the burst is judged + // 15k-line diffs. Measure an identical idle window so the burst is judged // against this machine's floor rather than a fixed number. const stopBaseline = startLagMeter() await new Promise((resolve) => window.setTimeout(resolve, burstDurationMs)) @@ -284,7 +294,9 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { baseline, burst, expectedSampleCount: Math.floor(burstDurationMs / intervalMs), - editorCount: document.querySelectorAll('.monaco-diff-editor').length, + editorCount: Array.from(document.querySelectorAll('diffs-container')).filter((host) => + host.shadowRoot?.querySelector('[data-content] [data-line]') + ).length, sectionRowCount: rows.length, stuckLoadingRowCount: rows.filter((row) => row.textContent?.includes('Loading diff')) .length diff --git a/tests/e2e/diff-native-state-restoration.spec.ts b/tests/e2e/diff-native-state-restoration.spec.ts new file mode 100644 index 00000000000..ffec833ce74 --- /dev/null +++ b/tests/e2e/diff-native-state-restoration.spec.ts @@ -0,0 +1,118 @@ +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { addAndActivateRepo } from './helpers/isolated-repo-activation' +import { createIsolatedLargeDiffRepo } from './large-diff-repro-fixtures' + +test.use({ seedTestRepo: false }) +for (const mode of ['original-file', 'readonly-combined']) { + test(`restores selected text and horizontal scroll in ${mode}`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const original = `export const oldName = '${'a'.repeat(150)}SELECT_ME${'b'.repeat(160)}'\n` + const fixture = createIsolatedLargeDiffRepo(original) + registerPostElectronShutdownCleanup(async () => + rmSync(fixture.repoPath, { recursive: true, force: true }) + ) + writeFileSync(fixture.absolutePath, original.replace('oldName', 'newName')) + writeFileSync(path.join(fixture.repoPath, 'other.txt'), 'other file\n') + await waitForSessionReady(orcaPage) + await addAndActivateRepo(orcaPage, fixture.repoPath) + await orcaPage.evaluate(() => + window + .__store!.getState() + .updateSettings({ diffDefaultView: 'side-by-side', diffWordWrap: false }) + ) + await orcaPage.getByRole('button', { name: /^Source Control/ }).click() + const entry = orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: path.basename(fixture.relativePath) }) + if (mode === 'readonly-combined') { + await orcaPage.getByRole('button', { name: 'Stage All', exact: true }).click() + await expect( + orcaPage.locator('[data-testid="source-control-entry"][data-source-control-area="staged"]') + ).toHaveCount(2) + await orcaPage.getByRole('button', { name: 'View all', exact: true }).first().click() + } else { + await entry.click() + await orcaPage + .locator('[data-tab-id][data-active="true"]') + .filter({ hasText: path.basename(fixture.relativePath) }) + .dblclick() + } + const host = orcaPage + .locator('diffs-container') + .filter({ has: orcaPage.locator('[data-line]', { hasText: 'SELECT_ME' }) }) + .first() + const side = mode === 'original-file' ? 'deletions' : 'additions' + const code = host.locator(`[data-code][data-${side}]`) + await expect(code).toBeVisible({ timeout: 20_000 }) + await code.evaluate((node) => { + node.scrollLeft = 1000 + }) + const word = await code + .locator('[data-line]') + .first() + .evaluate((row) => { + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT) + let node: Node | null + while ((node = walker.nextNode())) { + const start = node.textContent?.indexOf('SELECT_ME') ?? -1 + if (start < 0) { + continue + } + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + 9) + const rect = range.getBoundingClientRect() + return { x: rect.x, right: rect.right, y: rect.y + rect.height / 2 } + } + throw new Error('Missing target text') + }) + await orcaPage.mouse.move(word.right, word.y) + await orcaPage.mouse.down() + await orcaPage.mouse.move(word.x, word.y, { steps: 6 }) + await orcaPage.mouse.up() + const selectedText = () => + host.evaluate((host) => + (host.shadowRoot as ShadowRoot & { getSelection(): Selection }).getSelection().toString() + ) + await expect.poll(selectedText).toBe('SELECT_ME') + const scrollLeft = await code.evaluate((node) => node.scrollLeft) + if (mode === 'original-file') { + await orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: 'other.txt' }) + .click() + await expect(orcaPage.locator('diffs-container [data-content]')).toContainText('other file') + await entry.click() + } else { + const header = orcaPage + .locator('[data-combined-diff-section-row]') + .filter({ hasText: path.basename(fixture.relativePath) }) + .locator('.sticky') + .first() + await header.click() + await expect(host).toHaveCount(0) + await header.click() + } + await expect.poll(() => code.evaluate((node) => node.scrollLeft)).toBeCloseTo(scrollLeft, 0) + await expect.poll(selectedText).toBe('SELECT_ME') + await orcaPage.waitForTimeout(300) + await expect.poll(selectedText).toBe('SELECT_ME') + if (mode === 'original-file') { + await expect(host.locator('pre')).toHaveAttribute('data-deleted-text-selection', '') + expect( + await code + .locator('[data-line] span') + .first() + .evaluate((node) => getComputedStyle(node, '::selection').backgroundColor) + ).not.toBe('rgba(0, 0, 0, 0)') + } + await orcaPage.screenshot({ + path: testInfo.outputPath(`${mode}-restored-native-selection.png`) + }) + }) +}