mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
fix(diff): retain native selections and restore visible selection colors
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+3
-3
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
+4
-2
@@ -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`)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user