import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiFolder6Line, RiPushpin2Line, RiPushpinLine } from '@remixicon/react'; import { cn, formatPathForDisplay } from '@/lib/utils'; import { opencodeClient } from '@/lib/opencode/client'; import { useDeviceInfo } from '@/lib/device'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface DirectoryItem { name: string; path: string; isDirectory: boolean; children?: DirectoryItem[]; isExpanded?: boolean; } interface DirectoryTreeProps { currentPath: string; onSelectPath: (path: string) => void; triggerClassName?: string; variant?: 'dropdown' | 'inline'; className?: string; selectionBehavior?: 'immediate' | 'deferred'; onDoubleClickPath?: (path: string) => void; showHidden?: boolean; rootDirectory?: string | null; isRootReady?: boolean; /** Always show action icons (add, pin) instead of only on hover */ alwaysShowActions?: boolean; } export const DirectoryTree: React.FC = ({ currentPath, onSelectPath, triggerClassName, variant = 'dropdown', className, selectionBehavior = 'immediate', onDoubleClickPath, showHidden = false, rootDirectory = null, isRootReady, alwaysShowActions = false, }) => { const { isMobile } = useDeviceInfo(); const [directories, setDirectories] = React.useState([]); const [expandedPaths, setExpandedPaths] = React.useState>(new Set()); const [isLoading, setIsLoading] = React.useState(true); const [isOpen, setIsOpen] = React.useState(false); const [homeDirectory, setHomeDirectory] = React.useState(''); const [pinnedPaths, setPinnedPaths] = React.useState>(new Set()); const [creatingInPath, setCreatingInPath] = React.useState(null); const [newDirName, setNewDirName] = React.useState(''); const [isPinnedExpanded, setIsPinnedExpanded] = React.useState(true); const inputRef = React.useRef(null); const { requestAccess, startAccessing, isDesktop } = useFileSystemAccess(); const previousShowHidden = React.useRef(showHidden); const stripTrailingSlashes = React.useCallback((value: string | null | undefined) => { if (!value) { return value; } if (value === '/' || value.length === 0) { return '/'; } let trimmed = value; while (trimmed.length > 1 && trimmed.endsWith('/')) { trimmed = trimmed.slice(0, -1); } return trimmed.length === 0 ? '/' : trimmed; }, []); const normalizedHomeDirectory = React.useMemo(() => { if (!homeDirectory) { return null; } const normalized = homeDirectory.replace(/\\/g, '/'); return stripTrailingSlashes(normalized) as string; }, [homeDirectory, stripTrailingSlashes]); const effectiveRoot = React.useMemo(() => { if (typeof rootDirectory === 'string' && rootDirectory.length > 0) { const normalized = rootDirectory.replace(/\\/g, '/'); const stripped = stripTrailingSlashes(normalized); if (stripped && stripped !== '/') { return stripped as string; } } if (normalizedHomeDirectory && normalizedHomeDirectory !== '/') { return normalizedHomeDirectory; } return null; }, [rootDirectory, normalizedHomeDirectory, stripTrailingSlashes]); const rootReady = React.useMemo(() => { if (typeof isRootReady === 'boolean') { return Boolean(isRootReady && effectiveRoot); } return Boolean(effectiveRoot); }, [isRootReady, effectiveRoot]); React.useEffect(() => { if (!rootReady) { setIsLoading(true); setDirectories([]); } }, [rootReady]); const isPathWithinHome = React.useCallback( (targetPath: string | null | undefined): boolean => { if (!targetPath) { return false; } if (!rootReady || !effectiveRoot) { return false; } const normalizedTargetRaw = targetPath.replace(/\\/g, '/'); const normalizedTarget = (stripTrailingSlashes(normalizedTargetRaw) as string) ?? normalizedTargetRaw; if (normalizedTarget === effectiveRoot) { return true; } const prefix = `${effectiveRoot}/`; return normalizedTarget.startsWith(prefix); }, [rootReady, effectiveRoot, stripTrailingSlashes] ); const handleDirectorySelect = async (path: string) => { if (!rootReady) { return; } if (selectionBehavior === 'deferred') { onSelectPath(path); return; } if (isDesktop) { const accessResult = await requestAccess(path); if (accessResult.success && accessResult.path) { await startAccessing(accessResult.path); onSelectPath(accessResult.path); } else { console.error('Failed to get directory access:', accessResult.error); onSelectPath(path); } } else { onSelectPath(path); } }; React.useEffect(() => { let cancelled = false; const applyRootDirectory = (candidate: string | null | undefined) => { if (!candidate) { return false; } const normalized = stripTrailingSlashes(candidate.replace(/\\/g, '/')); if (!normalized || normalized === '/') { return false; } setHomeDirectory(typeof normalized === 'string' ? normalized : candidate.replace(/\\/g, '/')); return true; }; const appliedInitialRoot = rootDirectory ? applyRootDirectory(rootDirectory) : false; const resolveHomeDirectory = async () => { try { const fsHome = await opencodeClient.getFilesystemHome(); if (!cancelled && applyRootDirectory(fsHome)) { return; } } catch (error) { console.warn('Failed to resolve filesystem home directory:', error); } try { const info = await opencodeClient.getSystemInfo(); if (!cancelled && applyRootDirectory(info?.homeDirectory)) { return; } } catch (error) { console.warn('Failed to resolve home directory from system info:', error); } }; if (!appliedInitialRoot) { resolveHomeDirectory(); } return () => { cancelled = true; }; }, [rootDirectory, stripTrailingSlashes]); React.useEffect(() => { let cancelled = false; const applyPinned = (paths: string[]) => { if (cancelled) { return; } const normalized = paths .filter((path): path is string => typeof path === 'string' && path.length > 0) .map((path) => { const normalizedPath = path.replace(/\\/g, '/'); return (stripTrailingSlashes(normalizedPath) as string) ?? normalizedPath; }); setPinnedPaths(new Set(normalized)); }; const loadFromLocalStorage = () => { try { const raw = localStorage.getItem('pinnedDirectories'); if (!raw) { return; } const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { applyPinned(parsed); } } catch (error) { console.warn('Failed to load pinned directories from local storage:', error); } }; const loadPinnedDirectories = async () => { try { let pinned: string[] = []; const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' }, }); if (response.ok) { const data = await response.json(); pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : []; } if (cancelled) { return; } applyPinned(pinned); } catch (error) { console.warn('Failed to load pinned directories:', error); } }; loadFromLocalStorage(); const handleSettingsSynced = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail && Array.isArray(detail.pinnedDirectories)) { applyPinned(detail.pinnedDirectories); } }; window.addEventListener('openchamber:settings-synced', handleSettingsSynced); void loadPinnedDirectories(); return () => { cancelled = true; window.removeEventListener('openchamber:settings-synced', handleSettingsSynced); }; }, [stripTrailingSlashes]); const isInitialPinnedSync = React.useRef(true); React.useEffect(() => { if (isInitialPinnedSync.current) { isInitialPinnedSync.current = false; return; } const payload = { pinnedDirectories: Array.from(pinnedPaths), }; void updateDesktopSettings(payload); }, [pinnedPaths]); React.useEffect(() => { if (!effectiveRoot) { return; } setPinnedPaths((prev) => { const filtered = Array.from(prev) .map((path) => (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path) .filter((path) => isPathWithinHome(path)); return new Set(filtered); }); }, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]); // Reload directories when showHidden changes, but keep expanded state React.useEffect(() => { if (previousShowHidden.current !== showHidden) { previousShowHidden.current = showHidden; // Silently reload without clearing state - loadInitialDirectories will be called // via its dependency on loadDirectory which depends on showHidden } }, [showHidden]); const togglePin = (path: string) => { setPinnedPaths(prev => { if (!isPathWithinHome(path)) { return prev; } const normalizedPath = (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path.replace(/\\/g, '/'); const newSet = new Set(prev); if (newSet.has(normalizedPath)) { newSet.delete(normalizedPath); } else { newSet.add(normalizedPath); } return newSet; }); }; const pinnedDirectories = React.useMemo(() => { return Array.from(pinnedPaths) .map((rawPath) => { const normalizedPath = stripTrailingSlashes(rawPath.replace(/\\/g, '/')) ?? rawPath; return normalizedPath; }) .filter((path) => isPathWithinHome(path)) .map((path) => ({ path, name: path.split('/').pop() || path })) .sort((a, b) => a.name.localeCompare(b.name)); }, [pinnedPaths, isPathWithinHome, stripTrailingSlashes]); const loadDirectory = React.useCallback(async (path: string): Promise => { const shouldInclude = (name: string) => showHidden || !name.startsWith('.'); const normalizedHome = effectiveRoot; if (!rootReady || !normalizedHome) { return []; } const normalizedPathRaw = path && path.length > 0 ? path : normalizedHome; const normalizedPath = normalizedPathRaw ? normalizedPathRaw.replace(/\\/g, '/') : null; const normalizedTarget = normalizedPath ? stripTrailingSlashes(normalizedPath) ?? normalizedPath : null; if (normalizedTarget) { const homePrefix = `${normalizedHome}/`; const withinHome = normalizedTarget === normalizedHome || normalizedTarget.startsWith(homePrefix); if (!withinHome) { return []; } } try { const filesystemEntries = await opencodeClient.listLocalDirectory(path); return filesystemEntries .filter((entry) => { if (!entry.isDirectory) { return false; } if (!shouldInclude(entry.name)) { return false; } const normalizedEntryRaw = entry.path.replace(/\\/g, '/'); const normalizedEntryPath = stripTrailingSlashes(normalizedEntryRaw) ?? normalizedEntryRaw; const entryPrefix = normalizedEntryPath === normalizedHome ? normalizedHome : `${normalizedHome}/`; return normalizedEntryPath === normalizedHome || normalizedEntryPath.startsWith(entryPrefix); }) .map((entry) => ({ name: entry.name, path: entry.path.replace(/\\/g, '/'), isDirectory: true })) .sort((a, b) => a.name.localeCompare(b.name)); } catch { try { const tempClient = opencodeClient.getApiClient(); const response = await tempClient.file.list({ path: '.', directory: path }); if (!response.data) { return []; } return response.data .filter((item: { type?: string; name?: string; absolute?: string; path?: string }) => { if (item.type !== 'directory') { return false; } if (!item.name || !shouldInclude(item.name)) { return false; } const rawPath = String(item.absolute || item.path || item.name).replace(/\\/g, '/'); const absolutePath = stripTrailingSlashes(rawPath) ?? rawPath; const entryPrefix = absolutePath === normalizedHome ? normalizedHome : `${normalizedHome}/`; return absolutePath === normalizedHome || absolutePath.startsWith(entryPrefix); }) .map((item: { name?: string; absolute?: string; path?: string }) => ({ name: item.name || '', path: String(item.absolute || item.path || item.name).replace(/\\/g, '/'), isDirectory: true })) .filter((item): item is DirectoryItem => item.name !== '') .sort((a: DirectoryItem, b: DirectoryItem) => a.name.localeCompare(b.name)); } catch { return []; } } }, [showHidden, effectiveRoot, stripTrailingSlashes, rootReady]); const hasLoadedOnce = React.useRef(false); const loadInitialDirectories = React.useCallback(async () => { if (!rootReady || !effectiveRoot) { setIsLoading(true); setDirectories([]); return; } // Only show loading on initial load, not on refreshes (e.g., showHidden toggle) if (!hasLoadedOnce.current) { setIsLoading(true); } try { const homeContents = await loadDirectory(effectiveRoot); setDirectories(homeContents); hasLoadedOnce.current = true; } catch { /* ignored */ } finally { setIsLoading(false); } }, [rootReady, effectiveRoot, loadDirectory]); React.useEffect(() => { if (!rootReady) { return; } if ((variant === 'inline' || isOpen)) { loadInitialDirectories(); } }, [variant, isOpen, rootReady, loadInitialDirectories]); const toggleExpanded = async (item: DirectoryItem) => { if (!rootReady) { return; } const isCurrentlyExpanded = expandedPaths.has(item.path); const newExpanded = new Set(expandedPaths); if (isCurrentlyExpanded) { newExpanded.delete(item.path); setExpandedPaths(newExpanded); return; } newExpanded.add(item.path); setExpandedPaths(newExpanded); const children = await loadDirectory(item.path); const updateItems = (items: DirectoryItem[]): DirectoryItem[] => { return items.map((i) => { if (i.path === item.path) { return { ...i, children }; } if (i.children) { return { ...i, children: updateItems(i.children) }; } return i; }); }; setDirectories((prev) => updateItems(prev)); }; React.useEffect(() => { if (creatingInPath && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [creatingInPath]); const generateUniqueDirName = (parentPath: string, children: DirectoryItem[] = []): string => { const baseName = 'new_directory'; const existingNames = children.map(child => child.name); if (!existingNames.includes(baseName)) { return baseName; } let maxNumber = 1; const numberPattern = new RegExp(`^${baseName}(\\d+)$`); for (const name of existingNames) { const match = name.match(numberPattern); if (match) { const num = parseInt(match[1], 10); if (num > maxNumber) { maxNumber = num; } } } let counter = 2; while (existingNames.includes(`${baseName}${counter}`)) { counter++; } return `${baseName}${Math.max(counter, maxNumber + 1)}`; }; const startCreatingDirectory = async (parentItem: DirectoryItem) => { if (!rootReady) { return; } if (!expandedPaths.has(parentItem.path)) { const newExpanded = new Set(expandedPaths); newExpanded.add(parentItem.path); setExpandedPaths(newExpanded); if (!parentItem.children) { const children = await loadDirectory(parentItem.path); const updateItems = (items: DirectoryItem[]): DirectoryItem[] => { return items.map(i => { if (i.path === parentItem.path) { return { ...i, children }; } if (i.children) { return { ...i, children: updateItems(i.children) }; } return i; }); }; setDirectories((prev) => updateItems(prev)); const uniqueName = generateUniqueDirName(parentItem.path, children); setNewDirName(uniqueName); } else { const uniqueName = generateUniqueDirName(parentItem.path, parentItem.children); setNewDirName(uniqueName); } } else { const uniqueName = generateUniqueDirName(parentItem.path, parentItem.children); setNewDirName(uniqueName); } setCreatingInPath(parentItem.path); }; const createDirectory = async () => { if (!creatingInPath || !rootReady) return; const dirName = newDirName.trim() || 'new_directory'; const fullPath = `${creatingInPath}/${dirName}`; try { await opencodeClient.createDirectory(fullPath, { allowOutsideWorkspace: true }); const children = await loadDirectory(creatingInPath); const updateItems = (items: DirectoryItem[]): DirectoryItem[] => { return items.map(i => { if (i.path === creatingInPath) { return { ...i, children }; } if (i.children) { return { ...i, children: updateItems(i.children) }; } return i; }); }; setDirectories((prev) => updateItems(prev)); setCreatingInPath(null); setNewDirName(''); } catch (error) { console.error('Failed to create directory:', error); } }; const cancelCreatingDirectory = () => { setCreatingInPath(null); setNewDirName(''); }; const renderTreeItem = (item: DirectoryItem, level: number = 0) => { const isExpanded = expandedPaths.has(item.path); const hasChildren = item.isDirectory; const isPinned = pinnedPaths.has(item.path); const isSelected = currentPath === item.path; const isInlineVariant = variant === 'inline'; const rowContent = ( <> {hasChildren && ( )} {!hasChildren &&
} ); if (variant === 'inline') { return (
{rowContent}
{isExpanded && ( <> {creatingInPath === item.path && (
setNewDirName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); createDirectory(); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelCreatingDirectory(); } }} onBlur={createDirectory} className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground" placeholder="new_directory" />
)} {item.children && item.children.map((child) => renderTreeItem(child, level + 1))} )}
); } return (
{ e.preventDefault(); }} > {hasChildren && ( )} {!hasChildren &&
} {rowContent} {isExpanded && (
{creatingInPath === item.path && (
setNewDirName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); createDirectory(); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelCreatingDirectory(); } }} onBlur={createDirectory} className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground" placeholder="new_directory" />
)} {item.children && item.children.map((child) => renderTreeItem(child, level + 1))}
)}
); }; const renderPinnedRow = (name: string, path: string) => { if (variant === 'inline') { const isSelected = currentPath === path; return (
); } return ( { e.preventDefault(); handleDirectorySelect(path); if (selectionBehavior === 'immediate') { setIsOpen(false); } }} className={cn( 'flex items-start gap-2 cursor-pointer group py-2', currentPath === path && 'bg-interactive-selection' )} >
{name}
{formatPathForDisplay(path, homeDirectory)}
); }; const directoryContent = ( <> {!rootReady ? (
Locating home directory...
) : ( <> {pinnedDirectories.length > 0 && ( <> {isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))} {variant === 'dropdown' && } {variant === 'inline' && isPinnedExpanded && (
)} )}
Browse
{isLoading ? (
Loading...
) : ( directories.map((item) => renderTreeItem(item)) )} {!isLoading && directories.length === 0 && (
No directories found
)} )} ); if (variant === 'inline') { return (
{directoryContent}
); } return ( {directoryContent} ); };