Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
/**
|
|
* 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<string, { entries: DirEntry[]; timestamp: number }>();
|
|
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
|
|
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
|
|
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<DirEntry[]> {
|
|
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<DirEntry[]> => {
|
|
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<string, { entries: DirEntry[]; timestamp: number }>,
|
|
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" });
|
|
});
|
|
}
|