Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
5 changes: 5 additions & 0 deletions dor/src/commands/fuzzy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 19 additions & 4 deletions dor/src/commands/open-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function runFilePicker(options: PickerOptions): Promise<PickerChoice | nu
let handlerRows = new Map<number, number>();
let listRows = 0;
let redrawTimer: ReturnType<typeof setTimeout> | undefined;
let previousLines: string[] = [];
let previousSize = '';
const listing = new AbortController();

return new Promise((resolve) => {
Expand Down Expand Up @@ -194,6 +196,9 @@ export function runFilePicker(options: PickerOptions): Promise<PickerChoice | nu
}
if (done) return;
}
// A query edit restarts ranking; without a slice now, this frame would
// paint an empty list until the deferred scan catches up.
ranker.seed();
render();
};

Expand Down Expand Up @@ -241,10 +246,20 @@ export function runFilePicker(options: PickerOptions): Promise<PickerChoice | nu
lines.push(dim(clip(hints().join(' '), columns)));
}

let out = `${CSI}?2026h${CSI}?25l`;
lines.forEach((line, index) => { 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[] => {
Expand Down
76 changes: 74 additions & 2 deletions dor/test/open-picker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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; },
};
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading