Files
huskies/frontend/src/components/selection/usePathCompletion.ts
T
2026-02-16 20:34:03 +00:00

193 lines
4.5 KiB
TypeScript

import * as React from "react";
export interface FileEntry {
name: string;
kind: "file" | "dir";
}
export interface ProjectPathMatch {
name: string;
path: string;
}
export interface UsePathCompletionArgs {
pathInput: string;
setPathInput: (value: string) => void;
homeDir: string | null;
listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>;
debounceMs?: number;
}
export interface UsePathCompletionResult {
matchList: ProjectPathMatch[];
selectedMatch: number;
suggestionTail: string;
completionError: string | null;
currentPartial: string;
setSelectedMatch: (index: number) => void;
acceptSelectedMatch: () => void;
acceptMatch: (path: string) => void;
closeSuggestions: () => void;
}
function isFuzzyMatch(candidate: string, query: string) {
if (!query) return true;
const lowerCandidate = candidate.toLowerCase();
const lowerQuery = query.toLowerCase();
let idx = 0;
for (const char of lowerQuery) {
idx = lowerCandidate.indexOf(char, idx);
if (idx === -1) return false;
idx += 1;
}
return true;
}
function getCurrentPartial(input: string) {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.endsWith("/")) return "";
const idx = trimmed.lastIndexOf("/");
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
}
export function usePathCompletion({
pathInput,
setPathInput,
homeDir,
listDirectoryAbsolute,
debounceMs = 60,
}: UsePathCompletionArgs): UsePathCompletionResult {
const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]);
const [selectedMatch, setSelectedMatch] = React.useState(0);
const [suggestionTail, setSuggestionTail] = React.useState("");
const [completionError, setCompletionError] = React.useState<string | null>(
null,
);
React.useEffect(() => {
let active = true;
async function computeSuggestion() {
setCompletionError(null);
setSuggestionTail("");
setMatchList([]);
setSelectedMatch(0);
const trimmed = pathInput.trim();
if (!trimmed) {
return;
}
const endsWithSlash = trimmed.endsWith("/");
let dir = trimmed;
let partial = "";
if (!endsWithSlash) {
const idx = trimmed.lastIndexOf("/");
if (idx >= 0) {
dir = trimmed.slice(0, idx + 1);
partial = trimmed.slice(idx + 1);
} else {
dir = "";
partial = trimmed;
}
}
if (!dir) {
if (homeDir) {
dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`;
} else {
return;
}
}
const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, "");
const entries = await listDirectoryAbsolute(dirForListing);
if (!active) return;
const matches = entries
.filter((entry) => entry.kind === "dir")
.filter((entry) => isFuzzyMatch(entry.name, partial))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 8);
if (matches.length === 0) {
return;
}
const basePrefix = dir.endsWith("/") ? dir : `${dir}/`;
const list = matches.map((entry) => ({
name: entry.name,
path: `${basePrefix}${entry.name}/`,
}));
setMatchList(list);
}
const debounceId = window.setTimeout(() => {
computeSuggestion().catch((error) => {
console.error(error);
if (!active) return;
setCompletionError(
error instanceof Error
? error.message
: "Failed to compute suggestion.",
);
});
}, debounceMs);
return () => {
active = false;
window.clearTimeout(debounceId);
};
}, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]);
React.useEffect(() => {
if (matchList.length === 0) {
setSuggestionTail("");
return;
}
const index = Math.min(selectedMatch, matchList.length - 1);
const next = matchList[index];
const trimmed = pathInput.trim();
if (next.path.startsWith(trimmed)) {
setSuggestionTail(next.path.slice(trimmed.length));
} else {
setSuggestionTail("");
}
}, [matchList, selectedMatch, pathInput]);
const acceptMatch = React.useCallback(
(path: string) => {
setPathInput(path);
},
[setPathInput],
);
const acceptSelectedMatch = React.useCallback(() => {
const next = matchList[selectedMatch]?.path;
if (next) {
setPathInput(next);
}
}, [matchList, selectedMatch, setPathInput]);
const closeSuggestions = React.useCallback(() => {
setMatchList([]);
setSelectedMatch(0);
setSuggestionTail("");
setCompletionError(null);
}, []);
return {
matchList,
selectedMatch,
suggestionTail,
completionError,
currentPartial: getCurrentPartial(pathInput),
setSelectedMatch,
acceptSelectedMatch,
acceptMatch,
closeSuggestions,
};
}