mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(file-explorer): add folder collapse actions
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user