/** * Pure helpers for the working-directory picker search, backed by the * server's `file_glob_search` tool. Queries starting from a root (`/`, * `C:\`, `\\host\share`) or `~` navigate the tree (search the parent for * the last segment); anything else glob-matches home-relative entries. */ import { lastPathSegment } from './path-display'; import { GLOB, HOME_TILDE, LEADING_SLASHES_REGEX, PATH_SEPARATOR, SEARCH, TRAILING_SLASHES_REGEX } from '$lib/constants'; import type { GlobEntry, GlobSearchArgs } from '$lib/types/glob'; export interface PathQuery { parent: string; last: string; } /** * Rewrite `\` into `/` when the query carries a Windows root. Elsewhere the * backslash is left alone: it is a legal filename character on POSIX. */ function toPosixSeparators(query: string): string { if (!GLOB.DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(GLOB.WINDOWS_SEPARATOR)) return query; return query.split(GLOB.WINDOWS_SEPARATOR).join(PATH_SEPARATOR); } export function rootPrefixLength(path: string): number { const unc = path.match(GLOB.UNC_ROOT_REGEX); if (unc) return unc[0].length; const drive = path.match(GLOB.DRIVE_ROOT_REGEX); if (drive) return drive[0].length; return path.startsWith(PATH_SEPARATOR) ? PATH_SEPARATOR.length : 0; } /** A query starting from a root or from `~` is path navigation, not a home-relative glob. */ export function splitPathQuery(query: string): PathQuery | null { const normalized = toPosixSeparators(query); const rootLength = rootPrefixLength(normalized); if (rootLength === 0 && !normalized.startsWith(HOME_TILDE)) return null; // a root keeps its trailing separator so it stays absolute on its own const root = rootLength > 0 ? normalized.slice(0, rootLength).replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR : HOME_TILDE; const rest = normalized .slice(rootLength > 0 ? rootLength : HOME_TILDE.length) .replace(LEADING_SLASHES_REGEX, '') .replace(TRAILING_SLASHES_REGEX, ''); const parentOf = (dirs: string) => rootLength > 0 ? root + dirs : HOME_TILDE + PATH_SEPARATOR + dirs; if (!rest) return { last: '', parent: root }; const idx = rest.lastIndexOf(PATH_SEPARATOR); if (idx === -1) return { last: rest, parent: root }; return { last: rest.slice(idx + 1), parent: parentOf(rest.slice(0, idx)) }; } export function buildCaseInsensitiveGlob(query: string): string { let out = GLOB.WILDCARD; for (const c of query) { const lo = c.toLowerCase(); const up = c.toUpperCase(); if (lo !== up) out += GLOB.RANGE_OPEN + lo + up + GLOB.RANGE_CLOSE; // glob metacharacters are escaped into a literal character class so a // query like "a*b" matches a literal '*' instead of becoming "ab" else if (GLOB.SPECIAL_CHARS.includes(c)) out += GLOB.RANGE_OPEN + c + GLOB.RANGE_CLOSE; else out += c; } return out + GLOB.WILDCARD; } export function buildGlobSearchArgs( query: string, scopePath: string, searchDepth: number ): GlobSearchArgs { const pathQuery = splitPathQuery(query); const path = pathQuery ? pathQuery.parent : scopePath; const include = pathQuery ? pathQuery.last ? buildCaseInsensitiveGlob(pathQuery.last) : GLOB.WILDCARD : buildCaseInsensitiveGlob(query); const maxDepth = pathQuery ? SEARCH.PATH_NAV_MAX_DEPTH : searchDepth; return { include, last: pathQuery?.last, maxDepth, path, rankQuery: pathQuery?.last ?? query }; } const RANK_EXACT = 0; const RANK_PREFIX = 1; const RANK_SUBSTRING = 2; const RANK_OTHER = 3; function rankScore(path: string, query: string): number { const name = lastPathSegment(path).toLowerCase(); const q = query.toLowerCase(); if (name === q) return RANK_EXACT; if (name.startsWith(q)) return RANK_PREFIX; if (name.includes(q)) return RANK_SUBSTRING; return RANK_OTHER; } export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] { return [...entries].sort( (a, b) => rankScore(a.path, query) - rankScore(b.path, query) || a.path.length - b.path.length || a.path.localeCompare(b.path) ); } export function joinPath(base: string, rel: string): string { if (!base) return rel; return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel; } export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] { if (!query) return [{ match: false, text }]; const segments: { text: string; match: boolean }[] = []; const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); let i = 0; while (i < text.length) { const idx = lowerText.indexOf(lowerQuery, i); if (idx < 0) { segments.push({ match: false, text: text.slice(i) }); break; } if (idx > i) segments.push({ match: false, text: text.slice(i, idx) }); segments.push({ match: true, text: text.slice(idx, idx + query.length) }); i = idx + query.length; } return segments; }