From 94ec79222d33be8251ee575d466e21cf01e931b1 Mon Sep 17 00:00:00 2001 From: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Date: Fri, 22 May 2026 15:43:42 +0530 Subject: [PATCH] refactor(web): extract useDirectoryBrowser hook --- .../__tests__/useDirectoryBrowser.test.ts | 82 +++++++ packages/web/src/hooks/useDirectoryBrowser.ts | 212 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 packages/web/src/hooks/__tests__/useDirectoryBrowser.test.ts create mode 100644 packages/web/src/hooks/useDirectoryBrowser.ts diff --git a/packages/web/src/hooks/__tests__/useDirectoryBrowser.test.ts b/packages/web/src/hooks/__tests__/useDirectoryBrowser.test.ts new file mode 100644 index 000000000..3bc8da418 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useDirectoryBrowser.test.ts @@ -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")); + }); +}); diff --git a/packages/web/src/hooks/useDirectoryBrowser.ts b/packages/web/src/hooks/useDirectoryBrowser.ts new file mode 100644 index 000000000..d4249b924 --- /dev/null +++ b/packages/web/src/hooks/useDirectoryBrowser.ts @@ -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; + 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([INITIAL_PATH]); + const [browseHistoryIndex, setBrowseHistoryIndex] = useState(0); + const browseHistoryRef = useRef([INITIAL_PATH]); + const browseHistoryIndexRef = useRef(0); + const browseRequestIdRef = useRef(0); + const [browseEntries, setBrowseEntries] = useState([]); + const [currentDirectory, setCurrentDirectory] = useState(null); + const [roots, setRoots] = useState([]); + const [locationInput, setLocationInput] = useState(INITIAL_PATH); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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, + }; +}