diff --git a/src/features/file-explorer/components/file-tree.tsx b/src/features/file-explorer/components/file-tree.tsx index 5f7f053b..92214b24 100644 --- a/src/features/file-explorer/components/file-tree.tsx +++ b/src/features/file-explorer/components/file-tree.tsx @@ -38,6 +38,7 @@ import { cn } from "@/utils/cn"; import { getRelativePath } from "@/utils/path-helpers"; import { IS_MAC } from "@/utils/platform"; import { useDragDrop } from "../hooks/use-drag-drop"; +import { buildVisibleFileTreeRows } from "../lib/visible-file-tree-rows"; import { FileTreeItem } from "./file-tree-item"; import "../styles/file-tree.css"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -60,8 +61,8 @@ interface FileTreeProps { activePath?: string; updateActivePath?: (path: string) => void; rootFolderPath?: string; - onFileSelect: (path: string, isDir: boolean) => void; - onFileOpen?: (path: string, isDir: boolean) => void; + onFileSelect: (path: string, isDir: boolean) => void | Promise; + onFileOpen?: (path: string, isDir: boolean) => void | Promise; onCreateNewFileInDirectory: (directoryPath: string, fileName: string) => void; onCreateNewFolderInDirectory?: (directoryPath: string, folderName: string) => void; onDeletePath?: (path: string, isDir: boolean) => void; @@ -291,18 +292,10 @@ function FileTreeComponent({ // Compute visible rows based on expansion state in the UI store const expandedPaths = useFileTreeStore((s) => s.expandedPaths); - const visibleRows = useMemo(() => { - const rows: Array<{ file: FileEntry; depth: number; isExpanded: boolean }> = []; - const walk = (items: FileEntry[], depth: number) => { - for (const item of items) { - const isExpanded = item.isDir && expandedPaths.has(item.path); - rows.push({ file: item, depth, isExpanded }); - if (item.isDir && isExpanded && item.children) walk(item.children, depth + 1); - } - }; - walk(filteredFiles, 0); - return rows; - }, [filteredFiles, expandedPaths]); + const visibleRows = useMemo( + () => buildVisibleFileTreeRows(filteredFiles, expandedPaths), + [filteredFiles, expandedPaths], + ); // Virtualizer setup const rowVirtualizer = useVirtualizer({ @@ -468,6 +461,13 @@ function FileTreeComponent({ return { path, isDir, file }; }; + const toggleDirectory = useCallback( + async (path: string) => { + await Promise.resolve(onFileSelect(path, true)); + }, + [onFileSelect], + ); + const handleContainerClick = useCallback( (e: React.MouseEvent) => { const t = getTargetItem(e.target); @@ -480,10 +480,14 @@ function FileTreeComponent({ } e.preventDefault(); e.stopPropagation(); - onFileSelect(t.path, t.isDir); + if (t.isDir) { + void toggleDirectory(t.path); + } else { + void Promise.resolve(onFileSelect(t.path, false)); + } updateActivePath?.(t.path); }, - [onFileSelect, updateActivePath, pathToFile], + [onFileSelect, toggleDirectory, updateActivePath, pathToFile], ); const handleContainerDoubleClick = useCallback( @@ -492,7 +496,7 @@ function FileTreeComponent({ if (!t) return; e.preventDefault(); e.stopPropagation(); - onFileOpen?.(t.path, t.isDir); + void Promise.resolve(onFileOpen?.(t.path, t.isDir)); updateActivePath?.(t.path); }, [onFileOpen, updateActivePath, pathToFile], @@ -704,8 +708,6 @@ function FileTreeComponent({ const current = visibleRows[curIndex]?.file; const isDir = visibleRows[curIndex]?.file.isDir; - const toggle = (path: string) => useFileTreeStore.getState().toggleFolder(path); - const mod = e.metaKey || e.ctrlKey; if (mod && current) { if (e.key === "c") { @@ -775,7 +777,7 @@ function FileTreeComponent({ if (isDir) { const expanded = useFileTreeStore.getState().isExpanded(current.path); if (!expanded) { - toggle(current.path); + void toggleDirectory(current.path); } else { const child = visibleRows[curIndex + 1]; if (child && child.depth === visibleRows[curIndex].depth + 1) { @@ -790,7 +792,7 @@ function FileTreeComponent({ if (!current) break; e.preventDefault(); if (isDir && useFileTreeStore.getState().isExpanded(current.path)) { - toggle(current.path); + void toggleDirectory(current.path); } else { const sep = current.path.includes("\\") ? "\\" : "/"; const parentPath = current.path.split(sep).slice(0, -1).join(sep); @@ -806,9 +808,9 @@ function FileTreeComponent({ if (!current) break; e.preventDefault(); if (isDir) { - toggle(current.path); + void toggleDirectory(current.path); } else { - onFileOpen?.(current.path, false); + void Promise.resolve(onFileOpen?.(current.path, false)); } break; } diff --git a/src/features/file-explorer/lib/visible-file-tree-rows.test.ts b/src/features/file-explorer/lib/visible-file-tree-rows.test.ts new file mode 100644 index 00000000..504c400c --- /dev/null +++ b/src/features/file-explorer/lib/visible-file-tree-rows.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { buildVisibleFileTreeRows } from "./visible-file-tree-rows"; + +const tree = [ + { + name: "root", + path: "/root", + isDir: true, + children: [ + { + name: "src", + path: "/root/src", + isDir: true, + children: [ + { + name: "features", + path: "/root/src/features", + isDir: true, + children: [ + { + name: "file-explorer", + path: "/root/src/features/file-explorer", + isDir: true, + children: [ + { + name: "file-tree.tsx", + path: "/root/src/features/file-explorer/file-tree.tsx", + isDir: false, + }, + ], + }, + ], + }, + ], + }, + ], + }, +]; + +describe("buildVisibleFileTreeRows", () => { + test("shows only the expanded root branch", () => { + const rows = buildVisibleFileTreeRows(tree, new Set(["/root"])); + + expect(rows.map((row) => row.file.path)).toEqual(["/root", "/root/src"]); + expect(rows.map((row) => row.depth)).toEqual([0, 1]); + }); + + test("shows third-level rows when parent folders are expanded", () => { + const rows = buildVisibleFileTreeRows( + tree, + new Set(["/root", "/root/src", "/root/src/features"]), + ); + + expect(rows.map((row) => row.file.path)).toEqual([ + "/root", + "/root/src", + "/root/src/features", + "/root/src/features/file-explorer", + ]); + expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 3]); + }); + + test("shows deeper descendants once every ancestor is expanded", () => { + const rows = buildVisibleFileTreeRows( + tree, + new Set(["/root", "/root/src", "/root/src/features", "/root/src/features/file-explorer"]), + ); + + expect(rows.map((row) => row.file.path)).toEqual([ + "/root", + "/root/src", + "/root/src/features", + "/root/src/features/file-explorer", + "/root/src/features/file-explorer/file-tree.tsx", + ]); + expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 3, 4]); + }); + + test("hides nested descendants when a middle folder collapses", () => { + const rows = buildVisibleFileTreeRows(tree, new Set(["/root", "/root/src"])); + + expect(rows.map((row) => row.file.path)).toEqual(["/root", "/root/src", "/root/src/features"]); + expect(rows.map((row) => row.depth)).toEqual([0, 1, 2]); + }); +}); diff --git a/src/features/file-explorer/lib/visible-file-tree-rows.ts b/src/features/file-explorer/lib/visible-file-tree-rows.ts new file mode 100644 index 00000000..31517fd0 --- /dev/null +++ b/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -0,0 +1,28 @@ +import type { FileEntry } from "@/features/file-system/types/app"; + +export interface VisibleFileTreeRow { + file: FileEntry; + depth: number; + isExpanded: boolean; +} + +export function buildVisibleFileTreeRows( + files: FileEntry[], + expandedPaths: ReadonlySet, +): VisibleFileTreeRow[] { + const rows: VisibleFileTreeRow[] = []; + + const walk = (items: FileEntry[], depth: number) => { + for (const item of items) { + const isExpanded = item.isDir && expandedPaths.has(item.path); + rows.push({ file: item, depth, isExpanded }); + + if (item.isDir && isExpanded && item.children) { + walk(item.children, depth + 1); + } + } + }; + + walk(files, 0); + return rows; +} diff --git a/src/features/file-explorer/types/index.ts b/src/features/file-explorer/types/index.ts index eecd3166..2aee434a 100644 --- a/src/features/file-explorer/types/index.ts +++ b/src/features/file-explorer/types/index.ts @@ -6,8 +6,8 @@ export interface FileTreeProps { activePath?: string; updateActivePath?: (path: string) => void; rootFolderPath?: string; - onFileSelect: (path: string, isDir: boolean) => void; - onFileOpen?: (path: string, isDir: boolean) => void; + onFileSelect: (path: string, isDir: boolean) => void | Promise; + onFileOpen?: (path: string, isDir: boolean) => void | Promise; onCreateNewFileInDirectory: (directoryPath: string, fileName: string) => void; onCreateNewFolderInDirectory?: (directoryPath: string, folderName: string) => void; onDeletePath?: (path: string, isDir: boolean) => void;