diff --git a/apps/web/src/components/files/FileBrowserPanel.test.tsx b/apps/web/src/components/files/FileBrowserPanel.test.tsx new file mode 100644 index 000000000000..e2b273b1aba6 --- /dev/null +++ b/apps/web/src/components/files/FileBrowserPanel.test.tsx @@ -0,0 +1,175 @@ +import { FileTree } from "@pierre/trees/react"; +import type { FileTree as FileTreeModel } from "@pierre/trees"; +import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { InputGroupInput } from "~/components/ui/input-group"; +import FileBrowserPanel from "./FileBrowserPanel"; + +const files = vi.hoisted(() => ({ + directoryEntries: [] as ProjectEntry[], + searchEntries: [] as ProjectEntry[], + searchPending: false, + searchError: null as string | null, + load: vi.fn(async () => {}), + refresh: vi.fn(), +})); + +vi.mock("./useDirectoryEntries", () => ({ + useDirectoryEntries: () => ({ + entries: files.directoryEntries, + load: files.load, + refresh: files.refresh, + ready: true, + error: null, + isPending: false, + }), +})); +vi.mock("~/state/queries", () => ({ + useProjectPathSearch: () => ({ + entries: files.searchEntries, + isPending: files.searchPending, + error: files.searchError, + truncated: false, + refresh: files.refresh, + }), +})); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("~/hooks/useWorkspaceMutationRefresh", () => ({ useWorkspaceMutationRefresh: vi.fn() })); +vi.mock("~/composerHandleContext", () => ({ useComposerHandleContext: () => null })); +vi.mock("~/fileContextMenu", () => ({ useFileContextMenu: () => ({}) })); +vi.mock("~/localApi", () => ({ readLocalApi: () => undefined })); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); +vi.mock("~/components/MorphIcon", () => ({ MorphIcon: () => null })); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: () => null, +})); + +describe("Files panel search", () => { + let renderer: ReactTestRenderer | undefined; + const props = { + environmentId: EnvironmentId.make("test-environment"), + cwd: "/workspace", + projectName: "Test project", + selectedPath: null, + selectedPathRevealId: 0, + onOpenFile: vi.fn(), + workspaceMutationId: null, + }; + + async function render() { + await act(async () => { + if (renderer) renderer.update(); + else renderer = create(); + }); + } + + async function search(value: string) { + await act(async () => { + renderer!.root.findByType(InputGroupInput).props.onChange({ target: { value } }); + }); + } + + function visiblePaths() { + const trees = renderer!.root.findAllByType(FileTree); + if (trees.length === 0) return []; + const model: FileTreeModel = trees[0]!.props.model; + return model.getVisibleRows(0, model.getVisibleCount()).map((row) => row.path); + } + + function hasMessage(message: string) { + return ( + renderer!.root.findAll((node) => node.type === "div" && node.props.children === message) + .length > 0 + ); + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", new EventTarget()); + files.directoryEntries = [ + { path: "src", kind: "directory" }, + { path: "src/app.ts", kind: "file" }, + { path: "package.json", kind: "file" }, + ]; + files.searchEntries = []; + files.searchPending = false; + files.searchError = null; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("shows no matching files instead of the full tree when a filename is absent", async () => { + await render(); + expect(visiblePaths()).toEqual(["src/", "package.json"]); + + await search(".env.local"); + expect(visiblePaths()).toEqual([]); + expect(hasMessage("No matching files.")).toBe(true); + + await search("package"); + expect(visiblePaths()).toEqual(["package.json"]); + expect(hasMessage("No matching files.")).toBe(false); + + await search(""); + expect(visiblePaths()).toEqual(["src/", "package.json"]); + }); + + it("still finds ignored files already loaded from a directory", async () => { + files.directoryEntries.push({ path: ".env.local", kind: "file", ignored: true }); + await render(); + await search(".env.local"); + expect(visiblePaths()).toEqual([".env.local"]); + expect(hasMessage("No matching files.")).toBe(false); + }); + + it("waits for remote matches without showing unrelated files or a premature empty result", async () => { + await render(); + files.searchPending = true; + await search("settings.ts"); + expect(visiblePaths()).toEqual([]); + expect(hasMessage("Loading files…")).toBe(true); + expect(hasMessage("No matching files.")).toBe(false); + + files.searchEntries = [{ path: "config/settings.ts", kind: "file" }]; + files.searchPending = false; + await render(); + expect(visiblePaths()).toEqual(["config/", "config/settings.ts"]); + expect(hasMessage("No matching files.")).toBe(false); + }); + + it("restores the tree on Escape after an unsuccessful search", async () => { + await render(); + await search("missing-file"); + expect(visiblePaths()).toEqual([]); + + await act(async () => { + renderer!.root.findByType(InputGroupInput).props.onKeyDown({ + key: "Escape", + currentTarget: { blur: vi.fn() }, + }); + }); + expect(visiblePaths()).toEqual(["src/", "package.json"]); + expect(hasMessage("No matching files.")).toBe(false); + }); + + it("does not report an empty result when the search failed", async () => { + await render(); + files.searchError = "Workspace search failed."; + await search("missing-file"); + expect(visiblePaths()).toEqual([]); + expect(hasMessage("No matching files.")).toBe(false); + expect(JSON.stringify(renderer!.toJSON())).toContain("Workspace search failed."); + }); +}); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ad134abf8904..f94186880c36 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -288,6 +288,8 @@ export default function FileBrowserPanel({ unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); + // Pierre restores the unfiltered tree when a search has no matches. + const hasNoSearchMatches = search.value.trim().length > 0 && search.matchingPaths.length === 0; const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => areAllDirectoriesExpanded(currentModel, directoryPaths), ); @@ -547,12 +549,20 @@ export default function FileBrowserPanel({ Loading files… )} - + {hasNoSearchMatches ? ( + ready && !isPending && !pathSearch.isPending && !error && !pathSearch.error ? ( +
+ No matching files. +
+ ) : null + ) : ( + + )} ); }