From 3ce25b83749f4e8f5ed76b943733a0c4e544cf39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Sun, 17 May 2026 16:20:02 -0600 Subject: [PATCH] feat(file-explorer): add folder collapse actions --- .../right-sidebar/FileExplorer.test.tsx | 149 +++++++++++++++++- .../components/right-sidebar/FileExplorer.tsx | 27 +++- .../right-sidebar/FileExplorerRow.tsx | 14 ++ .../right-sidebar/FileExplorerToolbar.tsx | 26 ++- .../right-sidebar/FileExplorerVirtualRows.tsx | 3 + src/renderer/src/store/slices/editor.test.ts | 40 +++++ src/renderer/src/store/slices/editor.ts | 30 ++++ 7 files changed, 282 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 80f07e7fa29..97e5fdf949e 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { Loader2, RefreshCw } from 'lucide-react' +import { ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { FileExplorerToolbar } from './FileExplorerToolbar' +import { FileExplorerRow, shouldShowCollapseFolderAction } from './FileExplorerRow' +import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' +import type { TreeNode } from './file-explorer-types' type ReactElementLike = { type: unknown @@ -36,6 +39,32 @@ function findRefreshButton(node: unknown): ReactElementLike { return found } +function findCollapseAllButton(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === 'Collapse All') { + found = entry + } + }) + if (!found) { + throw new Error('collapse all button not found') + } + return found +} + +function findFileExplorerRow(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === FileExplorerRow) { + found = entry + } + }) + if (!found) { + throw new Error('file explorer row not found') + } + return found +} + function findRepoNameLabel(node: unknown, repoName: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -79,7 +108,9 @@ describe('FileExplorerToolbar', () => { const onRefresh = vi.fn() const element = FileExplorerToolbar({ repoName: 'orca', - refresh: makeRefreshState({ handleRefresh: onRefresh }) + refresh: makeRefreshState({ handleRefresh: onRefresh }), + canCollapseAll: false, + onCollapseAll: vi.fn() }) const button = findRefreshButton(element) @@ -95,7 +126,9 @@ describe('FileExplorerToolbar', () => { const repoName = 'really-long-repo-name-that-should-not-push-refresh-offscreen' const element = FileExplorerToolbar({ repoName, - refresh: makeRefreshState() + refresh: makeRefreshState(), + canCollapseAll: false, + onCollapseAll: vi.fn() }) const label = findRepoNameLabel(element, repoName) @@ -108,7 +141,9 @@ describe('FileExplorerToolbar', () => { it('disables the refresh button and shows a spinner while refreshing', () => { const element = FileExplorerToolbar({ repoName: 'orca', - refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true }) + refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true }), + canCollapseAll: false, + onCollapseAll: vi.fn() }) const button = findRefreshButton(element) @@ -117,4 +152,110 @@ describe('FileExplorerToolbar', () => { expect(hasIcon(button, Loader2)).toBe(true) expect(hasIcon(button, RefreshCw)).toBe(false) }) + + it('fires the collapse all action from the icon button', () => { + const onCollapseAll = vi.fn() + const element = FileExplorerToolbar({ + repoName: 'orca', + refresh: makeRefreshState(), + canCollapseAll: true, + onCollapseAll + }) + + const button = findCollapseAllButton(element) + ;(button.props.onClick as () => void)() + + expect(onCollapseAll).toHaveBeenCalledTimes(1) + expect(button.props.disabled).toBe(false) + expect(hasIcon(button, ListCollapse)).toBe(true) + }) + + it('disables collapse all when no directories are expanded', () => { + const element = FileExplorerToolbar({ + repoName: 'orca', + refresh: makeRefreshState(), + canCollapseAll: false, + onCollapseAll: vi.fn() + }) + + const button = findCollapseAllButton(element) + + expect(button.props.disabled).toBe(true) + expect(hasIcon(button, ListCollapse)).toBe(true) + }) +}) + +describe('FileExplorerRow collapse folder action', () => { + const directoryNode: TreeNode = { + name: 'src', + path: '/repo/src', + relativePath: 'src', + isDirectory: true, + depth: 0 + } + + it('only shows collapse folder for expanded directories', () => { + expect(shouldShowCollapseFolderAction(directoryNode, true)).toBe(true) + expect(shouldShowCollapseFolderAction(directoryNode, false)).toBe(false) + expect( + shouldShowCollapseFolderAction( + { + ...directoryNode, + name: 'index.ts', + path: '/repo/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false + }, + true + ) + ).toBe(false) + }) + + it('passes the row node to the collapse folder handler', () => { + const onCollapseFolderSubtree = vi.fn() + const element = FileExplorerVirtualRows({ + virtualizer: { + getTotalSize: () => 26, + getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }], + measureElement: vi.fn() + } as never, + inlineInputIndex: -1, + flatRows: [directoryNode], + inlineInput: null, + handleInlineSubmit: vi.fn(), + dismissInlineInput: vi.fn(), + folderStatusByRelativePath: new Map(), + statusByRelativePath: new Map(), + ignoredByRelativePath: new Set(), + expanded: new Set([directoryNode.path]), + dirCache: {}, + selectedPaths: new Set(), + activeFileId: null, + flashingPath: null, + deleteShortcutLabel: 'Del', + onClick: vi.fn(), + onDoubleClick: vi.fn(), + onContextMenuSelect: vi.fn(), + onCopyPaths: vi.fn(), + onStartNew: vi.fn(), + onStartRename: vi.fn(), + onDuplicate: vi.fn(), + onRequestDelete: vi.fn(), + onCollapseFolderSubtree, + onMoveDrop: vi.fn(), + onDragTargetChange: vi.fn(), + onDragSourceChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + dropTargetDir: null, + dragSourcePath: null, + nativeDropTargetDir: null + }) + + const row = findFileExplorerRow(element) + ;(row.props.onCollapseFolderSubtree as () => void)() + + expect(onCollapseFolderSubtree).toHaveBeenCalledWith(directoryNode) + }) }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 3a0dcef4989..9facefccc99 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- File Explorer coordinates tree state, selection, drag/drop, and toolbar actions in one component. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' @@ -32,6 +33,8 @@ function FileExplorerInner(): React.JSX.Element { const activeRepo = useRepoById(activeWorktree?.repoId ?? null) const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) const expandedDirs = useAppStore((s) => s.expandedDirs) + const collapseAllDirs = useAppStore((s) => s.collapseAllDirs) + const collapseDirSubtree = useAppStore((s) => s.collapseDirSubtree) const toggleDir = useAppStore((s) => s.toggleDir) const pendingExplorerReveal = useAppStore((s) => s.pendingExplorerReveal) const clearPendingExplorerReveal = useAppStore((s) => s.clearPendingExplorerReveal) @@ -65,6 +68,13 @@ function FileExplorerInner(): React.JSX.Element { resetAndLoad } = useFileExplorerTree(worktreePath, expanded, activeWorktreeId) const manualRefresh = useFileExplorerManualRefresh(refreshTree) + const canCollapseAll = expanded.size > 0 + const handleCollapseAll = useCallback(() => { + if (!activeWorktreeId) { + return + } + collapseAllDirs(activeWorktreeId) + }, [activeWorktreeId, collapseAllDirs]) const [flashingPath, setFlashingPath] = useState(null) const [bgMenuOpen, setBgMenuOpen] = useState(false) @@ -296,6 +306,15 @@ function FileExplorerInner(): React.JSX.Element { selectRowWithModifiers(node, event, handleClick), [handleClick, selectRowWithModifiers] ) + const handleCollapseFolderSubtree = useCallback( + (node: (typeof flatRows)[number]) => { + if (!activeWorktreeId || !node.isDirectory) { + return + } + collapseDirSubtree(activeWorktreeId, node.path) + }, + [activeWorktreeId, collapseDirSubtree] + ) if (!worktreePath) { return ( @@ -318,7 +337,12 @@ function FileExplorerInner(): React.JSX.Element { return ( <>
- + void onDuplicate: (node: TreeNode) => void onRequestDelete: () => void + onCollapseFolderSubtree: () => void onMoveDrop: (sourcePath: string, destDir: string) => void onDragTargetChange: (dir: string | null) => void onDragSourceChange: (path: string | null) => void @@ -220,6 +223,10 @@ type FileExplorerRowProps = { onNativeDragExpandDir: (dirPath: string) => void } +export function shouldShowCollapseFolderAction(node: TreeNode, isExpanded: boolean): boolean { + return node.isDirectory && isExpanded +} + export function FileExplorerRow({ node, isExpanded, @@ -241,6 +248,7 @@ export function FileExplorerRow({ onStartRename, onDuplicate, onRequestDelete, + onCollapseFolderSubtree, onMoveDrop, onDragTargetChange, onDragSourceChange, @@ -397,6 +405,12 @@ export function FileExplorerRow({ Open Markdown Preview )} + {shouldShowCollapseFolderAction(node, isExpanded) && ( + + + Collapse Folder + + )} { const state = useAppStore.getState() diff --git a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx index 5f94ade1c90..417e78181ed 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Loader2, RefreshCw } from 'lucide-react' +import { ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -10,11 +10,15 @@ type FileExplorerToolbarProps = { showRefreshSpinner: boolean handleRefresh: () => void } + canCollapseAll: boolean + onCollapseAll: () => void } export function FileExplorerToolbar({ repoName, - refresh + refresh, + canCollapseAll, + onCollapseAll }: FileExplorerToolbarProps): React.JSX.Element { return (
@@ -24,6 +28,24 @@ export function FileExplorerToolbar({ > {repoName} + + + + + + Collapse All + +