diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx index 1a3df5b6f..e51e7b679 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 } 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"; @@ -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") { @@ -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\\信租风控通后端" }); }); }); @@ -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( {}} />); + 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); + }); }); diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 3c439dce4..313cee027 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -172,33 +172,53 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat const [dirs, setDirs] = useState([]); const [roots, setRoots] = useState(["/"]); 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(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; @@ -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(); @@ -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 (
@@ -250,26 +292,17 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat

{t.index.instructions}

-
+
-
@@ -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" />
- {roots.map((root) => ( + {displayRoots.map((root) => ( + {!isWinPath && ( + + )} {segments.map((seg, i) => ( - / + {(i > 0 || !isWinPath) && /}