diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx index 9955cfb8..7ec25084 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx @@ -108,7 +108,7 @@ interface FileExplorerTreeProps { activePath?: string; updateActivePath?: (path: string) => void; rootFolderPath?: string; - onFileSelect: (path: string, isDir: boolean) => void | Promise; + onFileSelect: (path: string, isDir: boolean) => void | string | Promise; onFileOpen?: (path: string, isDir: boolean) => void | Promise; onCreateNewFileInDirectory: ( directoryPath: string, @@ -958,7 +958,8 @@ function FileExplorerTreeComponent({ const toggleDirectory = useCallback( async (path: string) => { - await Promise.resolve(onFileSelect(path, true)); + const resolvedPath = await Promise.resolve(onFileSelect(path, true)); + return resolvedPath ?? path; }, [onFileSelect], ); @@ -980,9 +981,12 @@ function FileExplorerTreeComponent({ fileOpenBenchmark.mark(t.path, "explorer-click"); } if (t.isDir) { - void toggleDirectory(t.path); setFocusedPath(t.path); updateActivePath?.(t.path); + void toggleDirectory(t.path).then((resolvedPath) => { + setFocusedPath(resolvedPath); + updateActivePath?.(resolvedPath); + }); } else { setFocusedPath(t.path); void Promise.resolve(onFileSelect(t.path, false)); @@ -1201,7 +1205,7 @@ function FileExplorerTreeComponent({ if (isDir) { const expanded = useFileTreeStore.getState().actions.isExpanded(current.path); if (!expanded) { - void toggleDirectory(current.path); + void toggleDirectory(current.path).then(setFocusedPath); } else { const child = visibleRows[curIndex + 1]; if (child && child.depth === visibleRows[curIndex].depth + 1) { @@ -1232,7 +1236,7 @@ function FileExplorerTreeComponent({ if (!current) break; e.preventDefault(); if (isDir) { - void toggleDirectory(current.path); + void toggleDirectory(current.path).then(setFocusedPath); } else { void Promise.resolve(onFileOpen?.(current.path, false)); } diff --git a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx index 89ca8b82..dcabf7d6 100644 --- a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx +++ b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx @@ -43,7 +43,7 @@ import { getBaseName, getDirName, getRelativePath, joinPath } from "@/utils/path interface UseFileExplorerContextMenuOptions { rootFolderPath?: string; - onFileSelect: (path: string, isDir: boolean) => void | Promise; + onFileSelect: (path: string, isDir: boolean) => void | string | Promise; onCreateNewFileInDirectory?: ( directoryPath: string, fileName: string, diff --git a/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts b/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts index 130e5344..18ec6014 100644 --- a/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts +++ b/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -1,4 +1,5 @@ import type { FileEntry } from "@/features/file-system/types/app.types"; +import { getCompactFolderChild } from "@/features/file-system/controllers/file-tree-utils"; import type { FileTreeSortOrder } from "@/features/settings/types/settings.types"; import { getBaseName, getRelativePath, joinPath, pathStartsWithRoot } from "@/utils/path-helpers"; @@ -116,23 +117,6 @@ export function collectFileTreeSearchHits( return hits; } -function getCompactFolderChild(item: FileEntry): FileEntry | null { - if (!item.isDir || item.isEditing || item.isRenaming || item.isNewItem || !item.children) { - return null; - } - - if (item.children.length !== 1) { - return null; - } - - const child = item.children[0]; - if (!child.isDir || child.isEditing || child.isRenaming || child.isNewItem) { - return null; - } - - return child; -} - function sortFileTreeEntriesForDisplay( entries: readonly FileEntry[], sortOrder: FileTreeSortOrder, diff --git a/windows/tauri/src/features/file-system/controllers/file-tree-utils.test.ts b/windows/tauri/src/features/file-system/controllers/file-tree-utils.test.ts new file mode 100644 index 00000000..a2533aa5 --- /dev/null +++ b/windows/tauri/src/features/file-system/controllers/file-tree-utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import type { FileEntry } from "../types/app.types"; +import { loadFolderExpansion } from "./file-tree-utils"; + +const directory = (name: string, path: string, children?: FileEntry[]): FileEntry => ({ + name, + path, + isDir: true, + children, +}); + +describe("compact folder expansion", () => { + test("loads and expands a single-child directory chain in one action", async () => { + const entries = new Map([ + ["/a", [directory("b", "/a/b")]], + ["/a/b", [directory("c", "/a/b/c")]], + ["/a/b/c", [{ name: "file.ts", path: "/a/b/c/file.ts", isDir: false }]], + ]); + + const result = await loadFolderExpansion( + [directory("a", "/a")], + "/a", + true, + async (path) => entries.get(path) ?? [], + ); + + expect(result.expandedPaths).toEqual(["/a", "/a/b", "/a/b/c"]); + expect(result.finalPath).toBe("/a/b/c"); + }); + + test("stops at a branch and keeps non-compact expansion to one level", async () => { + const entries = new Map([ + ["/a", [directory("b", "/a/b")]], + ["/a/b", [directory("c", "/a/b/c"), directory("d", "/a/b/d")]], + ]); + const readChildren = async (path: string) => entries.get(path) ?? []; + + const compact = await loadFolderExpansion([directory("a", "/a")], "/a", true, readChildren); + const regular = await loadFolderExpansion([directory("a", "/a")], "/a", false, readChildren); + + expect(compact.expandedPaths).toEqual(["/a", "/a/b"]); + expect(regular.expandedPaths).toEqual(["/a"]); + }); +}); diff --git a/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts b/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts index 991c2a84..1c0b520e 100644 --- a/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts +++ b/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts @@ -52,6 +52,57 @@ export function updateFileInTree( return changed ? updatedFiles : files; } +export function getCompactFolderChild(item: FileEntry): FileEntry | null { + if (!item.isDir || item.isEditing || item.isRenaming || item.isNewItem || !item.children) { + return null; + } + + if (item.children.length !== 1) { + return null; + } + + const child = item.children[0]; + return child.isDir && !child.isEditing && !child.isRenaming && !child.isNewItem ? child : null; +} + +export async function loadFolderExpansion( + files: FileEntry[], + startPath: string, + compactFolders: boolean, + readChildren: (path: string) => Promise, +) { + const expandedPaths: string[] = []; + const loadedChildren = new Map(); + const visitedPaths = new Set(); + let nextFiles = files; + let currentPath = startPath; + + while (true) { + if (visitedPaths.has(currentPath)) break; + visitedPaths.add(currentPath); + const folder = findFileInTree(nextFiles, currentPath); + if (!folder?.isDir) break; + + let children = folder.children; + if (!children || children.length === 0) { + children = await readChildren(currentPath); + loadedChildren.set(currentPath, children); + nextFiles = updateFileInTree(nextFiles, currentPath, (item) => ({ ...item, children })); + } + + expandedPaths.push(currentPath); + const child = compactFolders ? getCompactFolderChild({ ...folder, children }) : null; + if (!child) break; + currentPath = child.path; + } + + return { + expandedPaths, + finalPath: expandedPaths[expandedPaths.length - 1] ?? startPath, + loadedChildren, + }; +} + export function removeFileFromTree(files: FileEntry[], targetPath: string): FileEntry[] { let changed = false; const nextFiles: FileEntry[] = []; diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index c9161fac..015738a9 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -83,6 +83,7 @@ import { import { addFileToTree, findFileInTree, + loadFolderExpansion, removeFileFromTree, sortFileEntries, updateFileInTree, @@ -1482,8 +1483,7 @@ const createFileSystemStore = (workspaceId: string): StoreApi { if (isDir) { - await get().toggleFolder(path); - return; + return get().toggleFolder(path); } const selectedWslInfo = parseWslPath(path); @@ -1919,31 +1919,42 @@ const createFileSystemStore = (workspaceId: string): StoreApi ({ - ...item, - children: childEntries, - })); + const expansion = await loadFolderExpansion( + get().files, + path, + useSettingsStore.getState().settings.compactFoldersInFileTree, + (directoryPath) => + readProviderDirectoryEntries(directoryPath, get().rootFolderPath ?? directoryPath), + ); + if (expansion.loadedChildren.size > 0) { set((state) => { - state.files = updatedFiles; - state.filesVersion++; + let updatedFiles = state.files; + for (const [directoryPath, children] of expansion.loadedChildren) { + updatedFiles = updateFileInTree(updatedFiles, directoryPath, (item) => ({ + ...item, + children, + })); + } + if (updatedFiles !== state.files) { + state.files = updatedFiles; + state.filesVersion++; + } }); } - uiActions.toggleFolder(path); + + const expandedPaths = new Set(uiActions.getExpandedPaths()); + expansion.expandedPaths.forEach((expandedPath) => expandedPaths.add(expandedPath)); + uiActions.setExpandedPaths(expandedPaths); // Preload deeper children in background for snappier navigation get() - .preloadSubtree(path, 2, 80) + .preloadSubtree(expansion.finalPath, 2, 80) .catch(() => {}); + return expansion.finalPath; } else { // Collapse: only toggle UI state; keep children cached uiActions.toggleFolder(path); + return path; } }, diff --git a/windows/tauri/src/features/file-system/types/interface.types.ts b/windows/tauri/src/features/file-system/types/interface.types.ts index d2fb28bc..05cafaeb 100644 --- a/windows/tauri/src/features/file-system/types/interface.types.ts +++ b/windows/tauri/src/features/file-system/types/interface.types.ts @@ -45,9 +45,9 @@ export interface FsActions { column?: number, codeEditorRef?: React.RefObject, isPreview?: boolean, - ) => Promise; + ) => Promise; handleFileOpen: (path: string, isDir: boolean) => Promise; - toggleFolder: (path: string) => Promise; + toggleFolder: (path: string) => Promise; revealPathInTree: (targetPath: string) => Promise; handleCreateNewFile: () => Promise; handleCreateNewFileInDirectory: (