storkit: create 365_story_surface_api_rate_limit_warnings_in_chat

This commit is contained in:
dave
2026-03-22 18:19:23 +00:00
parent f346712dd1
commit e4227cf673
175 changed files with 0 additions and 83945 deletions
@@ -1,170 +0,0 @@
export interface ProjectPathMatch {
name: string;
path: string;
}
export interface ProjectPathInputProps {
value: string;
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
suggestionTail: string;
matchList: ProjectPathMatch[];
selectedMatch: number;
onSelectMatch: (index: number) => void;
onAcceptMatch: (path: string) => void;
onCloseSuggestions: () => void;
currentPartial: string;
}
function renderHighlightedMatch(text: string, query: string) {
if (!query) return text;
let qIndex = 0;
const lowerQuery = query.toLowerCase();
const counts = new Map<string, number>();
return text.split("").map((char) => {
const isMatch =
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
if (isMatch) {
qIndex += 1;
}
const count = counts.get(char) ?? 0;
counts.set(char, count + 1);
return (
<span
key={`${char}-${count}`}
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
>
{char}
</span>
);
});
}
export function ProjectPathInput({
value,
onChange,
onKeyDown,
suggestionTail,
matchList,
selectedMatch,
onSelectMatch,
onAcceptMatch,
onCloseSuggestions,
currentPartial,
}: ProjectPathInputProps) {
return (
<div
style={{
position: "relative",
marginTop: "12px",
marginBottom: "170px",
}}
>
<div
style={{
position: "absolute",
inset: 0,
padding: "10px",
color: "#aaa",
fontFamily: "monospace",
whiteSpace: "pre",
overflow: "hidden",
textOverflow: "ellipsis",
pointerEvents: "none",
}}
>
{value}
{suggestionTail}
</div>
<input
type="text"
value={value}
placeholder="/path/to/project"
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
style={{
width: "100%",
padding: "10px",
fontFamily: "monospace",
background: "transparent",
position: "relative",
zIndex: 1,
}}
/>
{matchList.length > 0 && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
marginTop: "6px",
border: "1px solid #ddd",
borderRadius: "6px",
overflow: "hidden",
background: "#fff",
fontFamily: "monospace",
height: "160px",
overflowY: "auto",
boxSizing: "border-box",
zIndex: 2,
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
padding: "4px 6px",
borderBottom: "1px solid #eee",
background: "#fafafa",
}}
>
<button
type="button"
aria-label="Close suggestions"
onClick={onCloseSuggestions}
style={{
width: "24px",
height: "24px",
borderRadius: "4px",
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
lineHeight: 1,
}}
>
×
</button>
</div>
{matchList.map((match, index) => {
const isSelected = index === selectedMatch;
return (
<button
key={match.path}
type="button"
onMouseEnter={() => onSelectMatch(index)}
onMouseDown={(event) => {
event.preventDefault();
onSelectMatch(index);
onAcceptMatch(match.path);
}}
style={{
width: "100%",
textAlign: "left",
padding: "6px 8px",
border: "none",
background: isSelected ? "#f0f0f0" : "transparent",
cursor: "pointer",
fontFamily: "inherit",
}}
>
{renderHighlightedMatch(match.name, currentPartial)}/
</button>
);
})}
</div>
)}
</div>
);
}
@@ -1,66 +0,0 @@
export interface RecentProjectsListProps {
projects: string[];
onOpenProject: (path: string) => void;
onForgetProject: (path: string) => void;
}
export function RecentProjectsList({
projects,
onOpenProject,
onForgetProject,
}: RecentProjectsListProps) {
return (
<div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "0.9em", color: "#666" }}>Recent projects</div>
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
{projects.map((project) => {
const displayName =
project.split("/").filter(Boolean).pop() ?? project;
return (
<li key={project} style={{ marginBottom: "6px" }}>
<div
style={{ display: "flex", gap: "6px", alignItems: "center" }}
>
<button
type="button"
onClick={() => onOpenProject(project)}
style={{
flex: 1,
textAlign: "left",
padding: "8px 10px",
borderRadius: "6px",
border: "1px solid #ddd",
background: "#f7f7f7",
cursor: "pointer",
fontFamily: "monospace",
fontSize: "0.9em",
}}
title={project}
>
{displayName}
</button>
<button
type="button"
aria-label={`Forget ${displayName}`}
onClick={() => onForgetProject(project)}
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
fontSize: "1.1em",
lineHeight: 1,
}}
>
×
</button>
</div>
</li>
);
})}
</ul>
</div>
);
}
@@ -1,136 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { describe, expect, it, vi } from "vitest";
import type { SelectionScreenProps } from "./SelectionScreen";
import { SelectionScreen } from "./SelectionScreen";
function makeProps(
overrides: Partial<SelectionScreenProps> = {},
): SelectionScreenProps {
return {
knownProjects: [],
onOpenProject: vi.fn(),
onForgetProject: vi.fn(),
pathInput: "",
homeDir: null,
onPathInputChange: vi.fn(),
onPathInputKeyDown: vi.fn() as (
event: KeyboardEvent<HTMLInputElement>,
) => void,
isOpening: false,
suggestionTail: "",
matchList: [],
selectedMatch: -1,
onSelectMatch: vi.fn(),
onAcceptMatch: vi.fn(),
onCloseSuggestions: vi.fn(),
completionError: null,
currentPartial: "",
...overrides,
};
}
describe("SelectionScreen", () => {
it("renders the title and description", () => {
render(<SelectionScreen {...makeProps()} />);
expect(screen.getByText("Storkit")).toBeInTheDocument();
expect(
screen.getByText("Paste or complete a project path to start."),
).toBeInTheDocument();
});
it("renders recent projects list when knownProjects is non-empty", () => {
render(
<SelectionScreen
{...makeProps({ knownProjects: ["/Users/test/project"] })}
/>,
);
expect(screen.getByText("Recent projects")).toBeInTheDocument();
});
it("does not render recent projects list when knownProjects is empty", () => {
render(<SelectionScreen {...makeProps({ knownProjects: [] })} />);
expect(screen.queryByText("Recent projects")).not.toBeInTheDocument();
});
it("calls onOpenProject when Open Project button is clicked", () => {
const onOpenProject = vi.fn();
render(
<SelectionScreen
{...makeProps({ pathInput: "/my/path", onOpenProject })}
/>,
);
fireEvent.click(screen.getByText("Open Project"));
expect(onOpenProject).toHaveBeenCalledWith("/my/path");
});
it("shows Opening... text and disables buttons when isOpening is true", () => {
render(<SelectionScreen {...makeProps({ isOpening: true })} />);
expect(screen.getByText("Opening...")).toBeInTheDocument();
const buttons = screen.getAllByRole("button");
for (const button of buttons) {
if (
button.textContent === "Opening..." ||
button.textContent === "New Project"
) {
expect(button).toBeDisabled();
}
}
});
it("displays completion error when completionError is provided", () => {
render(
<SelectionScreen {...makeProps({ completionError: "Path not found" })} />,
);
expect(screen.getByText("Path not found")).toBeInTheDocument();
});
it("does not display error div when completionError is null", () => {
const { container } = render(<SelectionScreen {...makeProps()} />);
const errorDiv = container.querySelector('[style*="color: red"]');
expect(errorDiv).toBeNull();
});
it("New Project button calls onPathInputChange with homeDir (trailing slash appended)", () => {
const onPathInputChange = vi.fn();
const onCloseSuggestions = vi.fn();
render(
<SelectionScreen
{...makeProps({
homeDir: "/Users/test",
onPathInputChange,
onCloseSuggestions,
})}
/>,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/");
expect(onCloseSuggestions).toHaveBeenCalled();
});
it("New Project button uses homeDir as-is when it already ends with /", () => {
const onPathInputChange = vi.fn();
const onCloseSuggestions = vi.fn();
render(
<SelectionScreen
{...makeProps({
homeDir: "/Users/test/",
onPathInputChange,
onCloseSuggestions,
})}
/>,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/");
expect(onCloseSuggestions).toHaveBeenCalled();
});
it("New Project button uses empty string when homeDir is null", () => {
const onPathInputChange = vi.fn();
render(
<SelectionScreen {...makeProps({ homeDir: null, onPathInputChange })} />,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("");
});
});
@@ -1,116 +0,0 @@
import type { KeyboardEvent } from "react";
import { ProjectPathInput } from "./ProjectPathInput.tsx";
import { RecentProjectsList } from "./RecentProjectsList.tsx";
export interface RecentProjectMatch {
name: string;
path: string;
}
export interface SelectionScreenProps {
knownProjects: string[];
onOpenProject: (path: string) => void;
onForgetProject: (path: string) => void;
pathInput: string;
homeDir?: string | null;
onPathInputChange: (value: string) => void;
onPathInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
isOpening: boolean;
suggestionTail: string;
matchList: RecentProjectMatch[];
selectedMatch: number;
onSelectMatch: (index: number) => void;
onAcceptMatch: (path: string) => void;
onCloseSuggestions: () => void;
completionError: string | null;
currentPartial: string;
}
export function SelectionScreen({
knownProjects,
onOpenProject,
onForgetProject,
pathInput,
homeDir,
onPathInputChange,
onPathInputKeyDown,
isOpening,
suggestionTail,
matchList,
selectedMatch,
onSelectMatch,
onAcceptMatch,
onCloseSuggestions,
completionError,
currentPartial,
}: SelectionScreenProps) {
const resolvedHomeDir = homeDir
? homeDir.endsWith("/")
? homeDir
: `${homeDir}/`
: "";
return (
<div
className="selection-screen"
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<h1>Storkit</h1>
<p>Paste or complete a project path to start.</p>
{knownProjects.length > 0 && (
<RecentProjectsList
projects={knownProjects}
onOpenProject={onOpenProject}
onForgetProject={onForgetProject}
/>
)}
<ProjectPathInput
value={pathInput}
onChange={onPathInputChange}
onKeyDown={onPathInputKeyDown}
suggestionTail={suggestionTail}
matchList={matchList}
selectedMatch={selectedMatch}
onSelectMatch={onSelectMatch}
onAcceptMatch={onAcceptMatch}
onCloseSuggestions={onCloseSuggestions}
currentPartial={currentPartial}
/>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
alignItems: "center",
}}
>
<button
type="button"
onClick={() => onOpenProject(pathInput)}
disabled={isOpening}
>
{isOpening ? "Opening..." : "Open Project"}
</button>
<button
type="button"
onClick={() => {
onPathInputChange(resolvedHomeDir);
onCloseSuggestions();
}}
disabled={isOpening}
>
New Project
</button>
<div style={{ fontSize: "0.85em", color: "#666" }}>
Press Tab to complete the next path segment
</div>
</div>
{completionError && (
<div style={{ color: "red", marginTop: "8px" }}>{completionError}</div>
)}
</div>
);
}
@@ -1,461 +0,0 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FileEntry } from "./usePathCompletion";
import {
getCurrentPartial,
isFuzzyMatch,
usePathCompletion,
} from "./usePathCompletion";
describe("isFuzzyMatch", () => {
it("matches when query is empty", () => {
expect(isFuzzyMatch("anything", "")).toBe(true);
});
it("matches exact prefix", () => {
expect(isFuzzyMatch("Documents", "Doc")).toBe(true);
});
it("matches fuzzy subsequence", () => {
expect(isFuzzyMatch("Documents", "dms")).toBe(true);
});
it("is case insensitive", () => {
expect(isFuzzyMatch("Documents", "DOCU")).toBe(true);
});
it("rejects when chars not found in order", () => {
expect(isFuzzyMatch("abc", "acb")).toBe(false);
});
it("rejects completely unrelated", () => {
expect(isFuzzyMatch("hello", "xyz")).toBe(false);
});
});
describe("getCurrentPartial", () => {
it("returns empty for empty input", () => {
expect(getCurrentPartial("")).toBe("");
});
it("returns empty when input ends with slash", () => {
expect(getCurrentPartial("/home/user/")).toBe("");
});
it("returns last segment", () => {
expect(getCurrentPartial("/home/user/Doc")).toBe("Doc");
});
it("returns full input when no slash", () => {
expect(getCurrentPartial("Doc")).toBe("Doc");
});
it("trims then evaluates: trailing-slash input returns empty", () => {
// " /home/user/ " trims to "/home/user/" which ends with slash
expect(getCurrentPartial(" /home/user/ ")).toBe("");
});
it("trims then returns last segment", () => {
expect(getCurrentPartial(" /home/user/Doc ")).toBe("Doc");
});
});
describe("usePathCompletion hook", () => {
const mockListDir = vi.fn<(path: string) => Promise<FileEntry[]>>();
beforeEach(() => {
mockListDir.mockReset();
});
it("returns empty matchList for empty input", async () => {
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
// Allow effect + setTimeout(0) to fire
await waitFor(() => {
expect(mockListDir).not.toHaveBeenCalled();
});
expect(result.current.matchList).toEqual([]);
});
it("fetches directory listing and returns matches", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
{ name: ".bashrc", kind: "file" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
expect(result.current.matchList[0].name).toBe("Documents");
expect(result.current.matchList[1].name).toBe("Downloads");
expect(result.current.matchList.every((m) => m.path.endsWith("/"))).toBe(
true,
);
});
it("filters by fuzzy match on partial input", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
{ name: "Desktop", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/Doc",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
expect(result.current.matchList[0].name).toBe("Documents");
});
it("calls setPathInput when acceptMatch is invoked", () => {
const setPathInput = vi.fn();
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/",
setPathInput,
homeDir: "/home",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
act(() => {
result.current.acceptMatch("/home/user/Documents/");
});
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
});
it("uses homeDir when input has no slash (bare partial)", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Doc",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
expect(mockListDir).toHaveBeenCalledWith("/home/user");
expect(result.current.matchList[0].name).toBe("Documents");
expect(result.current.matchList[0].path).toBe("/home/user/Documents/");
});
it("returns early when input has no slash and homeDir is null", async () => {
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Doc",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
// Wait for debounce + effect to fire
await waitFor(() => {
expect(result.current.matchList).toEqual([]);
});
expect(mockListDir).not.toHaveBeenCalled();
});
it("returns empty matchList when no dirs match the fuzzy filter", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/zzz",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(mockListDir).toHaveBeenCalled();
});
// No dirs match "zzz" fuzzy filter, so matchList stays empty
expect(result.current.matchList).toEqual([]);
});
it("sets completionError when listDirectoryAbsolute throws an Error", async () => {
mockListDir.mockRejectedValue(new Error("Permission denied"));
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/root/",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.completionError).toBe("Permission denied");
});
});
it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => {
mockListDir.mockRejectedValue("some string error");
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/root/",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.completionError).toBe(
"Failed to compute suggestion.",
);
});
});
it("clears suggestionTail when selected match path does not start with input", async () => {
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Doc",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
// Wait for matches to load (path will be /home/user/Documents/)
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
// The match path is "/home/user/Documents/" which does NOT start with "Doc"
// so suggestionTail should be ""
expect(result.current.suggestionTail).toBe("");
});
it("acceptSelectedMatch calls setPathInput with the selected match path", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const setPathInput = vi.fn();
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput,
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
act(() => {
result.current.acceptSelectedMatch();
});
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
});
it("acceptSelectedMatch does nothing when matchList is empty", () => {
const setPathInput = vi.fn();
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "",
setPathInput,
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
act(() => {
result.current.acceptSelectedMatch();
});
expect(setPathInput).not.toHaveBeenCalled();
});
it("closeSuggestions clears matchList, selectedMatch, suggestionTail, and completionError", async () => {
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
act(() => {
result.current.closeSuggestions();
});
expect(result.current.matchList).toEqual([]);
expect(result.current.selectedMatch).toBe(0);
expect(result.current.suggestionTail).toBe("");
expect(result.current.completionError).toBeNull();
});
it("uses homeDir with trailing slash as-is", async () => {
mockListDir.mockResolvedValue([{ name: "Projects", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Pro",
setPathInput: vi.fn(),
homeDir: "/home/user/",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
expect(mockListDir).toHaveBeenCalledWith("/home/user");
expect(result.current.matchList[0].path).toBe("/home/user/Projects/");
});
it("handles root directory listing (dir = '/')", async () => {
mockListDir.mockResolvedValue([
{ name: "home", kind: "dir" },
{ name: "etc", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
expect(mockListDir).toHaveBeenCalledWith("/");
expect(result.current.matchList[0].name).toBe("etc");
expect(result.current.matchList[1].name).toBe("home");
});
it("computes suggestionTail when match path starts with trimmed input", async () => {
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
// path is "/home/user/Documents/" and input is "/home/user/"
// so tail should be "Documents/"
expect(result.current.suggestionTail).toBe("Documents/");
});
it("setSelectedMatch updates the selected index", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
act(() => {
result.current.setSelectedMatch(1);
});
expect(result.current.selectedMatch).toBe(1);
// After selecting index 1, suggestionTail should reflect "Downloads/"
expect(result.current.suggestionTail).toBe("Downloads/");
});
});
@@ -1,192 +0,0 @@
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;
}
export 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;
}
export 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,
};
}