import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import type { AutocompleteItem, AutocompleteProvider, } from "@earendil-works/pi-tui"; import { resolveTilde, type WorkspaceRoot } from "./roots"; export const ADD_DIR_PREFIX = "/add-dir "; /** Per-level directory listing cache (hidden-file listings are not cached). */ const dirCache = new Map(); async function listDir( absDir: string, includeHidden: boolean, ): Promise { const cached = !includeHidden && dirCache.get(absDir); if (cached) return cached; const entries = await readdir(absDir, { withFileTypes: true }).catch( () => [], ); const items = entries .filter((e) => includeHidden || !e.name.startsWith(".")) .sort( (a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name), ) .map((e) => { const absPath = join(absDir, e.name); return { value: absPath, label: e.isDirectory() ? `${e.name}/` : e.name, description: absPath, }; }); if (!includeHidden) dirCache.set(absDir, items); return items; } /** * Given the text after "/add-dir ", resolve the directory to list and the * partial-name filter (e.g. "src" → list cwd, filter "src"; "~/" → list home). */ export function resolvePickerState( argText: string, cwd: string, ): { pickerDir: string; filter: string } { const path = argText === "~" ? homedir() : resolveTilde(argText || ".", cwd); if (argText === "" || argText.endsWith("/") || argText.endsWith("\\")) return { pickerDir: path, filter: "" }; return { pickerDir: dirname(path), filter: basename(path) }; } function replaceRange( lines: string[], cursorLine: number, cursorCol: number, start: number, text: string, ) { const line = lines[cursorLine] ?? ""; const newLines = [...lines]; newLines[cursorLine] = line.slice(0, start) + text + line.slice(cursorCol); return { lines: newLines, cursorLine, cursorCol: start + text.length }; } /** Directory browser for the /add-dir command. */ export function createAddDirAutocompleteProvider( current: AutocompleteProvider, cwd: string, ): AutocompleteProvider { const argBeforeCursor = ( lines: string[], cursorLine: number, cursorCol: number, ) => { const before = (lines[cursorLine] ?? "").slice(0, cursorCol); return before.startsWith(ADD_DIR_PREFIX) ? before.slice(ADD_DIR_PREFIX.length) : null; }; return { async getSuggestions(lines, cursorLine, cursorCol, options) { const argText = argBeforeCursor(lines, cursorLine, cursorCol); if (argText === null) { return current.getSuggestions(lines, cursorLine, cursorCol, options); } const { pickerDir, filter } = resolvePickerState(argText, cwd); const lc = filter.toLowerCase(); const entries = (await listDir(pickerDir, filter.startsWith("."))).filter( (i) => i.label.toLowerCase().includes(lc), ); const nav: AutocompleteItem[] = []; const parent = dirname(pickerDir); if (parent !== pickerDir) { nav.push({ value: `${parent}/`, label: "../", description: `Go up to ${parent}`, }); } const home = homedir(); if (pickerDir !== home) { nav.push({ value: `${home}/`, label: "~/", description: `Jump to home (${home})`, }); } const items = [...nav, ...entries]; return items.length ? { items, prefix: argText } : null; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { if (argBeforeCursor(lines, cursorLine, cursorCol) === null) { return current.applyCompletion( lines, cursorLine, cursorCol, item, prefix, ); } return replaceRange( lines, cursorLine, cursorCol, ADD_DIR_PREFIX.length, item.value, ); }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { if (argBeforeCursor(lines, cursorLine, cursorCol) !== null) return false; return ( current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true ); }, }; } /** "$$" expands to a workspace root path. */ export function createRootExpansionProvider( current: AutocompleteProvider, getRoots: () => readonly WorkspaceRoot[], ): AutocompleteProvider { return { async getSuggestions(lines, cursorLine, cursorCol, options) { const before = (lines[cursorLine] ?? "").slice(0, cursorCol); const match = before.match(/(?:^|[ \t])\$\$([^\s$]*)$/); if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options); const roots = getRoots(); if (roots.length === 0) return null; const query = (match[1] ?? "").toLowerCase(); const items = roots.map((root) => ({ value: root.path, label: root.alias ?? (basename(root.path) || root.path), description: root.path, })); const filtered = items.filter( (i) => i.label.toLowerCase().includes(query) || i.value.toLowerCase().includes(query), ); return { prefix: `$$${match[1] ?? ""}`, items: filtered.length ? filtered : items, }; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { // Only handle completions this provider produced ($$-prefixed); // everything else (command menu, file completion) belongs to the chain. if (!prefix.startsWith("$$")) { return current.applyCompletion( lines, cursorLine, cursorCol, item, prefix, ); } return replaceRange( lines, cursorLine, cursorCol, cursorCol - prefix.length, item.value, ); }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { return ( current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true ); }, }; }