Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
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/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user