Merge pull request #76 from multica-ai/feat/file-list-enhance

feat: add hidden files toggle and improve file tree UX
This commit is contained in:
LinYushen
2026-01-21 15:54:32 +08:00
committed by GitHub
6 changed files with 325 additions and 38 deletions

View File

@@ -15,7 +15,7 @@ import { RightPanel, RightPanelHeader, RightPanelContent } from './components/la
import { FileTree } from './components/FileTree'
import { Toaster } from '@/components/ui/sonner'
import { useChatScroll } from './hooks/useChatScroll'
import { ChevronDown } from 'lucide-react'
import { ChevronDown, Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils'
function AppContent(): React.JSX.Element {
@@ -46,6 +46,8 @@ function AppContent(): React.JSX.Element {
// UI state
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
const showHiddenFiles = useUIStore((s) => s.showHiddenFiles)
const toggleShowHiddenFiles = useUIStore((s) => s.toggleShowHiddenFiles)
// Permission state - get the session ID that has a pending permission request
const pendingPermission = usePermissionStore((s) => s.pendingRequests[0] ?? null)
@@ -218,14 +220,31 @@ function AppContent(): React.JSX.Element {
{/* Right panel - file tree */}
<RightPanel>
<RightPanelHeader>
<RightPanelHeader className="justify-between">
<span className="text-sm font-medium">All files</span>
<button
onClick={toggleShowHiddenFiles}
className={cn(
'p-1 rounded hover:bg-accent transition-colors',
showHiddenFiles ? 'text-foreground' : 'text-muted-foreground'
)}
title={showHiddenFiles ? 'Hide hidden files' : 'Show hidden files'}
aria-label={showHiddenFiles ? 'Hide hidden files' : 'Show hidden files'}
aria-pressed={showHiddenFiles}
>
{showHiddenFiles ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</button>
</RightPanelHeader>
<RightPanelContent className="p-0">
{currentSession ? (
<FileTree
rootPath={currentSession.workingDirectory}
directoryExists={currentSession.directoryExists}
onCreateSession={handleCreateSession}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground p-4">

View File

@@ -2,7 +2,7 @@
* FileTree component for displaying directory structure
* With lazy loading and context menu support
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
ChevronRightIcon,
FolderIcon,
@@ -10,7 +10,12 @@ import {
FileIcon,
ImageIcon,
LockIcon,
FolderX
FolderX,
Terminal,
Code2,
Hammer,
Copy,
AppWindowMac
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -36,10 +41,13 @@ import {
} from './FileIcons'
import type { FileTreeNode, DetectedApp } from '../../../shared/electron-api'
import { useFileChangeStore } from '../stores/fileChangeStore'
import { useUIStore } from '../stores/uiStore'
import { excludeHiddenFiles } from '../utils/fileTree'
interface FileTreeProps {
rootPath: string
directoryExists?: boolean
onCreateSession?: (cwd: string) => void
}
interface TreeItemProps {
@@ -47,9 +55,10 @@ interface TreeItemProps {
level: number
expandedPaths: Set<string>
onToggle: (path: string) => void
childrenCache: Map<string, FileTreeNode[]>
getChildren: (path: string) => FileTreeNode[] | undefined
loadChildren: (path: string) => Promise<void>
availableApps: DetectedApp[]
onCreateSession?: (cwd: string) => void
}
// Get the appropriate icon for a file/directory
@@ -121,13 +130,14 @@ function TreeItem({
level,
expandedPaths,
onToggle,
childrenCache,
getChildren,
loadChildren,
availableApps
availableApps,
onCreateSession
}: TreeItemProps): React.JSX.Element {
const isExpanded = expandedPaths.has(node.path)
const isDirectory = node.type === 'directory'
const children = childrenCache.get(node.path)
const children = getChildren(node.path)
const [isLoading, setIsLoading] = useState(false)
const [hasError, setHasError] = useState(false)
@@ -214,29 +224,41 @@ function TreeItem({
{finderApp && (
<ContextMenuItem onClick={() => handleOpenWith('finder')}>
<span className="flex items-center gap-2">
<span>📁</span>
<FolderOpenIcon className="h-4 w-4" />
<span>Finder</span>
</span>
<span className="ml-auto text-xs text-muted-foreground">O</span>
</ContextMenuItem>
)}
{/* Editors */}
{editorApps.length > 0 && (
{/* Apps (Multica + Editors) */}
{(isDirectory && onCreateSession) || editorApps.length > 0 ? (
<>
<ContextMenuSeparator />
{/* Multica - only for directories */}
{isDirectory && onCreateSession && (
<ContextMenuItem onClick={() => onCreateSession(node.path)}>
<span className="flex items-center gap-2">
<AppWindowMac className="h-4 w-4" />
<span>Multica</span>
</span>
</ContextMenuItem>
)}
{/* Editors */}
{editorApps.map((app) => (
<ContextMenuItem key={app.id} onClick={() => handleOpenWith(app.id)}>
<span className="flex items-center gap-2">
{app.id === 'cursor' && <span>🔵</span>}
{app.id === 'vscode' && <span>🔷</span>}
{app.id === 'xcode' && <span>🔧</span>}
{app.id === 'xcode' ? (
<Hammer className="h-4 w-4" />
) : (
<Code2 className="h-4 w-4" />
)}
<span>{app.name}</span>
</span>
</ContextMenuItem>
))}
</>
)}
) : null}
{/* Terminals */}
{terminalApps.length > 0 && (
@@ -245,9 +267,7 @@ function TreeItem({
{terminalApps.map((app) => (
<ContextMenuItem key={app.id} onClick={() => handleOpenWith(app.id)}>
<span className="flex items-center gap-2">
{app.id === 'ghostty' && <span>👻</span>}
{app.id === 'iterm' && <span>📟</span>}
{app.id === 'terminal' && <span></span>}
<Terminal className="h-4 w-4" />
<span>{app.name}</span>
</span>
</ContextMenuItem>
@@ -261,7 +281,7 @@ function TreeItem({
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleOpenWith('copy-path')}>
<span className="flex items-center gap-2">
<span>📋</span>
<Copy className="h-4 w-4" />
<span>Copy path</span>
</span>
</ContextMenuItem>
@@ -288,9 +308,10 @@ function TreeItem({
level={level + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
childrenCache={childrenCache}
getChildren={getChildren}
loadChildren={loadChildren}
availableApps={availableApps}
onCreateSession={onCreateSession}
/>
))
)}
@@ -312,14 +333,34 @@ function DirectoryNotFound(): React.JSX.Element {
)
}
// Filter out hidden files (starting with .)
const filterHidden = (nodes: FileTreeNode[]): FileTreeNode[] =>
nodes.filter((n) => !n.name.startsWith('.'))
export function FileTree({
rootPath,
directoryExists = true,
onCreateSession
}: FileTreeProps): React.JSX.Element {
// Hidden files toggle state (controlled by parent via uiStore)
const showHiddenFiles = useUIStore((s) => s.showHiddenFiles)
export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): React.JSX.Element {
const [rootChildren, setRootChildren] = useState<FileTreeNode[]>([])
// Store raw (unfiltered) data
const [rawRootChildren, setRawRootChildren] = useState<FileTreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [childrenCache, setChildrenCache] = useState<Map<string, FileTreeNode[]>>(new Map())
const [rawChildrenCache, setRawChildrenCache] = useState<Map<string, FileTreeNode[]>>(new Map())
// Compute filtered root children based on showHiddenFiles preference
const rootChildren = useMemo(
() => (showHiddenFiles ? rawRootChildren : excludeHiddenFiles(rawRootChildren)),
[rawRootChildren, showHiddenFiles]
)
// Helper to get filtered children for a given path (filters on demand)
const getFilteredChildren = useCallback(
(path: string): FileTreeNode[] | undefined => {
const children = rawChildrenCache.get(path)
if (!children) return undefined
return showHiddenFiles ? children : excludeHiddenFiles(children)
},
[rawChildrenCache, showHiddenFiles]
)
const [availableApps, setAvailableApps] = useState<DetectedApp[]>([])
// Initialize loading state based on directoryExists
const [isLoading, setIsLoading] = useState(directoryExists)
@@ -342,7 +383,7 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
window.electronAPI.listDirectory(rootPath),
window.electronAPI.detectApps()
])
setRootChildren(filterHidden(children))
setRawRootChildren(children)
setAvailableApps(apps)
} catch (error) {
console.error('Failed to load file tree:', error)
@@ -377,14 +418,13 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
// Re-fetch root directory
console.log('[FileTree] Fetching root directory:', rootPath)
const children = await window.electronAPI.listDirectory(rootPath)
const filtered = filterHidden(children)
console.log(
'[FileTree] Root directory result:',
filtered.length,
children.length,
'items',
filtered.map((c) => c.name)
children.map((c) => c.name)
)
setRootChildren(filtered)
setRawRootChildren(children)
// Re-fetch all expanded directories to update them
const currentExpanded = Array.from(expandedPathsRef.current)
@@ -395,7 +435,7 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
currentExpanded.map(async (path) => {
try {
const dirChildren = await window.electronAPI.listDirectory(path)
return { path, children: filterHidden(dirChildren) }
return { path, children: dirChildren }
} catch {
// Directory may no longer exist
return { path, children: [] }
@@ -405,7 +445,7 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
for (const { path, children: dirChildren } of results) {
newCache.set(path, dirChildren)
}
setChildrenCache(newCache)
setRawChildrenCache(newCache)
}
} catch (error) {
console.error('[FileTree] Failed to refresh file tree:', error)
@@ -430,11 +470,10 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
const loadChildren = useCallback(async (path: string) => {
// Fetch children first, then check cache in the setter to avoid stale closure
const children = await window.electronAPI.listDirectory(path)
const filtered = filterHidden(children)
setChildrenCache((prev) => {
setRawChildrenCache((prev) => {
// Skip if already cached (handles race conditions)
if (prev.has(path)) return prev
return new Map(prev).set(path, filtered)
return new Map(prev).set(path, children)
})
}, [])
@@ -468,9 +507,10 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
level={0}
expandedPaths={expandedPaths}
onToggle={handleToggle}
childrenCache={childrenCache}
getChildren={getFilteredChildren}
loadChildren={loadChildren}
availableApps={availableApps}
onCreateSession={onCreateSession}
/>
))}
</div>

View File

@@ -28,6 +28,10 @@ interface UIStore {
toggleRightPanel: () => void
setRightPanelOpen: (open: boolean) => void
setRightPanelWidth: (width: number) => void
// Hidden files visibility
showHiddenFiles: boolean
toggleShowHiddenFiles: () => void
}
export const useUIStore = create<UIStore>()(
@@ -49,7 +53,11 @@ export const useUIStore = create<UIStore>()(
setRightPanelWidth: (width) =>
set({
rightPanelWidth: Math.max(RIGHT_PANEL_MIN_WIDTH, Math.min(RIGHT_PANEL_MAX_WIDTH, width))
})
}),
// Hidden files - default hidden
showHiddenFiles: false,
toggleShowHiddenFiles: () => set((state) => ({ showHiddenFiles: !state.showHiddenFiles }))
}),
{
name: 'ui-state' // localStorage key

View File

@@ -0,0 +1,10 @@
/**
* Utility functions for file tree operations
*/
import type { FileTreeNode } from '../../../shared/electron-api'
/**
* Exclude hidden files (those starting with '.') from the list
*/
export const excludeHiddenFiles = (nodes: FileTreeNode[]): FileTreeNode[] =>
nodes.filter((n) => !n.name.startsWith('.'))

View File

@@ -0,0 +1,108 @@
/**
* Tests for FileTree helper functions
*/
import { describe, expect, it } from 'vitest'
import { excludeHiddenFiles } from '../../../../src/renderer/src/utils/fileTree'
import type { FileTreeNode } from '../../../../src/shared/electron-api'
describe('excludeHiddenFiles', () => {
const createNode = (name: string, type: 'file' | 'directory' = 'file'): FileTreeNode => ({
name,
path: `/test/${name}`,
type,
extension: type === 'file' ? name.split('.').pop() : undefined
})
it('returns an empty array when given an empty array', () => {
const result = excludeHiddenFiles([])
expect(result).toEqual([])
})
it('removes files starting with a dot', () => {
const nodes = [createNode('.gitignore'), createNode('README.md'), createNode('.env')]
const result = excludeHiddenFiles(nodes)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('README.md')
})
it('removes directories starting with a dot', () => {
const nodes = [
createNode('.git', 'directory'),
createNode('src', 'directory'),
createNode('.vscode', 'directory'),
createNode('node_modules', 'directory')
]
const result = excludeHiddenFiles(nodes)
expect(result).toHaveLength(2)
expect(result.map((n) => n.name)).toEqual(['src', 'node_modules'])
})
it('keeps all nodes when none are hidden', () => {
const nodes = [
createNode('index.ts'),
createNode('package.json'),
createNode('src', 'directory')
]
const result = excludeHiddenFiles(nodes)
expect(result).toHaveLength(3)
expect(result).toEqual(nodes)
})
it('removes all nodes when all are hidden', () => {
const nodes = [createNode('.gitignore'), createNode('.env'), createNode('.git', 'directory')]
const result = excludeHiddenFiles(nodes)
expect(result).toHaveLength(0)
})
it('handles mixed hidden files and directories', () => {
const nodes = [
createNode('.git', 'directory'),
createNode('.gitignore'),
createNode('src', 'directory'),
createNode('README.md'),
createNode('.env'),
createNode('package.json'),
createNode('.vscode', 'directory')
]
const result = excludeHiddenFiles(nodes)
expect(result).toHaveLength(3)
expect(result.map((n) => n.name)).toEqual(['src', 'README.md', 'package.json'])
})
it('does not modify the original array', () => {
const nodes = [createNode('.gitignore'), createNode('README.md')]
const originalLength = nodes.length
excludeHiddenFiles(nodes)
expect(nodes).toHaveLength(originalLength)
})
it('handles files with dots in the middle of the name', () => {
const nodes = [
createNode('file.test.ts'),
createNode('component.spec.tsx'),
createNode('.hidden.file'),
createNode('normal.config.js')
]
const result = excludeHiddenFiles(nodes)
expect(result).toHaveLength(3)
expect(result.map((n) => n.name)).toEqual([
'file.test.ts',
'component.spec.tsx',
'normal.config.js'
])
})
})

View File

@@ -0,0 +1,102 @@
/**
* Tests for uiStore - specifically showHiddenFiles state
*/
import { beforeEach, describe, expect, it } from 'vitest'
import {
useUIStore,
SIDEBAR_MIN_WIDTH,
SIDEBAR_MAX_WIDTH,
RIGHT_PANEL_MIN_WIDTH,
RIGHT_PANEL_MAX_WIDTH
} from '../../../../src/renderer/src/stores/uiStore'
describe('uiStore', () => {
beforeEach(() => {
// Reset to default state
useUIStore.setState({
sidebarOpen: true,
sidebarWidth: 256,
rightPanelOpen: true,
rightPanelWidth: 320,
showHiddenFiles: false
})
})
describe('showHiddenFiles', () => {
it('defaults to false (hidden files are not shown)', () => {
expect(useUIStore.getState().showHiddenFiles).toBe(false)
})
it('toggles showHiddenFiles from false to true', () => {
useUIStore.getState().toggleShowHiddenFiles()
expect(useUIStore.getState().showHiddenFiles).toBe(true)
})
it('toggles showHiddenFiles from true to false', () => {
useUIStore.setState({ showHiddenFiles: true })
useUIStore.getState().toggleShowHiddenFiles()
expect(useUIStore.getState().showHiddenFiles).toBe(false)
})
it('toggles multiple times correctly', () => {
expect(useUIStore.getState().showHiddenFiles).toBe(false)
useUIStore.getState().toggleShowHiddenFiles()
expect(useUIStore.getState().showHiddenFiles).toBe(true)
useUIStore.getState().toggleShowHiddenFiles()
expect(useUIStore.getState().showHiddenFiles).toBe(false)
useUIStore.getState().toggleShowHiddenFiles()
expect(useUIStore.getState().showHiddenFiles).toBe(true)
})
})
describe('sidebar state', () => {
it('toggles sidebar open state', () => {
expect(useUIStore.getState().sidebarOpen).toBe(true)
useUIStore.getState().toggleSidebar()
expect(useUIStore.getState().sidebarOpen).toBe(false)
})
it('sets sidebar open state directly', () => {
useUIStore.getState().setSidebarOpen(false)
expect(useUIStore.getState().sidebarOpen).toBe(false)
})
it('clamps sidebar width to min/max constraints', () => {
useUIStore.getState().setSidebarWidth(100) // below min
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_MIN_WIDTH)
useUIStore.getState().setSidebarWidth(1000) // above max
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_MAX_WIDTH)
useUIStore.getState().setSidebarWidth(300) // within range
expect(useUIStore.getState().sidebarWidth).toBe(300)
})
})
describe('right panel state', () => {
it('toggles right panel open state', () => {
expect(useUIStore.getState().rightPanelOpen).toBe(true)
useUIStore.getState().toggleRightPanel()
expect(useUIStore.getState().rightPanelOpen).toBe(false)
})
it('sets right panel open state directly', () => {
useUIStore.getState().setRightPanelOpen(false)
expect(useUIStore.getState().rightPanelOpen).toBe(false)
})
it('clamps right panel width to min/max constraints', () => {
useUIStore.getState().setRightPanelWidth(100) // below min
expect(useUIStore.getState().rightPanelWidth).toBe(RIGHT_PANEL_MIN_WIDTH)
useUIStore.getState().setRightPanelWidth(1000) // above max
expect(useUIStore.getState().rightPanelWidth).toBe(RIGHT_PANEL_MAX_WIDTH)
useUIStore.getState().setRightPanelWidth(350) // within range
expect(useUIStore.getState().rightPanelWidth).toBe(350)
})
})
})