diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fffdef9ec1..8fae0f2958 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 */} - + All files + {currentSession ? ( ) : (
diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index c76f21985e..8dae8827a5 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -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 onToggle: (path: string) => void - childrenCache: Map + getChildren: (path: string) => FileTreeNode[] | undefined loadChildren: (path: string) => Promise 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 && ( handleOpenWith('finder')}> - 📁 + Finder ⌘O )} - {/* Editors */} - {editorApps.length > 0 && ( + {/* Apps (Multica + Editors) */} + {(isDirectory && onCreateSession) || editorApps.length > 0 ? ( <> + {/* Multica - only for directories */} + {isDirectory && onCreateSession && ( + onCreateSession(node.path)}> + + + Multica + + + )} + {/* Editors */} {editorApps.map((app) => ( handleOpenWith(app.id)}> - {app.id === 'cursor' && 🔵} - {app.id === 'vscode' && 🔷} - {app.id === 'xcode' && 🔧} + {app.id === 'xcode' ? ( + + ) : ( + + )} {app.name} ))} - )} + ) : null} {/* Terminals */} {terminalApps.length > 0 && ( @@ -245,9 +267,7 @@ function TreeItem({ {terminalApps.map((app) => ( handleOpenWith(app.id)}> - {app.id === 'ghostty' && 👻} - {app.id === 'iterm' && 📟} - {app.id === 'terminal' && } + {app.name} @@ -261,7 +281,7 @@ function TreeItem({ handleOpenWith('copy-path')}> - 📋 + Copy path @@ -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([]) + // Store raw (unfiltered) data + const [rawRootChildren, setRawRootChildren] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) - const [childrenCache, setChildrenCache] = useState>(new Map()) + const [rawChildrenCache, setRawChildrenCache] = useState>(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([]) // 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} /> ))}
diff --git a/src/renderer/src/stores/uiStore.ts b/src/renderer/src/stores/uiStore.ts index d7cc363d38..e0ba6041a8 100644 --- a/src/renderer/src/stores/uiStore.ts +++ b/src/renderer/src/stores/uiStore.ts @@ -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()( @@ -49,7 +53,11 @@ export const useUIStore = create()( 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 diff --git a/src/renderer/src/utils/fileTree.ts b/src/renderer/src/utils/fileTree.ts new file mode 100644 index 0000000000..5599109a29 --- /dev/null +++ b/src/renderer/src/utils/fileTree.ts @@ -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('.')) diff --git a/tests/unit/renderer/components/FileTree.test.ts b/tests/unit/renderer/components/FileTree.test.ts new file mode 100644 index 0000000000..bdc075cda9 --- /dev/null +++ b/tests/unit/renderer/components/FileTree.test.ts @@ -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' + ]) + }) +}) diff --git a/tests/unit/renderer/stores/uiStore.test.ts b/tests/unit/renderer/stores/uiStore.test.ts new file mode 100644 index 0000000000..39ad40c4cf --- /dev/null +++ b/tests/unit/renderer/stores/uiStore.test.ts @@ -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) + }) + }) +})