Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 117 additions & 9 deletions graph-ui/src/components/StatsTab.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StatsTab } from "./StatsTab";

Expand Down Expand Up @@ -42,10 +42,11 @@ function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response

describe("StatsTab index modal", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

it("submits a custom path and project name", async () => {
it("submits a custom path", async () => {
let submitted: unknown = null;
mockProjectsFetch((url, init) => {
if (url === "/api/index") {
Expand All @@ -64,16 +65,10 @@ describe("StatsTab index modal", () => {
fireEvent.change(await screen.findByLabelText("Repository path"), {
target: { value: "D:\\work\\信租风控通后端" },
});
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "信租风控通后端" },
});
fireEvent.click(screen.getByRole("button", { name: "Index This Folder" }));

await waitFor(() => {
expect(submitted).toEqual({
root_path: "D:\\work\\信租风控通后端",
project_name: "信租风控通后端",
});
expect(submitted).toEqual({ root_path: "D:\\work\\信租风控通后端" });
});
});

Expand All @@ -92,4 +87,117 @@ describe("StatsTab index modal", () => {
expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
});

it("navigates Windows breadcrumb segments to real drive paths", async () => {
const fetchMock = mockProjectsFetch((url) => {
if (url.startsWith("/api/browse")) {
return new Response(JSON.stringify({
path: "C:/Users/rap",
parent: "C:/Users",
dirs: ["Documents", "Downloads"],
roots: ["C:/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return undefined;
});

render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));

/* No bogus unified "/" root crumb on a Windows drive path. */
await screen.findByRole("button", { name: "C:" });
expect(screen.queryByRole("button", { name: "/" })).not.toBeInTheDocument();

/* Clicking the drive crumb browses to "C:/", not "/C:". */
fireEvent.click(screen.getByRole("button", { name: "C:" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/browse?path=C%3A%2F");
});

/* Clicking a nested crumb browses to "C:/Users", not "/C:/Users". */
fireEvent.click(screen.getByRole("button", { name: "Users" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/browse?path=C%3A%2FUsers");
});
});

it("refreshes the folder list when a drive is typed into the path field", async () => {
mockProjectsFetch((url) => {
if (url.startsWith("/api/browse")) {
const m = /[?&]path=([^&]*)/.exec(url);
const path = m ? decodeURIComponent(m[1]) : "C:/Users/rap";
const onD = path.replace(/\\/g, "/").toUpperCase().startsWith("D:");
return new Response(JSON.stringify({
path,
parent: "C:/",
dirs: onD ? ["projects", "games"] : ["Documents", "Downloads"],
roots: ["C:/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return undefined;
});

render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));

/* Initial C: listing is shown. */
expect(await screen.findByText("Documents")).toBeInTheDocument();

/* Typing a different drive refreshes the listing to that drive (debounced). */
fireEvent.change(await screen.findByLabelText("Repository path"), {
target: { value: "D:/" },
});

expect(await screen.findByText("projects")).toBeInTheDocument();
expect(screen.queryByText("Documents")).not.toBeInTheDocument();
});

it("replaces the meaningless '/' root with the drive on Windows", async () => {
const fetchMock = mockProjectsFetch((url) => {
if (url.startsWith("/api/browse")) {
const m = /[?&]path=([^&]*)/.exec(url);
const path = m ? decodeURIComponent(m[1]) : "C:/Users/rap";
return new Response(JSON.stringify({
path,
parent: "C:/",
dirs: ["Documents"],
roots: ["/"], // older backend: no drive enumeration
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return undefined;
});

render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));

/* The bogus "/" quick-jump is gone; the current drive root is offered. */
expect(await screen.findByRole("button", { name: "Browse C:/" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Browse /" })).not.toBeInTheDocument();

/* Clicking it browses to the drive root, not "/". */
fireEvent.click(screen.getByRole("button", { name: "Browse C:/" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/browse?path=C%3A%2F");
});
});

it("does not auto-refresh on POSIX when a path is typed", async () => {
const fetchMock = mockProjectsFetch(); // browse returns POSIX path "/home/dev"

render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
await screen.findByText("alpha"); // initial POSIX listing

const browseCalls = () =>
fetchMock.mock.calls.filter((c) => String(c[0]).startsWith("/api/browse")).length;
const before = browseCalls();

fireEvent.change(screen.getByLabelText("Repository path"), {
target: { value: "/usr/local" },
});

/* Wait past the debounce window; a POSIX path must NOT trigger a re-browse. */
await new Promise((r) => setTimeout(r, 400));
expect(browseCalls()).toBe(before);
});
});
81 changes: 58 additions & 23 deletions graph-ui/src/components/StatsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,33 +172,53 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
const [dirs, setDirs] = useState<string[]>([]);
const [roots, setRoots] = useState<string[]>(["/"]);
const [parentPath, setParentPath] = useState("");
const [projectName, setProjectName] = useState("");
const [filter, setFilter] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const filterRef = useRef<HTMLInputElement>(null);
/* Path whose listing is currently shown. Lets the typed-path effect skip a
* redundant re-fetch after browse() sets currentPath itself. */
const lastBrowsedRef = useRef<string>("");

const browse = useCallback(async (path?: string) => {
setLoading(true);
const browse = useCallback(async (path?: string, opts?: { silent?: boolean }) => {
const silent = opts?.silent ?? false;
if (!silent) setLoading(true);
setError(null);
try {
const q = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/browse${q}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
lastBrowsedRef.current = data.path ?? "";
setCurrentPath(data.path ?? "");
setDirs((data.dirs ?? []).sort());
setRoots(data.roots ?? ["/"]);
setParentPath(data.parent ?? "/");
} catch (e) { setError(e instanceof Error ? e.message : "Browse failed"); }
finally { setLoading(false); }
} catch (e) {
/* Silent (typed-path) refreshes keep the last good listing instead of
* flashing an error while the user is still typing a path. */
if (!silent) setError(e instanceof Error ? e.message : "Browse failed");
}
finally { if (!silent) setLoading(false); }
}, []);

useEffect(() => { browse(); }, [browse]);
useEffect(() => { filterRef.current?.focus(); }, []);

/* Windows only: when the user types a drive path into the Repository path
* field, refresh the folder listing to match (debounced). On Windows, typing
* is the way to switch drives, and without this the breadcrumb and path box
* updated but the directory list stayed stale (e.g. typing "D:/" still showed
* the previous drive's folders). POSIX navigation is left unchanged. */
useEffect(() => {
if (!currentPath || currentPath === lastBrowsedRef.current) return;
if (!/^[A-Za-z]:/.test(currentPath.replace(/\\/g, "/"))) return;
const id = setTimeout(() => { void browse(currentPath, { silent: true }); }, 350);
return () => clearTimeout(id);
}, [currentPath, browse]);

const filteredDirs = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return dirs;
Expand All @@ -211,9 +231,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
if (!path) return;
setSubmitting(true); setError(null);
try {
const body: { root_path: string; project_name?: string } = { root_path: path };
if (projectName.trim()) body.project_name = projectName.trim();
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root_path: path }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
onCreated(); onClose();
Expand All @@ -239,6 +257,30 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
/* Breadcrumb segments */
const displayPath = currentPath.replace(/\\/g, "/");
const segments = displayPath.split("/").filter(Boolean);
/* A Windows drive path ("C:/Users/rap") has no unified "/" root — its first
* segment is the drive letter. Build crumb targets accordingly so clicking a
* segment navigates to a real directory instead of a bogus "/C:/..." path
* that the backend rejects as "not a directory". */
const isWinPath = /^[A-Za-z]:$/.test(segments[0] ?? "");
const crumbPath = (i: number): string => {
const parts = segments.slice(0, i + 1);
if (isWinPath) return parts.length === 1 ? `${parts[0]}/` : parts.join("/");
return "/" + parts.join("/");
};

/* Root/drive quick-jump buttons. On Windows the POSIX "/" root is meaningless
* — browsing it returns an empty listing — so drop it and offer drive roots
* instead. An older backend may not enumerate drives, so always include the
* current drive; other drives stay reachable by typing a path. */
const displayRoots = (() => {
if (!isWinPath) return roots;
const drives = Array.from(new Set(
roots.filter((r) => /^[A-Za-z]:[\\/]?$/.test(r)).map((r) => `${r[0].toUpperCase()}:/`),
));
const curRoot = `${displayPath[0].toUpperCase()}:/`;
if (!drives.includes(curRoot)) drives.unshift(curRoot);
return drives;
})();

return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
Expand All @@ -250,26 +292,17 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
<p className="text-[12px] text-foreground/30">{t.index.instructions}</p>
</div>

<div className="px-5 pb-3 grid grid-cols-[1fr_220px] gap-3 shrink-0">
<div className="px-5 pb-3 shrink-0">
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.repositoryPath}</span>
<input
aria-label={t.index.repositoryPath}
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && /^[A-Za-z]:/.test(currentPath.replace(/\\/g, "/"))) { e.preventDefault(); void browse(currentPath); } }}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground font-mono outline-none focus:border-primary/40"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.projectName}</span>
<input
aria-label={t.index.projectName}
value={projectName}
placeholder={t.index.projectNamePlaceholder}
onChange={(e) => setProjectName(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
</label>
</div>

<div className="px-5 pb-3 flex items-center gap-2 shrink-0">
Expand All @@ -282,7 +315,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
<div className="flex items-center gap-1">
{roots.map((root) => (
{displayRoots.map((root) => (
<button
key={root}
aria-label={t.index.browseRoot(root)}
Expand All @@ -297,12 +330,14 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat

{/* Breadcrumb */}
<div className="px-5 py-2 border-y border-border/20 flex items-center gap-0.5 overflow-x-auto text-[11px] shrink-0">
<button onClick={() => browse("/")} className="text-primary/60 hover:text-primary shrink-0 transition-colors">/</button>
{!isWinPath && (
<button onClick={() => browse("/")} className="text-primary/60 hover:text-primary shrink-0 transition-colors">/</button>
)}
{segments.map((seg, i) => (
<span key={i} className="flex items-center gap-0.5 shrink-0">
<span className="text-foreground/15">/</span>
{(i > 0 || !isWinPath) && <span className="text-foreground/15">/</span>}
<button
onClick={() => browse("/" + segments.slice(0, i + 1).join("/"))}
onClick={() => browse(crumbPath(i))}
className={`transition-colors ${i === segments.length - 1 ? "text-foreground/70 font-medium" : "text-primary/50 hover:text-primary"}`}
>
{seg}
Expand Down
4 changes: 0 additions & 4 deletions graph-ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ export const messages = {
selectRepositoryFolder: "Select Repository Folder",
instructions: "Navigate to the project root and click \"Index This Folder\".",
repositoryPath: "Repository path",
projectName: "Project name",
projectNamePlaceholder: "Optional display name",
filterFolders: "Filter folders",
noSubdirectories: "No subdirectories",
indexThisFolder: "Index This Folder",
Expand Down Expand Up @@ -112,8 +110,6 @@ export const messages = {
selectRepositoryFolder: "选择仓库目录",
instructions: "导航到项目根目录,然后点击“索引此目录”。",
repositoryPath: "仓库路径",
projectName: "项目名称",
projectNamePlaceholder: "可选显示名称",
filterFolders: "筛选目录",
noSubdirectories: "没有子目录",
indexThisFolder: "索引此目录",
Expand Down
Loading