refactor(web): extract useDirectoryBrowser hook
This commit is contained in:
parent
8169799356
commit
94ec79222d
|
|
@ -0,0 +1,82 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useDirectoryBrowser } from "@/hooks/useDirectoryBrowser";
|
||||
|
||||
function mockBrowse(entries: unknown[]) {
|
||||
return vi.fn().mockResolvedValue({ ok: true, json: async () => ({ entries, roots: [] }) });
|
||||
}
|
||||
|
||||
function deferredResponse() {
|
||||
let resolve!: (value: { ok: true; json: () => Promise<{ entries: never[]; roots: never[] }> }) => void;
|
||||
const promise = new Promise<{ ok: true; json: () => Promise<{ entries: never[]; roots: never[] }> }>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolve({ ok: true, json: async () => ({ entries: [], roots: [] }) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDirectoryBrowser", () => {
|
||||
it("loads the initial path on reset and tracks history", async () => {
|
||||
vi.stubGlobal("fetch", mockBrowse([{ name: "a", isDirectory: true, isGitRepo: false, hasLocalConfig: false }]));
|
||||
const { result } = renderHook(() => useDirectoryBrowser());
|
||||
act(() => result.current.reset());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.browsePath).toBe("~");
|
||||
expect(result.current.canGoBack).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.browse("~/a");
|
||||
});
|
||||
expect(result.current.browsePath).toBe("~/a");
|
||||
expect(result.current.canGoBack).toBe(true);
|
||||
|
||||
act(() => result.current.goBack());
|
||||
await waitFor(() => expect(result.current.browsePath).toBe("~"));
|
||||
});
|
||||
|
||||
it("surfaces a browse error when the API fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: "path is restricted" }) }));
|
||||
const { result } = renderHook(() => useDirectoryBrowser());
|
||||
await act(async () => {
|
||||
await result.current.browse("~/secret");
|
||||
});
|
||||
expect(result.current.error).toBe("path is restricted");
|
||||
});
|
||||
|
||||
it("ignores stale pending replace responses after newer navigation", async () => {
|
||||
const pendingRefresh = deferredResponse();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ entries: [], roots: [] }) })
|
||||
.mockReturnValueOnce(pendingRefresh.promise)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ entries: [], roots: [] }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ entries: [], roots: [] }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useDirectoryBrowser());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.browse("~/a");
|
||||
});
|
||||
expect(result.current.browsePath).toBe("~/a");
|
||||
expect(result.current.canGoBack).toBe(true);
|
||||
|
||||
act(() => result.current.refresh());
|
||||
act(() => result.current.goBack());
|
||||
await waitFor(() => expect(result.current.browsePath).toBe("~"));
|
||||
|
||||
await act(async () => {
|
||||
pendingRefresh.resolve();
|
||||
await pendingRefresh.promise;
|
||||
});
|
||||
|
||||
expect(result.current.browsePath).toBe("~");
|
||||
expect(result.current.locationInput).toBe("~");
|
||||
expect(result.current.selectedBrowsePath).toBe("~");
|
||||
expect(result.current.canGoForward).toBe(true);
|
||||
act(() => result.current.goForward());
|
||||
await waitFor(() => expect(result.current.browsePath).toBe("~/a"));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { getParentBrowsePath } from "@/components/AddProjectModal.parts";
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
isGitRepo: boolean;
|
||||
hasLocalConfig: boolean;
|
||||
modifiedAt?: number;
|
||||
}
|
||||
|
||||
export interface BrowseCurrentDirectory {
|
||||
isGitRepo: boolean;
|
||||
hasLocalConfig: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseRoot {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UseDirectoryBrowser {
|
||||
browsePath: string;
|
||||
selectedBrowsePath: string;
|
||||
setSelectedBrowsePath: (path: string) => void;
|
||||
directoryEntries: BrowseEntry[];
|
||||
currentDirectory: BrowseCurrentDirectory | null;
|
||||
roots: BrowseRoot[];
|
||||
selectedRootPath: string;
|
||||
locationInput: string;
|
||||
setLocationInput: (value: string) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
parentPath: string | null;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
browse: (
|
||||
path: string,
|
||||
options?: { mode?: "push" | "replace"; selectedPath?: string; historyIndex?: number },
|
||||
) => Promise<void>;
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
goUp: () => void;
|
||||
refresh: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface BrowseResponseBody {
|
||||
error?: string;
|
||||
entries?: BrowseEntry[];
|
||||
current?: BrowseCurrentDirectory;
|
||||
roots?: BrowseRoot[];
|
||||
}
|
||||
|
||||
const INITIAL_PATH = "~";
|
||||
|
||||
export function useDirectoryBrowser(): UseDirectoryBrowser {
|
||||
const [browsePath, setBrowsePath] = useState(INITIAL_PATH);
|
||||
const [selectedBrowsePath, setSelectedBrowsePath] = useState(INITIAL_PATH);
|
||||
const [browseHistory, setBrowseHistory] = useState<string[]>([INITIAL_PATH]);
|
||||
const [browseHistoryIndex, setBrowseHistoryIndex] = useState(0);
|
||||
const browseHistoryRef = useRef<string[]>([INITIAL_PATH]);
|
||||
const browseHistoryIndexRef = useRef(0);
|
||||
const browseRequestIdRef = useRef(0);
|
||||
const [browseEntries, setBrowseEntries] = useState<BrowseEntry[]>([]);
|
||||
const [currentDirectory, setCurrentDirectory] = useState<BrowseCurrentDirectory | null>(null);
|
||||
const [roots, setRoots] = useState<BrowseRoot[]>([]);
|
||||
const [locationInput, setLocationInput] = useState(INITIAL_PATH);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const syncHistory = useCallback((nextHistory: string[], nextIndex: number) => {
|
||||
browseHistoryRef.current = nextHistory;
|
||||
browseHistoryIndexRef.current = nextIndex;
|
||||
setBrowseHistory(nextHistory);
|
||||
setBrowseHistoryIndex(nextIndex);
|
||||
}, []);
|
||||
|
||||
const clearBrowseResults = useCallback((path: string, selectedPath?: string) => {
|
||||
setBrowseEntries([]);
|
||||
setCurrentDirectory(null);
|
||||
setRoots([]);
|
||||
setSelectedBrowsePath(selectedPath ?? path);
|
||||
}, []);
|
||||
|
||||
const browse = useCallback(
|
||||
async (
|
||||
path: string,
|
||||
options?: { mode?: "push" | "replace"; selectedPath?: string; historyIndex?: number },
|
||||
) => {
|
||||
const requestId = browseRequestIdRef.current + 1;
|
||||
browseRequestIdRef.current = requestId;
|
||||
const targetHistoryIndex = options?.historyIndex ?? browseHistoryIndexRef.current;
|
||||
setLocationInput(path);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?path=${encodeURIComponent(path)}`).catch(() => null);
|
||||
if (requestId !== browseRequestIdRef.current) return;
|
||||
if (!response) {
|
||||
clearBrowseResults(path, options?.selectedPath);
|
||||
setError("Failed to browse directories.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as BrowseResponseBody | null;
|
||||
if (requestId !== browseRequestIdRef.current) return;
|
||||
if (!response.ok) {
|
||||
clearBrowseResults(path, options?.selectedPath);
|
||||
setError(body?.error ?? "Failed to browse directories.");
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = options?.mode ?? "push";
|
||||
setBrowsePath(path);
|
||||
setLocationInput(path);
|
||||
setSelectedBrowsePath(options?.selectedPath ?? path);
|
||||
setBrowseEntries(body?.entries ?? []);
|
||||
setCurrentDirectory(body?.current ?? null);
|
||||
setRoots(body?.roots ?? []);
|
||||
|
||||
if (mode === "push") {
|
||||
const nextHistory = browseHistoryRef.current.slice(0, targetHistoryIndex + 1);
|
||||
if (nextHistory[nextHistory.length - 1] !== path) nextHistory.push(path);
|
||||
syncHistory(nextHistory, nextHistory.length - 1);
|
||||
} else {
|
||||
const nextHistory = [...browseHistoryRef.current];
|
||||
nextHistory[targetHistoryIndex] = path;
|
||||
syncHistory(nextHistory, targetHistoryIndex);
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== browseRequestIdRef.current) return;
|
||||
clearBrowseResults(path, options?.selectedPath);
|
||||
setError("Failed to browse directories.");
|
||||
} finally {
|
||||
if (requestId === browseRequestIdRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[clearBrowseResults, syncHistory],
|
||||
);
|
||||
|
||||
const navigateHistory = useCallback(
|
||||
(nextIndex: number) => {
|
||||
const history = browseHistoryRef.current;
|
||||
if (nextIndex < 0 || nextIndex >= history.length) return;
|
||||
browseHistoryIndexRef.current = nextIndex;
|
||||
setBrowseHistoryIndex(nextIndex);
|
||||
void browse(history[nextIndex] ?? INITIAL_PATH, { mode: "replace", historyIndex: nextIndex });
|
||||
},
|
||||
[browse],
|
||||
);
|
||||
|
||||
const directoryEntries = useMemo(() => browseEntries.filter((entry) => entry.isDirectory), [browseEntries]);
|
||||
const parentPath = getParentBrowsePath(browsePath);
|
||||
const selectedRootPath = roots.find((root) => browsePath.startsWith(root.path))?.path ?? "";
|
||||
const canGoBack = browseHistoryIndex > 0;
|
||||
const canGoForward = browseHistoryIndex < browseHistory.length - 1;
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
navigateHistory(browseHistoryIndexRef.current - 1);
|
||||
}, [navigateHistory]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
navigateHistory(browseHistoryIndexRef.current + 1);
|
||||
}, [navigateHistory]);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (!parentPath) return;
|
||||
void browse(parentPath);
|
||||
}, [browse, parentPath]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void browse(browsePath, { mode: "replace", selectedPath: selectedBrowsePath });
|
||||
}, [browse, browsePath, selectedBrowsePath]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setError(null);
|
||||
syncHistory([INITIAL_PATH], 0);
|
||||
setBrowsePath(INITIAL_PATH);
|
||||
setLocationInput(INITIAL_PATH);
|
||||
setSelectedBrowsePath(INITIAL_PATH);
|
||||
setBrowseEntries([]);
|
||||
setCurrentDirectory(null);
|
||||
setRoots([]);
|
||||
void browse(INITIAL_PATH, { mode: "replace", selectedPath: INITIAL_PATH, historyIndex: 0 });
|
||||
}, [browse, syncHistory]);
|
||||
|
||||
return {
|
||||
browsePath,
|
||||
selectedBrowsePath,
|
||||
setSelectedBrowsePath,
|
||||
directoryEntries,
|
||||
currentDirectory,
|
||||
roots,
|
||||
selectedRootPath,
|
||||
locationInput,
|
||||
setLocationInput,
|
||||
loading,
|
||||
error,
|
||||
parentPath,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
browse,
|
||||
goBack,
|
||||
goForward,
|
||||
goUp,
|
||||
refresh,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue