mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): clear search highlights when the find bar closes (#10872)
Closing Ctrl+F left one match highlighted until the window was minimized and restored. xterm's DecorationService keys its SortedList on `decoration.marker.line`, but `SortedList.delete()` only records an index and defers compaction, while `Marker.dispose()` sets `line = -1` — mutating that same sort key. After the first disposal the array is no longer sorted, so the binary search inside `delete()` can miss a decoration that is present. It returns false, `onDecorationRemoved` never fires, and the decoration stays live and keeps painting. Repaints don't help; they faithfully re-paint a live decoration, which is why only a window cycle appeared to fix it. `clearDecorations()` disposes the active match before the match highlights, which is exactly the order that trips this. Patch `delete()` to retry once after compacting pending deletions, on the miss path only, so the common bulk delete keeps its O(log n) search and deferred batching. A 3000-trial randomized differential against upstream semantics shows no behavior change for well-ordered lists.
This commit is contained in:
File diff suppressed because one or more lines are too long
Generated
+24
-24
@@ -19,7 +19,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c
|
||||
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -46,7 +46,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -206,25 +206,25 @@ importers:
|
||||
version: 5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9224,39 +9224,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Regression for STA-2707: search highlights stayed painted after the find bar
|
||||
* closed, until the window was minimized and restored.
|
||||
*
|
||||
* Mechanism, in @xterm/xterm's SortedList (patched in
|
||||
* config/patches/@xterm__xterm@6.1.0-beta.287.patch):
|
||||
*
|
||||
* - DecorationService keys its SortedList on `decoration.marker.line`.
|
||||
* - `delete()` only records an index and defers the array compaction, while
|
||||
* `Marker.dispose()` sets `line = -1` — mutating that very sort key.
|
||||
* - So after the first disposal the array is no longer sorted, and the binary
|
||||
* search inside `delete()` can miss a decoration that IS present. `delete()`
|
||||
* returns false, `onDecorationRemoved` never fires, and the decoration keeps
|
||||
* reporting itself at its cells forever.
|
||||
*
|
||||
* `clearDecorations()` disposes the active (top-layer) match first and the
|
||||
* match highlights after, which is exactly the order that trips this — so
|
||||
* closing search left one match highlighted. Repaints don't help: the leaked
|
||||
* decoration is still live, so every repaint faithfully re-paints it.
|
||||
*
|
||||
* These tests drive the REAL Terminal + SearchAddon and assert through
|
||||
* `forEachDecorationAtCell` — the same lookup both renderers use to decide a
|
||||
* cell's background — so a regression fails here regardless of renderer.
|
||||
*/
|
||||
|
||||
type LeakProbeDecoration = { marker: { line: number } }
|
||||
|
||||
type DecorationServiceInternals = {
|
||||
decorations: Iterable<LeakProbeDecoration>
|
||||
forEachDecorationAtCell: (
|
||||
x: number,
|
||||
line: number,
|
||||
layer: string | undefined,
|
||||
callback: (decoration: LeakProbeDecoration) => void
|
||||
) => void
|
||||
}
|
||||
|
||||
const SEARCH_DECORATIONS = {
|
||||
matchBackground: '#5c4a00',
|
||||
matchBorder: '#5c4a00',
|
||||
matchOverviewRuler: '#ffcc00',
|
||||
activeMatchBackground: '#c4580e',
|
||||
activeMatchBorder: '#ffcf6b',
|
||||
activeMatchColorOverviewRuler: '#ff9900'
|
||||
} as const
|
||||
|
||||
const PROBE_ROWS = 30
|
||||
const PROBE_COLS = 80
|
||||
|
||||
function settle(ms = 20): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve))
|
||||
}
|
||||
|
||||
type SearchHarness = {
|
||||
terminal: Terminal
|
||||
search: SearchAddon
|
||||
/** Cells whose background a renderer would still paint from a decoration. */
|
||||
highlightedCellCount: () => number
|
||||
/** Closes the find bar exactly the way TerminalSearch does. */
|
||||
closeSearch: () => void
|
||||
}
|
||||
|
||||
function openTerminalWithSearch(): SearchHarness {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const search = new SearchAddon()
|
||||
terminal.loadAddon(search)
|
||||
|
||||
const decorationService = (
|
||||
terminal as never as { _core: { _decorationService: DecorationServiceInternals } }
|
||||
)._core._decorationService
|
||||
|
||||
return {
|
||||
terminal,
|
||||
search,
|
||||
highlightedCellCount: () => {
|
||||
let count = 0
|
||||
for (let line = 0; line < PROBE_ROWS; line++) {
|
||||
for (let x = 0; x < PROBE_COLS; x++) {
|
||||
decorationService.forEachDecorationAtCell(x, line, undefined, () => count++)
|
||||
}
|
||||
}
|
||||
return count
|
||||
},
|
||||
closeSearch: () => {
|
||||
search.clearDecorations()
|
||||
search.findNext('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content shapes that produce different match/marker layouts. The leak only
|
||||
* showed up for some of them, so the regression has to sweep rather than pin
|
||||
* one lucky case.
|
||||
*/
|
||||
const CONTENT_SHAPES: readonly (readonly [string, string])[] = [
|
||||
['matches on two lines', 'needle one\r\nneedle two\r\n'],
|
||||
['matches on three lines', 'needle one\r\nneedle two\r\nneedle three\r\n'],
|
||||
['matches on four lines', 'needle a\r\nneedle b\r\nneedle c\r\nneedle d\r\n'],
|
||||
['matches separated by gaps', 'needle a\r\nplain\r\nneedle b\r\nplain\r\nneedle c\r\n'],
|
||||
['two matches on one line', 'needle needle\r\nplain\r\n']
|
||||
]
|
||||
|
||||
describe('terminal search decoration cleanup (STA-2707)', () => {
|
||||
beforeEach(() => {
|
||||
// happy-dom has no canvas text metrics; xterm measures glyphs on open().
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it.each(CONTENT_SHAPES)(
|
||||
'leaves no highlighted cells after closing search (%s)',
|
||||
async (_name, content) => {
|
||||
// Sweeping the match-navigation count matters: which decoration is the
|
||||
// active one decides whether the stale sort key hides a later delete.
|
||||
for (const navigations of [0, 1, 2, 3]) {
|
||||
const harness = openTerminalWithSearch()
|
||||
await write(harness.terminal, content)
|
||||
|
||||
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
|
||||
await settle()
|
||||
for (let i = 0; i < navigations; i++) {
|
||||
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
|
||||
await settle(5)
|
||||
}
|
||||
await settle()
|
||||
expect(
|
||||
harness.highlightedCellCount(),
|
||||
`search should highlight matches while open (navigations=${navigations})`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
harness.closeSearch()
|
||||
await settle()
|
||||
|
||||
expect(
|
||||
harness.highlightedCellCount(),
|
||||
`closing search must leave no highlighted cells (navigations=${navigations})`
|
||||
).toBe(0)
|
||||
|
||||
document.body.replaceChildren()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves no highlighted cells after typing a query character by character', async () => {
|
||||
const harness = openTerminalWithSearch()
|
||||
await write(harness.terminal, 'needle one\r\nneedle two\r\nneedle three\r\n')
|
||||
|
||||
// The find bar re-searches on every keystroke, so each character disposes
|
||||
// the previous run's decorations — the same path that leaked.
|
||||
for (const query of ['n', 'ne', 'nee', 'need', 'needl', 'needle']) {
|
||||
harness.search.findNext(query, { decorations: SEARCH_DECORATIONS, incremental: true })
|
||||
await settle(10)
|
||||
}
|
||||
await settle()
|
||||
|
||||
harness.closeSearch()
|
||||
await settle()
|
||||
|
||||
expect(harness.highlightedCellCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not accumulate highlights across repeated open/close cycles', async () => {
|
||||
const harness = openTerminalWithSearch()
|
||||
await write(harness.terminal, 'needle one\r\nneedle two\r\nneedle three\r\n')
|
||||
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
|
||||
await settle(10)
|
||||
harness.closeSearch()
|
||||
await settle(10)
|
||||
}
|
||||
|
||||
expect(harness.highlightedCellCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps highlights painted while search is still open', async () => {
|
||||
const harness = openTerminalWithSearch()
|
||||
await write(harness.terminal, 'needle one\r\nneedle two\r\n')
|
||||
|
||||
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
|
||||
await settle()
|
||||
|
||||
expect(harness.highlightedCellCount()).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user