fix(diff): retain native selections and restore visible selection colors

This commit is contained in:
Neil
2026-09-07 17:28:22 -07:00
parent ad6a51eca5
commit 070fcf381a
21 changed files with 730 additions and 31 deletions
+26
View File
@@ -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;
+4
View File
@@ -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.
+3 -3
View File
@@ -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)
+3
View File
@@ -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;
@@ -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') {
@@ -240,6 +240,7 @@ export function DiffSectionItem({
() =>
fileDiff ? (
<PierreDiffSurface
key={editStateKey}
fileDiff={fileDiff}
sideBySide={sideBySide}
settings={settings}
@@ -266,6 +266,7 @@ export default function DiffViewer({
) : fileDiff ? (
<PierreDiffProviders scrollContainerRef={scrollContainerRef}>
<PierreDiffSurface
key={modelKey}
fileDiff={fileDiff}
sideBySide={sideBySide}
settings={settings}
@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useLayoutEffect, useRef } from 'react'
import type React from 'react'
import { useAppStore } from '@/store'
import { joinPath } from '@/lib/path'
@@ -110,6 +110,8 @@ export function useCombinedDiffSectionSave({
[file, sectionsRef, setSectionHeights, setSections]
)
const saveRef = useRef(saveSection)
saveRef.current = saveSection
useLayoutEffect(() => {
saveRef.current = saveSection
}, [saveSection])
return saveRef
}
@@ -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
]
@@ -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 =
'<code data-code data-deletions><div data-line="1" data-line-index="0,0" data-line-type="change-deletion">old one\n</div><div data-line="2" data-line-index="1,1" data-line-type="change-deletion">old two\n</div></code>'
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()
})
@@ -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<ReturnType<typeof getPierreSelectionRange>>
side: DiffSearchSide
signature: string
backward: boolean
}
}
const cache = new Map<string, PierreNativeViewState>()
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<PierreNativeViewState['selection']>,
instance: PierreDiffInstance,
editor?: Pick<Editor, 'setDeletedTextSelectionActive'> | 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
}
@@ -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
@@ -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
}
}
}
}
@@ -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')
@@ -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', () => {
@@ -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) {
@@ -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<number, FrameRequestCallback>()
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()
})
@@ -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<HTMLElement | null>,
activeGroupId: string,
editorRef: React.RefObject<Pick<Editor, 'setDeletedTextSelectionActive'> | 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<number | null>(null)
const attempts = useRef(0)
const lastSnapshot = useRef<PierreNativeViewState | undefined>(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<HTMLElement>('[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]
)
}
@@ -25,6 +25,7 @@ export function usePierreDiffSearchView({
const owner = useRef({})
const frame = useRef<number | null>(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 }
}
@@ -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
@@ -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`)
})
})
}