Files
llama.cpp/tools/ui/src/lib/utils/working-directory.ts
T

168 lines
4.7 KiB
TypeScript

/**
* 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;
}