[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
506
components/terminal/autocomplete/remotePathCompleter.ts
Normal file
506
components/terminal/autocomplete/remotePathCompleter.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* 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" });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user