feat(file-explorer): add folder collapse actions

This commit is contained in:
Leynier Gutiérrez González
2026-05-17 18:20:02 -04:00
committed by GitHub
parent a99929e42e
commit 3ce25b8374
7 changed files with 282 additions and 7 deletions
@@ -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)
})
})
@@ -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<string | null>(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 (
<>
<div ref={explorerShellRef} data-orca-explorer-shell className="flex h-full min-h-0 flex-col">
<FileExplorerToolbar repoName={repoName} refresh={manualRefresh} />
<FileExplorerToolbar
repoName={repoName}
refresh={manualRefresh}
canCollapseAll={canCollapseAll}
onCollapseAll={handleCollapseAll}
/>
<ScrollArea
className={cn(
'min-h-0 flex-1',
@@ -393,6 +417,7 @@ function FileExplorerInner(): React.JSX.Element {
onStartRename={startRename}
onDuplicate={handleDuplicate}
onRequestDelete={requestDelete}
onCollapseFolderSubtree={handleCollapseFolderSubtree}
onMoveDrop={handleMoveDrop}
onDragTargetChange={setDropTargetDir}
onDragSourceChange={setDragSourcePath}
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- File Explorer rows own dense context-menu and drag/drop interactions. */
import React, { useCallback, useEffect, useRef } from 'react'
import {
ChevronRight,
@@ -11,6 +12,7 @@ import {
Folder,
FolderOpen,
FolderPlus,
ListCollapse,
Loader2,
Pencil,
Trash2
@@ -212,6 +214,7 @@ type FileExplorerRowProps = {
onStartRename: (node: TreeNode) => 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
</ContextMenuItem>
)}
{shouldShowCollapseFolderAction(node, isExpanded) && (
<ContextMenuItem onSelect={onCollapseFolderSubtree}>
<ListCollapse />
Collapse Folder
</ContextMenuItem>
)}
<ContextMenuItem
onSelect={() => {
const state = useAppStore.getState()
@@ -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 (
<div className="flex h-8 min-h-8 items-center gap-2 border-b border-border px-2">
@@ -24,6 +28,24 @@ export function FileExplorerToolbar({
>
{repoName}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label="Collapse All"
disabled={!canCollapseAll}
onClick={onCollapseAll}
>
<ListCollapse className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
Collapse All
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -32,6 +32,7 @@ type FileExplorerVirtualRowsProps = {
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
onRequestDelete: (node: TreeNode) => void
onCollapseFolderSubtree: (node: TreeNode) => void
onMoveDrop: (sourcePath: string, destDir: string) => void
onDragTargetChange: (dir: string | null) => void
onDragSourceChange: (path: string | null) => void
@@ -68,6 +69,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
onStartRename,
onDuplicate,
onRequestDelete,
onCollapseFolderSubtree,
onMoveDrop,
onDragTargetChange,
onDragSourceChange,
@@ -165,6 +167,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
onStartRename={onStartRename}
onDuplicate={onDuplicate}
onRequestDelete={() => onRequestDelete(n)}
onCollapseFolderSubtree={() => onCollapseFolderSubtree(n)}
onMoveDrop={onMoveDrop}
onDragTargetChange={onDragTargetChange}
onDragSourceChange={onDragSourceChange}
@@ -63,6 +63,46 @@ describe('createEditorSlice right sidebar state', () => {
store.getState().toggleRightSidebar()
expect(store.getState().rightSidebarOpen).toBe(false)
})
it('collapses all expanded directories for one worktree', () => {
const store = createEditorStore()
store.setState({
expandedDirs: {
'wt-1': new Set(['/repo/src', '/repo/src/components']),
'wt-2': new Set(['/other/packages'])
}
})
store.getState().collapseAllDirs('wt-1')
expect(store.getState().expandedDirs['wt-1']).toEqual(new Set())
expect(store.getState().expandedDirs['wt-2']).toEqual(new Set(['/other/packages']))
})
it('keeps collapse all stable when the worktree has no expanded directories', () => {
const store = createEditorStore()
const expandedDirs = { 'wt-2': new Set(['/other/packages']) }
store.setState({ expandedDirs })
store.getState().collapseAllDirs('wt-1')
expect(store.getState().expandedDirs).toBe(expandedDirs)
})
it('collapses one directory subtree without touching sibling directories', () => {
const store = createEditorStore()
store.setState({
expandedDirs: {
'wt-1': new Set(['/repo/src', '/repo/src/components', '/repo/src2', '/repo/tests']),
'wt-2': new Set(['/other/src'])
}
})
store.getState().collapseDirSubtree('wt-1', '/repo/src')
expect(store.getState().expandedDirs['wt-1']).toEqual(new Set(['/repo/src2', '/repo/tests']))
expect(store.getState().expandedDirs['wt-2']).toEqual(new Set(['/other/src']))
})
})
describe('createEditorSlice file search seed state', () => {
+30
View File
@@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import { joinPath } from '@/lib/path'
import { toast } from 'sonner'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal-links'
import { openHttpLink } from '@/lib/http-link-routing'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
@@ -239,6 +240,8 @@ export type EditorSlice = {
// File explorer state
expandedDirs: Record<string, Set<string>> // worktreeId -> set of expanded dir paths
collapseAllDirs: (worktreeId: string) => void
collapseDirSubtree: (worktreeId: string, dirPath: string) => void
toggleDir: (worktreeId: string, dirPath: string) => void
pendingExplorerReveal: {
worktreeId: string
@@ -705,6 +708,33 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// File explorer
expandedDirs: {},
collapseAllDirs: (worktreeId) =>
set((s) => {
const current = s.expandedDirs[worktreeId]
if (!current?.size) {
return s
}
return {
expandedDirs: {
...s.expandedDirs,
[worktreeId]: new Set<string>()
}
}
}),
collapseDirSubtree: (worktreeId, dirPath) =>
set((s) => {
const current = s.expandedDirs[worktreeId]
if (!current?.size) {
return s
}
const next = new Set(
Array.from(current).filter((expandedDir) => !isPathInsideOrEqual(dirPath, expandedDir))
)
if (next.size === current.size) {
return s
}
return { expandedDirs: { ...s.expandedDirs, [worktreeId]: next } }
}),
toggleDir: (worktreeId, dirPath) =>
set((s) => {
const current = s.expandedDirs[worktreeId] ?? new Set<string>()