/** * Remote path completion for terminal autocomplete. * Lists files/directories on the remote (or local) machine * when the user types commands that expect path arguments. */ import type { CompletionContext } from "./completionEngine"; import type { FigArg } from "./figSpecLoader"; import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout"; /** Directory entry returned from IPC */ export interface DirEntry { name: string; type: "file" | "directory" | "symlink"; } interface ResolvePathOptions { preferRelativeCwd?: boolean; } /** Bridge interface for directory listing */ interface PathBridge { listAutocompleteRemoteDir?: ( sessionId: string, path: string, foldersOnly: boolean, filterPrefix?: string, limit?: number, ) => Promise<{ success: boolean; entries: DirEntry[] }>; listAutocompleteLocalDir?: ( path: string, foldersOnly: boolean, filterPrefix?: string, limit?: number, ) => Promise<{ success: boolean; entries: DirEntry[] }>; } function getBridge(): PathBridge | undefined { return (window as Window & { netcatty?: PathBridge }).netcatty; } // Cache directory listings for 5 seconds. Full-directory cache is shared between // popup suggestions and cascading sub-directory panels; filtered cache avoids // repeated round-trips while the user keeps typing within the same directory. const fullDirCache = new Map(); const filteredDirCache = new Map(); const inFlightRequests = new Map>(); const CACHE_TTL_MS = 5000; const MAX_CACHE_SIZE = 30; const MAX_FILTERED_CACHE_SIZE = 60; /** Commands that commonly accept file/directory path arguments. * Subcommand-first tools (docker, kubectl, go, cargo, make) are excluded — * their path arguments are better handled via Fig specs. */ const PATH_COMMANDS = new Set([ // Navigation & listing "cd", "pushd", "ls", "ll", "la", "dir", "tree", "exa", "eza", "lsd", // Viewing & editing "cat", "less", "more", "head", "tail", "bat", "tac", "nl", "tee", "vim", "vi", "nvim", "nano", "emacs", "code", "subl", "micro", "helix", "hx", "joe", "mcedit", // File operations "cp", "mv", "rm", "mkdir", "rmdir", "touch", "ln", "install", "shred", // Permissions & metadata "chmod", "chown", "chgrp", "stat", "file", "lsattr", "chattr", // Search & filter "find", "rg", "grep", "egrep", "fgrep", "ag", "fd", "locate", "wc", "sort", "uniq", "cut", "awk", "sed", // Archive & compression "tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd", "7z", "rar", "unrar", // Transfer & sync "scp", "rsync", "diff", "cmp", "patch", // Scripting & execution "source", ".", "bash", "sh", "zsh", "fish", "python", "python3", "node", "ruby", "perl", "php", "rustc", "gcc", "g++", "deno", "bun", "tsx", "ts-node", // Disk & filesystem "du", "df", "chroot", // Misc "realpath", "readlink", "basename", "dirname", "md5sum", "sha256sum", "xxd", "hexdump", "xdg-open", "open", "start", ]); /** Commands that only accept directories (not files) */ const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]); /** * Check if the current command context expects a path argument. */ export function shouldDoPathCompletion( ctx: CompletionContext, resolvedArgs?: FigArg | FigArg[], ): { shouldComplete: boolean; foldersOnly: boolean } { const currentWord = stripWrappingQuotes(ctx.currentWord); // 1. Typed path trigger: if current word starts with path-like prefix, always complete if (currentWord.startsWith("/") || currentWord.startsWith("./") || currentWord.startsWith("../") || currentWord.startsWith("~/") || currentWord === "." || currentWord === ".." || currentWord === "~") { const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName); return { shouldComplete: true, foldersOnly }; } // 2. Fig spec template check if (resolvedArgs) { const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs]; for (const arg of args) { const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : []; if (templates.includes("filepaths") || templates.includes("folders")) { return { shouldComplete: true, foldersOnly: templates.includes("folders") && !templates.includes("filepaths"), }; } } } // 3. Hardcoded command list (for commands without fig specs) if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) { // Only if we're past the command name and not typing an option if (!currentWord.startsWith("-")) { return { shouldComplete: true, foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName), }; } } return { shouldComplete: false, foldersOnly: false }; } /** * Parse the current word into directory-to-list and filter prefix. */ export function resolvePathComponents( currentWord: string, cwd: string | undefined, options: ResolvePathOptions = {}, ): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } { const quotePrefix = getLeadingQuote(currentWord); const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix); const unquotedWord = stripWrappingQuotes(currentWord); const preferRelativeCwd = options.preferRelativeCwd === true; // Handle empty input — list CWD if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") { const dir = unquotedWord === "~" ? "~" : unquotedWord === ".." ? resolveDirLookup("../", cwd, preferRelativeCwd) : resolveDirLookup("", cwd, preferRelativeCwd); const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix; return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix }; } // Find the last path separator const lastSlash = unquotedWord.lastIndexOf("/"); if (lastSlash >= 0) { const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing / const filterPart = unquotedWord.substring(lastSlash + 1); const decodedDirPart = decodeShellPathFragment(dirPart); const decodedFilterPart = decodeShellPathFragment(filterPart); const dirToList = resolveDirLookup(decodedDirPart, cwd, preferRelativeCwd); return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix }; } // No slash — filter CWD entries by the typed prefix return { dirToList: resolveDirLookup("", cwd, preferRelativeCwd), filterPrefix: decodeShellPathFragment(unquotedWord), pathPrefix: quotePrefix, quoteSuffix, }; } export function normalizePathTokenForLookup( token: string, cwd?: string, options: ResolvePathOptions = {}, ): string { const { dirToList, filterPrefix } = resolvePathComponents(token, cwd, options); if (!filterPrefix) return dirToList; if (!dirToList || dirToList === ".") { return filterPrefix; } const needsSeparator = !dirToList.endsWith("/"); return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`; } /** * Get path completion suggestions. */ export async function getPathSuggestions( ctx: CompletionContext, options: { sessionId?: string; protocol?: string; os?: "linux" | "windows" | "macos"; cwd?: string; cwdSource?: AutocompleteCwdSource; foldersOnly: boolean; }, ): Promise<{ name: string; type: DirEntry["type"] }[]> { const { sessionId, protocol, os, cwd, cwdSource, foldersOnly } = options; const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd, { preferRelativeCwd: shouldPreferRemoteShellCwd(protocol, sessionId, os, cwd, cwdSource), }); const entries = await listDirectoryEntries(dirToList, { sessionId, protocol, os, foldersOnly, filterPrefix, limit: 100, }); return sortPathEntries(entries); } /** * List directory contents via IPC, with shared caching and in-flight dedup. */ export async function listDirectoryEntries( dirPath: string, options: { sessionId?: string; protocol?: string; os?: "linux" | "windows" | "macos"; foldersOnly: boolean; filterPrefix?: string; limit?: number; }, ): Promise { const { sessionId, protocol, os, foldersOnly, filterPrefix = "", limit = 100, } = options; const normalizedPrefix = filterPrefix.toLowerCase(); const maxEntries = clampLimit(limit); const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`; const fullCacheKey = `${baseKey}:all`; const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`; const bypassCache = shouldBypassCache(protocol, sessionId, os, dirPath); const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey; // Full directory cache can satisfy both full and filtered lookups. // Relative SSH cwd paths bypass durable cache and in-flight reuse: the shell // cwd can move, so a listing started for "." in directory A must not satisfy // a later lookup after cd into B. Soft-budget timeout + late refresh already // share one promise at the getCompletions call site. if (!bypassCache) { const fullCached = fullDirCache.get(fullCacheKey); if (isFresh(fullCached)) { return filterEntries(fullCached.entries, normalizedPrefix, maxEntries); } if (normalizedPrefix) { const filteredCached = filteredDirCache.get(filteredCacheKey); if (isFresh(filteredCached)) { return filteredCached.entries; } } const inFlightFull = inFlightRequests.get(fullCacheKey); if (inFlightFull) { return filterEntries(await inFlightFull, normalizedPrefix, maxEntries); } const inFlight = inFlightRequests.get(requestKey); if (inFlight) return inFlight; } // Make IPC call const promise = (async (): Promise => { try { const bridge = getBridge(); if (!bridge) return []; let result: { success: boolean; entries: DirEntry[] }; if (protocol === "local" || !sessionId) { if (!bridge.listAutocompleteLocalDir) return []; result = await bridge.listAutocompleteLocalDir( dirPath, foldersOnly, normalizedPrefix || undefined, maxEntries, ); } else { if (!bridge.listAutocompleteRemoteDir) return []; result = await bridge.listAutocompleteRemoteDir( sessionId, dirPath, foldersOnly, normalizedPrefix || undefined, maxEntries, ); } if (result.success) { const timestamp = Date.now(); if (bypassCache) { return result.entries; } if (normalizedPrefix) { filteredDirCache.set(requestKey, { entries: result.entries, timestamp }); evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE); return result.entries; } fullDirCache.set(requestKey, { entries: result.entries, timestamp }); evictOldest(fullDirCache, MAX_CACHE_SIZE); return result.entries; } return []; } catch { return []; } finally { if (!bypassCache) { inFlightRequests.delete(requestKey); } } })(); if (!bypassCache) { inFlightRequests.set(requestKey, promise); } return promise; } function clampLimit(limit: number): number { if (!Number.isFinite(limit)) return 100; return Math.max(1, Math.min(200, Math.floor(limit))); } function resolveDirLookup(pathToken: string, cwd: string | undefined, preferRelativeCwd = false): string { if (!pathToken) return preferRelativeCwd ? "." : (cwd || "."); if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken); if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken); if (preferRelativeCwd) return normalizePosixLikePath(pathToken); if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`); return normalizePosixLikePath(pathToken); } export function shouldPreferRemoteShellCwd( protocol: string | undefined, sessionId: string | undefined, os?: "linux" | "windows" | "macos", cwd?: string, cwdSource?: AutocompleteCwdSource, ): boolean { if (cwdSource === "prompt" && cwd?.startsWith("/")) return false; return Boolean(sessionId && protocol !== "local" && os === "linux"); } function shouldBypassCache( protocol: string | undefined, sessionId: string | undefined, os: "linux" | "windows" | "macos" | undefined, dirPath: string, ): boolean { if (!shouldPreferRemoteShellCwd(protocol, sessionId, os)) return false; return !dirPath.startsWith("/") && dirPath !== "~" && !dirPath.startsWith("~/"); } function normalizePosixLikePath(input: string): string { if (!input) return "."; const hasLeadingSlash = input.startsWith("/"); const hasTildeRoot = input === "~" || input.startsWith("~/"); const hasTrailingSlash = input.length > 1 && input.endsWith("/"); const fixedRootSegments = hasTildeRoot ? 1 : 0; const raw = hasLeadingSlash ? input.slice(1) : hasTildeRoot ? input.slice(2) : input; const segments = hasTildeRoot ? ["~"] : []; for (const segment of raw.split("/")) { if (!segment || segment === ".") continue; if (segment === "..") { if ( segments.length > fixedRootSegments && segments[segments.length - 1] !== ".." ) { segments.pop(); } else if (!hasLeadingSlash || hasTildeRoot) { segments.push(segment); } continue; } segments.push(segment); } let result: string; if (hasLeadingSlash) { result = "/" + segments.join("/"); if (result === "/") return result; } else if (segments.length > 0) { result = segments.join("/"); } else if (hasTildeRoot) { result = "~"; } else { result = "."; } if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") { result += "/"; } else if (hasTrailingSlash && result === "~") { result = "~/"; } return result; } function isFresh( cached: { entries: DirEntry[]; timestamp: number } | undefined, ): cached is { entries: DirEntry[]; timestamp: number } { return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS); } function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] { if (!filterPrefix) return entries.slice(0, limit); const filtered: DirEntry[] = []; for (const entry of entries) { if (entry.name.toLowerCase().startsWith(filterPrefix)) { filtered.push(entry); if (filtered.length >= limit) break; } } return filtered; } function evictOldest( cache: Map, maxSize: number, ): void { while (cache.size > maxSize) { const oldestKey = cache.keys().next().value; if (!oldestKey) break; cache.delete(oldestKey); } } function decodeShellPathFragment(value: string): string { let result = ""; let escaped = false; for (const ch of value) { if (escaped) { result += ch; escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } result += ch; } if (escaped) result += "\\"; return result; } function getLeadingQuote(value: string): string { return value.startsWith('"') || value.startsWith("'") ? value[0] : ""; } function getTrailingMatchingQuote(value: string, quotePrefix: string): string { return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : ""; } function stripWrappingQuotes(value: string): string { if (!value) return value; let result = value; if (result.startsWith('"') || result.startsWith("'")) { result = result.slice(1); } if (result.endsWith('"') || result.endsWith("'")) { result = result.slice(0, -1); } return result; } function sortPathEntries(entries: DirEntry[]): DirEntry[] { return [...entries].sort((left, right) => { const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2; const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2; if (leftRank !== rightRank) return leftRank - rightRank; return left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); }); }