fix(graph-ui): navigate Windows drive breadcrumbs to real paths

The index file picker built breadcrumb targets as '/' + segments, so on a Windows drive path (C:/Users/rap) clicking a crumb browsed to '/C:/...', which the backend rejected as 'not a directory'. Only the '.. (up)' button worked.

Build drive-aware crumb targets (C:/, C:/Users) and drop the bogus unified '/' root crumb on Windows drive paths; POSIX behavior is unchanged. Add a regression test for Windows breadcrumb navigation and cleanup() for test isolation.

Signed-off-by: Zadak <rarepops@protonmail.com>
This commit is contained in:
Zadak
2026-07-04 09:39:28 +02:00
parent 9871e7e086
commit 4efa22cd95
2 changed files with 50 additions and 4 deletions
+35 -1
View File
@@ -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,39 @@ 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");
});
});
});
describe("IndexProgress", () => {
+15 -3
View File
@@ -239,6 +239,16 @@ 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("/");
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
@@ -297,12 +307,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}