diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index cfd3e951d..a9e246e21 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -232,6 +232,7 @@ Source of truth: `BUILTIN_HANDLERS` in `dor-tools-builtin/src/file-viewer-format - **Must list the files under the resolved CWD as git does in a work tree** — tracked and untracked, less ignored and deleted. Outside one, a walk lists each work tree it reaches likewise and skips dot-entries, `node_modules`, and the macOS home `Library`. - **Must offer the highlighted file's `tool.openHandlers` answer in order**: what [Opening local files](#opening-local-files) selects, then later matching rules' `tool` and `preview`, then each built-in supporting it — each once, with what it runs and the rule or built-in that offers it. +- **Must preserve the query and selected file across terminal resizes.** Source of truth: `runFilePicker` in `dor/src/commands/open-picker.ts`; `listOpenHandlers` in `lib/src/host/tool-open.ts`. diff --git a/dor/src/commands/fuzzy.ts b/dor/src/commands/fuzzy.ts index da5c26242..fc38d6bbf 100644 --- a/dor/src/commands/fuzzy.ts +++ b/dor/src/commands/fuzzy.ts @@ -208,6 +208,11 @@ export class Ranker { this.schedule(); } + /** One bounded slice now, so a frame drawn right away shows matches. */ + seed(): void { + if (this.scanning) this.step(SLICE_MS); + } + /** Ranks everything listed so far, now: Enter opens what is best. */ flush(): void { this.step(Infinity); diff --git a/dor/src/commands/open-picker.ts b/dor/src/commands/open-picker.ts index 414052f7c..89880670a 100644 --- a/dor/src/commands/open-picker.ts +++ b/dor/src/commands/open-picker.ts @@ -68,6 +68,8 @@ export function runFilePicker(options: PickerOptions): Promise(); let listRows = 0; let redrawTimer: ReturnType | undefined; + let previousLines: string[] = []; + let previousSize = ''; const listing = new AbortController(); return new Promise((resolve) => { @@ -194,6 +196,9 @@ export function runFilePicker(options: PickerOptions): Promise { out += `${CSI}${index + 1};1H${line}${SGR.reset}${CSI}K`; }); - out += `${CSI}1;${3 + displayWidth(shown)}H${CSI}?25h${CSI}?2026l`; - terminal.write(out); + // Handler replies and ranking ticks often change nothing visible. Keep + // those frames silent, and leave unchanged rows alone on real updates. + // A resize invalidates the cache because the terminal may reflow rows. + // Erasing before writing keeps a character in the last column, where an + // erase after it would land on that character. + const size = `${columns}x${rows}`; + let out = ''; + lines.forEach((line, index) => { + if (size === previousSize && line === previousLines[index]) return; + out += `${CSI}${index + 1};1H${CSI}2K${line}${SGR.reset}`; + }); + previousLines = lines; + previousSize = size; + if (out) terminal.write(`${CSI}?2026h${CSI}?25l${out}${CSI}1;${3 + displayWidth(shown)}H${CSI}?25h${CSI}?2026l`); }; const renderPanel = (width: number): Line[] => { diff --git a/dor/test/open-picker.test.mjs b/dor/test/open-picker.test.mjs index 72674a258..08ce03900 100644 --- a/dor/test/open-picker.test.mjs +++ b/dor/test/open-picker.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runCli } from '../dist/cli.js'; import { fuzzyMatch, Ranker } from '../dist/commands/fuzzy.js'; -import { parseKeys } from '../dist/commands/open-picker.js'; +import { parseKeys, runFilePicker } from '../dist/commands/open-picker.js'; import { listFiles } from '../dist/commands/file-list.js'; import { execFileSync } from 'node:child_process'; import { setImmediate } from 'node:timers/promises'; @@ -75,14 +75,19 @@ test('parseKeys decodes keys, mouse, paste, and a lone escape', () => { function fakeTerminal({ columns = 120, rows = 20 } = {}) { let onInput; + let onResize; let output = ''; + let taken = 0; return { columns: () => columns, rows: () => rows, write: (text) => { output += text; }, - listen(input) { onInput = input; return () => { onInput = undefined; }; }, + listen(input, resize) { onInput = input; onResize = resize; return () => { onInput = undefined; onResize = undefined; }; }, send(chunk) { onInput(chunk); }, + resize(width, height) { columns = width; rows = height; onResize(); }, get output() { return output; }, + /** Output written since the previous call. */ + take() { const text = output.slice(taken); taken = output.length; return text; }, get listening() { return onInput !== undefined; }, }; } @@ -114,6 +119,73 @@ async function until(predicate, label) { assert.fail(`timed out waiting for ${label}`); } +/** The rows a frame repainted, in order. */ +const paintedRows = (text) => [...text.matchAll(/\x1b\[(\d+);1H/g)].map(match => Number(match[1])); + +test('picker redraws only changed rows, skips identical frames, and repaints after resize', async () => { + const terminal = fakeTerminal({ columns: 60, rows: 8 }); + const result = runFilePicker({ + terminal, + fixedTool: 'builtin:file', + async listFiles(onFiles) { onFiles(['a'.repeat(58), 'b.ts']); return { truncated: false }; }, + async handlers() { assert.fail('fixed handler needs no lookup'); }, + }); + try { + await until(() => terminal.output.includes('2/2'), 'completed listing'); + // The query/count row and the first file fill the terminal width, so an + // erase after either would delete its last character. + assert.doesNotMatch(terminal.take(), /\x1b\[K/, 'rows are erased only before they are written'); + terminal.send('\x1b[A'); // Already at the top. + terminal.resize(60, 8); + assert.equal(terminal.take(), '', 'unchanged frames emit no bytes or cursor toggles'); + + terminal.send('\x1b[B'); + const update = terminal.take(); + assert.deepEqual(paintedRows(update), [2, 3]); + assert.ok(update.endsWith('\x1b[1;3H\x1b[?25h\x1b[?2026l'), 'returns the cursor to the query'); + + terminal.send('b'); + const filtered = terminal.take(); + assert.match(filtered, /\x1b\[2;1H\x1b\[2K[^H]*b\x1b\[0m\x1b\[1m\.ts/, 'shorter replacement clears the old row first'); + assert.doesNotMatch(filtered, /0…\/2/, 'typing does not paint an empty list before ranking'); + terminal.resize(120, 10); + const wide = terminal.take(); + assert.deepEqual(paintedRows(wide), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.match(wide, /Opens with/); + terminal.resize(60, 6); + const narrow = terminal.take(); + assert.deepEqual(paintedRows(narrow), [1, 2, 3, 4, 5, 6]); + assert.match(narrow, /1;4H/, 'query cursor survives resize'); + terminal.send('\r'); + assert.deepEqual(await result, { file: 'b.ts' }); + } finally { + if (terminal.listening) terminal.send('\x03'); + await result; + } +}); + +test('handler replies leave unchanged file rows alone', async () => { + const terminal = fakeTerminal({ columns: 60 }); + let reply; + const result = runFilePicker({ + terminal, + async listFiles(onFiles) { onFiles(['a.ts', 'b.ts']); return { truncated: false }; }, + handlers: () => new Promise(resolve => { reply = resolve; }), + }); + try { + await until(() => reply !== undefined, 'handler request'); + terminal.take(); + reply({ handlers: HANDLERS, config: '/config', warnings: [], target: '/a.ts', directory: false }); + await until(() => terminal.output.includes('builtin:file'), 'handler reply'); + const update = terminal.take(); + assert.doesNotMatch(update, /a\.ts|b\.ts/); + assert.deepEqual(paintedRows(update), [19, 20]); + } finally { + terminal.send('\x03'); + await result; + } +}); + async function withTree(run) { const dir = await mkdtemp(join(tmpdir(), 'dor-picker-')); try {