diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx index f0cc22d5..00aa2037 100644 --- a/graph-ui/src/components/StatsTab.test.tsx +++ b/graph-ui/src/components/StatsTab.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ import "@testing-library/jest-dom/vitest"; -import { fireEvent, render, screen, waitFor, act } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StatsTab, IndexProgress } from "./StatsTab"; import { messages } from "../lib/i18n"; @@ -43,6 +43,7 @@ function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response describe("StatsTab index modal", () => { afterEach(() => { + cleanup(); vi.unstubAllGlobals(); }); @@ -93,6 +94,119 @@ 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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); + }); }); describe("IndexProgress", () => { diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 07e15fd5..62abe7aa 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -179,26 +179,47 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const filterRef = useRef(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(""); - 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; @@ -239,6 +260,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 (
@@ -257,6 +302,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat 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" /> @@ -282,7 +328,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" />
- {roots.map((root) => ( + {displayRoots.map((root) => ( + {!isWinPath && ( + + )} {segments.map((seg, i) => ( - / + {(i > 0 || !isWinPath) && /}