564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
import fs, { Dirent, existsSync } from 'node:fs';
|
||
import { readdir } from 'node:fs/promises';
|
||
import { basename, dirname, join, resolve } from 'path';
|
||
import pc from 'picocolors';
|
||
import { termSize, truncateEnd } from './ui';
|
||
|
||
const SEARCH_DEPTH = 3;
|
||
|
||
// Directories to never recurse into. We still detect node_modules
|
||
// *presence* (marks a workspace root) via a stat, just don't list inside it.
|
||
const SKIP_RECURSE = new Set([
|
||
// VCS
|
||
'.git',
|
||
'.svn',
|
||
'.hg',
|
||
// Package managers (contents, not the marker)
|
||
'node_modules',
|
||
'.yarn',
|
||
'.pnpm',
|
||
// Build / framework caches
|
||
'.cache',
|
||
'.next',
|
||
'.nuxt',
|
||
'.output',
|
||
'.turbo',
|
||
'.angular',
|
||
// Editor / IDE
|
||
'.vscode',
|
||
'.idea',
|
||
// OS
|
||
'.Trash',
|
||
]);
|
||
|
||
// ── Keypress parsing ────────────────────────────────────────────────
|
||
|
||
interface KeyPress {
|
||
name: string; // e.g. 'a', 'enter', 'backspace', 'up', 'tab'
|
||
ctrl: boolean;
|
||
meta: boolean; // Alt / Option
|
||
char: string; // printable character, or '' for special keys
|
||
}
|
||
|
||
function parseKeypress(data: Buffer): KeyPress {
|
||
const raw = data.toString();
|
||
|
||
// Meta (Alt/Option) + key: ESC followed by a single character
|
||
if (raw.length === 2 && raw[0] === '\x1b' && raw[1] !== '[') {
|
||
const ch = raw[1];
|
||
if (ch === '\x7f')
|
||
return { name: 'backspace', ctrl: false, meta: true, char: '' };
|
||
return { name: ch, ctrl: false, meta: true, char: ch };
|
||
}
|
||
|
||
// CSI sequences: ESC [ <letter>
|
||
if (raw.startsWith('\x1b[')) {
|
||
const suffix = raw.slice(2);
|
||
const csiMap: Record<string, string> = {
|
||
A: 'up',
|
||
B: 'down',
|
||
C: 'right',
|
||
D: 'left',
|
||
H: 'home',
|
||
F: 'end',
|
||
};
|
||
const name = csiMap[suffix] ?? `unknown(${suffix})`;
|
||
return { name, ctrl: false, meta: false, char: '' };
|
||
}
|
||
|
||
// Single-byte control characters
|
||
if (raw.length === 1 && raw.charCodeAt(0) < 0x20) {
|
||
const code = raw.charCodeAt(0);
|
||
const ctrlMap: Record<number, string> = {
|
||
0x03: 'c', // Ctrl+C
|
||
0x08: 'backspace', // Ctrl+H (legacy backspace)
|
||
0x09: 'tab',
|
||
0x0d: 'enter',
|
||
0x15: 'u', // Ctrl+U
|
||
0x17: 'w', // Ctrl+W
|
||
};
|
||
const name = ctrlMap[code] ?? String.fromCharCode(code + 0x60);
|
||
const isCtrl = code !== 0x09 && code !== 0x0d; // tab/enter aren't "ctrl" keys
|
||
return { name, ctrl: isCtrl, meta: false, char: '' };
|
||
}
|
||
|
||
// DEL (0x7f) — Backspace on most terminals
|
||
if (raw === '\x7f') {
|
||
return { name: 'backspace', ctrl: false, meta: false, char: '' };
|
||
}
|
||
|
||
// Printable characters (including pasted multi-char text)
|
||
return { name: raw, ctrl: false, meta: false, char: raw };
|
||
}
|
||
|
||
// ── Input parsing ────────────────────────────────────────────────────
|
||
|
||
interface ParsedInput {
|
||
resolvedSearchDir: string;
|
||
filter: string;
|
||
prefix: string;
|
||
selfDisplay: string | null;
|
||
}
|
||
|
||
function parseInput(input: string): ParsedInput {
|
||
let searchDir: string;
|
||
let filter: string;
|
||
let prefix: string;
|
||
|
||
const expanded = input.startsWith('~/')
|
||
? (process.env.HOME || '') + input.slice(1)
|
||
: input;
|
||
|
||
if (expanded === '' || expanded.endsWith('/')) {
|
||
searchDir = expanded || '.';
|
||
filter = '';
|
||
prefix = input;
|
||
} else {
|
||
const d = dirname(expanded);
|
||
searchDir = d;
|
||
filter = basename(expanded).toLowerCase();
|
||
const inputDir = dirname(input);
|
||
prefix = inputDir === '.' ? '' : inputDir + '/';
|
||
}
|
||
|
||
const resolvedSearchDir = resolve(searchDir);
|
||
|
||
// Self-directory: show the search dir itself when there's no child filter
|
||
// (empty input, trailing slash, or the literal "." / ".." inputs).
|
||
let selfDisplay: string | null = null;
|
||
const showSelf = filter === '' || input === '.' || input === '..';
|
||
if (showSelf && existsSync(join(resolvedSearchDir, 'node_modules'))) {
|
||
selfDisplay =
|
||
input === ''
|
||
? '.'
|
||
: input.endsWith('/')
|
||
? input.replace(/\/+$/, '')
|
||
: input;
|
||
}
|
||
|
||
return { resolvedSearchDir, filter, prefix, selfDisplay };
|
||
}
|
||
|
||
// ── Parallel directory walker ────────────────────────────────────────
|
||
|
||
/**
|
||
* Walk directories in parallel, invoking `onFound` for each workspace root
|
||
* (directory containing `node_modules`). All entries within a directory
|
||
* level are explored concurrently via Promise.all, which dramatically
|
||
* reduces wall-clock time compared to a sequential async generator.
|
||
*/
|
||
async function walkWorkspaceDirs(
|
||
dir: string,
|
||
maxDepth: number,
|
||
relPrefix: string,
|
||
onFound: (relPath: string) => void,
|
||
isCancelled: () => boolean
|
||
): Promise<void> {
|
||
if (maxDepth < 0 || isCancelled()) return;
|
||
|
||
let entries: Dirent[];
|
||
try {
|
||
entries = await readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
if (isCancelled()) return;
|
||
|
||
await Promise.all(
|
||
entries.map(async (e) => {
|
||
if (isCancelled()) return;
|
||
if (!e.isDirectory() || SKIP_RECURSE.has(e.name)) return;
|
||
|
||
const fullPath = join(dir, e.name);
|
||
const relPath = relPrefix ? relPrefix + '/' + e.name : e.name;
|
||
|
||
// Check for node_modules with a cheap stat rather than readdir
|
||
try {
|
||
await fs.promises.access(join(fullPath, 'node_modules'));
|
||
if (!isCancelled()) onFound(relPath);
|
||
} catch {
|
||
// Not a workspace root — keep looking deeper
|
||
}
|
||
|
||
if (maxDepth > 0 && !isCancelled()) {
|
||
await walkWorkspaceDirs(
|
||
fullPath,
|
||
maxDepth - 1,
|
||
relPath,
|
||
onFound,
|
||
isCancelled
|
||
);
|
||
}
|
||
})
|
||
);
|
||
}
|
||
|
||
// ── Interactive picker ───────────────────────────────────────────────
|
||
|
||
export class DirectoryPicker {
|
||
private input: string = '';
|
||
private cursorPos: number = 0;
|
||
private selectedIndex: number = -1; // -1 = on input line
|
||
private suggestions: string[] = [];
|
||
private lastRenderLines = 0;
|
||
private resolvePromise!: (value: string) => void;
|
||
|
||
// ── Scan cache ─────────────────────────────────────────────────────
|
||
// Key: resolved search directory path
|
||
// Value: all workspace-root relative paths found under that directory
|
||
private scanCache = new Map<string, string[]>();
|
||
private completedScans = new Set<string>();
|
||
private activeScanKey: string | null = null;
|
||
private cancelActiveScan: (() => void) | null = null;
|
||
|
||
/** Visible suggestion count adapts to the terminal height */
|
||
private get maxSuggestions(): number {
|
||
const { rows } = termSize();
|
||
return Math.max(3, Math.min(Math.floor(rows * 0.3), rows - 4));
|
||
}
|
||
|
||
run(): Promise<string> {
|
||
return new Promise<string>((res) => {
|
||
this.resolvePromise = res;
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
process.stdin.on('data', this.onKeypress);
|
||
this.updateSuggestions();
|
||
this.render();
|
||
});
|
||
}
|
||
|
||
private onKeypress = (data: Buffer) => {
|
||
const key = parseKeypress(data);
|
||
|
||
// ── Action keys ───────────────────────────────────────────────
|
||
if (key.ctrl && key.name === 'c') {
|
||
this.cleanup();
|
||
process.exit(0);
|
||
}
|
||
|
||
if (key.name === 'enter') {
|
||
const value =
|
||
this.selectedIndex >= 0
|
||
? this.suggestions[this.selectedIndex]
|
||
: this.input;
|
||
this.cleanup();
|
||
this.resolvePromise(value);
|
||
return;
|
||
}
|
||
|
||
if (key.name === 'tab') {
|
||
if (this.suggestions.length > 0) {
|
||
const idx = Math.max(this.selectedIndex, 0);
|
||
this.input = this.suggestions[idx];
|
||
this.cursorPos = this.input.length;
|
||
this.selectedIndex = -1;
|
||
this.updateSuggestions();
|
||
}
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
// ── Navigation ────────────────────────────────────────────────
|
||
if (key.name === 'up') {
|
||
this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : -1;
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
if (key.name === 'down') {
|
||
if (this.selectedIndex < this.suggestions.length - 1) {
|
||
this.selectedIndex++;
|
||
}
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
if (key.name === 'right') {
|
||
if (this.cursorPos < this.input.length) this.cursorPos++;
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
if (key.name === 'left') {
|
||
if (this.cursorPos > 0) this.cursorPos--;
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
// ── Deletion ──────────────────────────────────────────────────
|
||
if (key.ctrl && key.name === 'u') {
|
||
this.input = '';
|
||
this.cursorPos = 0;
|
||
this.selectedIndex = -1;
|
||
this.updateSuggestions();
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
// Ctrl+W or Meta+Backspace — delete word backward
|
||
if (
|
||
(key.ctrl && key.name === 'w') ||
|
||
(key.meta && key.name === 'backspace')
|
||
) {
|
||
this.deleteWordBackward();
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
if (key.name === 'backspace') {
|
||
if (this.cursorPos > 0) {
|
||
this.input =
|
||
this.input.slice(0, this.cursorPos - 1) +
|
||
this.input.slice(this.cursorPos);
|
||
this.cursorPos--;
|
||
this.selectedIndex = -1;
|
||
this.updateSuggestions();
|
||
}
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
// ── Printable text ────────────────────────────────────────────
|
||
if (key.char) {
|
||
this.input =
|
||
this.input.slice(0, this.cursorPos) +
|
||
key.char +
|
||
this.input.slice(this.cursorPos);
|
||
this.cursorPos += key.char.length;
|
||
this.selectedIndex = -1;
|
||
this.updateSuggestions();
|
||
this.render();
|
||
}
|
||
};
|
||
|
||
private deleteWordBackward() {
|
||
if (this.cursorPos <= 0) return;
|
||
const before = this.input.slice(0, this.cursorPos);
|
||
// Delete trailing slashes, then back to the previous slash or start
|
||
const trimmed = before.replace(/\/+$/, '');
|
||
const lastSlash = trimmed.lastIndexOf('/');
|
||
const newEnd = lastSlash === -1 ? 0 : lastSlash + 1;
|
||
this.input = this.input.slice(0, newEnd) + this.input.slice(this.cursorPos);
|
||
this.cursorPos = newEnd;
|
||
this.selectedIndex = -1;
|
||
this.updateSuggestions();
|
||
}
|
||
|
||
// ── Suggestion management ──────────────────────────────────────────
|
||
|
||
/**
|
||
* Recompute `this.suggestions` from the cache using the current input.
|
||
* Pure synchronous filter — no I/O.
|
||
*/
|
||
private refreshSuggestions() {
|
||
const parsed = parseInput(this.input);
|
||
|
||
this.suggestions = parsed.selfDisplay ? [parsed.selfDisplay] : [];
|
||
|
||
const cached = this.scanCache.get(parsed.resolvedSearchDir);
|
||
if (!cached) return;
|
||
|
||
for (const relPath of cached) {
|
||
if (parsed.filter) {
|
||
const firstSegment = relPath.split('/')[0].toLowerCase();
|
||
if (!firstSegment.startsWith(parsed.filter)) continue;
|
||
}
|
||
|
||
const displayPath = parsed.prefix + relPath;
|
||
|
||
// Insert in sorted position
|
||
const insertIdx = this.suggestions.findIndex((s) => s > displayPath);
|
||
if (insertIdx === -1) {
|
||
this.suggestions.push(displayPath);
|
||
} else {
|
||
this.suggestions.splice(insertIdx, 0, displayPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Called on every input change. Refreshes suggestions from cache instantly,
|
||
* then starts an async scan if the search directory hasn't been fully scanned.
|
||
*/
|
||
private updateSuggestions() {
|
||
const parsed = parseInput(this.input);
|
||
const cacheKey = parsed.resolvedSearchDir;
|
||
|
||
// Always refresh from whatever's in the cache (instant)
|
||
this.refreshSuggestions();
|
||
|
||
if (!existsSync(cacheKey)) return;
|
||
|
||
// If this directory is fully scanned, we're done — cache has everything
|
||
if (this.completedScans.has(cacheKey)) return;
|
||
|
||
// If there's already an active scan for this same directory, let it run
|
||
if (this.activeScanKey === cacheKey) return;
|
||
|
||
// Different directory (or first time) — cancel any old scan, start new
|
||
this.cancelActiveScan?.();
|
||
this.activeScanKey = cacheKey;
|
||
|
||
if (!this.scanCache.has(cacheKey)) {
|
||
this.scanCache.set(cacheKey, []);
|
||
}
|
||
|
||
this.startScan(cacheKey);
|
||
}
|
||
|
||
private async startScan(cacheKey: string) {
|
||
let cancelled = false;
|
||
this.cancelActiveScan = () => {
|
||
cancelled = true;
|
||
};
|
||
|
||
const cache = this.scanCache.get(cacheKey)!;
|
||
|
||
// Schedule suggestion refresh + render as a macrotask so that
|
||
// keypress events (also macrotasks) get interleaved rather than
|
||
// starved behind a flood of Promise.all microtask continuations.
|
||
let renderPending = false;
|
||
const scheduleRender = () => {
|
||
if (renderPending) return;
|
||
renderPending = true;
|
||
setImmediate(() => {
|
||
renderPending = false;
|
||
if (!cancelled) {
|
||
this.refreshSuggestions();
|
||
this.render();
|
||
}
|
||
});
|
||
};
|
||
|
||
await walkWorkspaceDirs(
|
||
cacheKey,
|
||
SEARCH_DEPTH,
|
||
'',
|
||
(relPath) => {
|
||
cache.push(relPath);
|
||
scheduleRender();
|
||
},
|
||
() => cancelled
|
||
);
|
||
|
||
// Final render to pick up any results from the last batch
|
||
if (!cancelled) {
|
||
this.refreshSuggestions();
|
||
this.render();
|
||
this.completedScans.add(cacheKey);
|
||
this.activeScanKey = null;
|
||
this.cancelActiveScan = null;
|
||
}
|
||
}
|
||
|
||
// ── Rendering ──────────────────────────────────────────────────────
|
||
|
||
private render() {
|
||
const CLR = '\x1b[K'; // clear to end of line (terminal control, not a color)
|
||
|
||
// On subsequent renders the cursor is on the input line (from prior
|
||
// positioning). Just return the cursor to column 0 so we overwrite in
|
||
// place. On the first render lastRenderLines is 0, so this is a no-op.
|
||
if (this.lastRenderLines > 0) {
|
||
process.stdout.write('\r');
|
||
}
|
||
|
||
const { cols } = termSize();
|
||
|
||
// ── Input line ──────────────────────────────────────────────────
|
||
const promptLabel = ` ${pc.cyan('>')} Repo path: `;
|
||
const promptVisualLen = ' > Repo path: '.length; // without ANSI
|
||
const maxInputDisplay = cols - promptVisualLen - 1;
|
||
const displayInput =
|
||
this.input.length > maxInputDisplay
|
||
? '…' + this.input.slice(-(maxInputDisplay - 1))
|
||
: this.input;
|
||
process.stdout.write(`${promptLabel}${displayInput}${CLR}\n`);
|
||
|
||
// Blank separator between input and suggestions
|
||
process.stdout.write(`${CLR}\n`);
|
||
|
||
// ── Suggestion lines ────────────────────────────────────────────
|
||
const visible = this.suggestions.slice(0, this.maxSuggestions);
|
||
const shownSuggestions = visible.length;
|
||
const pointer = ' › ';
|
||
const noPointer = ' ';
|
||
const indent = ' ';
|
||
const maxSuggestionLen = cols - indent.length - pointer.length - 1;
|
||
|
||
for (let i = 0; i < shownSuggestions; i++) {
|
||
const text = truncateEnd(visible[i], maxSuggestionLen);
|
||
if (i === this.selectedIndex) {
|
||
process.stdout.write(
|
||
`${indent}${pc.cyan(pointer)}${pc.inverse(` ${text} `)}${CLR}\n`
|
||
);
|
||
} else {
|
||
process.stdout.write(
|
||
`${indent}${pc.dim(`${noPointer}${text}`)}${CLR}\n`
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Hint line ───────────────────────────────────────────────────
|
||
process.stdout.write(
|
||
`${pc.dim(' ↑↓ navigate tab complete enter select')}${CLR}\n`
|
||
);
|
||
|
||
// ── Clear leftover lines from previous render ───────────────────
|
||
const totalLines = 1 + 1 + shownSuggestions + 1; // input + blank + suggestions + hint
|
||
for (let i = totalLines; i < this.lastRenderLines; i++) {
|
||
process.stdout.write(`${CLR}\n`);
|
||
}
|
||
|
||
// Track how many physical lines we wrote (including clears)
|
||
const linesWritten = Math.max(totalLines, this.lastRenderLines);
|
||
this.lastRenderLines = totalLines;
|
||
|
||
// Every line we wrote ended with \n, so the cursor is now
|
||
// `linesWritten` rows below the input line. Move back up.
|
||
if (linesWritten > 0) {
|
||
process.stdout.write(`\x1b[${linesWritten}A`);
|
||
}
|
||
|
||
// Position cursor within the input (account for truncation)
|
||
const inputOffset =
|
||
this.input.length > maxInputDisplay
|
||
? this.cursorPos - (this.input.length - maxInputDisplay) + 1
|
||
: this.cursorPos;
|
||
const cursorCol = Math.max(
|
||
promptVisualLen + 1,
|
||
promptVisualLen + inputOffset + 1
|
||
);
|
||
process.stdout.write(`\x1b[${Math.min(cursorCol, cols)}G`);
|
||
}
|
||
|
||
private cleanup() {
|
||
// Cancel any in-flight scan
|
||
this.cancelActiveScan?.();
|
||
|
||
process.stdin.setRawMode(false);
|
||
process.stdin.pause();
|
||
process.stdin.removeListener('data', this.onKeypress);
|
||
|
||
// Cursor is on the input line (from render positioning).
|
||
// Go to column 0, then overwrite with the final value.
|
||
const finalValue =
|
||
this.selectedIndex >= 0
|
||
? this.suggestions[this.selectedIndex]
|
||
: this.input;
|
||
process.stdout.write(`\r Repo path: ${finalValue}\x1b[K\n`);
|
||
|
||
// Clear all the suggestion / hint lines below
|
||
for (let i = 1; i < this.lastRenderLines; i++) {
|
||
process.stdout.write('\x1b[K\n');
|
||
}
|
||
|
||
// Move back up so subsequent output starts right after the input line
|
||
if (this.lastRenderLines > 1) {
|
||
process.stdout.write(`\x1b[${this.lastRenderLines - 1}A`);
|
||
}
|
||
}
|
||
}
|