diff --git a/src/routes/filesystem.routes.ts b/src/routes/filesystem.routes.ts index ac2196ad..99917d40 100644 --- a/src/routes/filesystem.routes.ts +++ b/src/routes/filesystem.routes.ts @@ -1,4 +1,5 @@ import { Router, Request } from 'express'; +import * as os from 'os'; import { CUIError, FileSystemListQuery, @@ -104,5 +105,28 @@ export function createFileSystemRoutes( } }); + // Get home directory + router.get('/home', async (req: RequestWithRequestId, res, next) => { + const requestId = req.requestId; + logger.debug('Home directory request', { requestId }); + + try { + const homeDirectory = os.homedir(); + + logger.debug('Home directory retrieved', { + requestId, + homeDirectory + }); + + res.json({ homeDirectory }); + } catch (error) { + logger.error('Failed to get home directory', { + requestId, + error: error instanceof Error ? error.message : String(error) + }); + next(error); + } + }); + return router; } \ No newline at end of file diff --git a/src/web/chat/components/Composer/Composer.tsx b/src/web/chat/components/Composer/Composer.tsx index 7e302b10..e7501265 100644 --- a/src/web/chat/components/Composer/Composer.tsx +++ b/src/web/chat/components/Composer/Composer.tsx @@ -3,20 +3,16 @@ import { ChevronDown, Mic, Send, Loader2, Sparkles, Laptop, Square, Check, X, Mi import { DropdownSelector, DropdownOption } from '../DropdownSelector'; import { PermissionDialog } from '../PermissionDialog'; import { WaveformVisualizer } from '../WaveformVisualizer'; +import { DirectoryPicker } from '../DirectoryPicker'; import { Button } from '../ui/button'; import { Textarea } from '../ui/textarea'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; -import type { PermissionRequest, Command } from '../../types'; +import type { PermissionRequest, Command, FileSystemEntry } from '../../types'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import { useAudioRecording } from '../../hooks/useAudioRecording'; import { api } from '../../../chat/services/api'; import { cn } from "../../lib/utils"; -export interface FileSystemEntry { - name: string; - type: 'file' | 'directory'; - depth: number; -} interface AutocompleteState { isActive: boolean; @@ -78,12 +74,14 @@ interface DirectoryDropdownProps { selectedDirectory: string; recentDirectories: Record; onDirectorySelect: (directory: string) => void; + onBrowseClick?: () => void; } function DirectoryDropdown({ selectedDirectory, recentDirectories, - onDirectorySelect + onDirectorySelect, + onBrowseClick }: DirectoryDropdownProps) { const [isOpen, setIsOpen] = useState(false); @@ -116,6 +114,7 @@ function DirectoryDropdown({ onOpenChange={setIsOpen} placeholder="Enter a directory..." showFilterInput={true} + onBrowseClick={onBrowseClick} filterPredicate={(option, searchText) => { // Allow filtering by path if (option.value.toLowerCase().includes(searchText.toLowerCase())) { @@ -340,6 +339,7 @@ export const Composer = forwardRef(function Composer const [selectedModel, setSelectedModel] = useState(model); const [selectedPermissionMode, setSelectedPermissionMode] = useState(cachedState.selectedPermissionMode); const [isPermissionDropdownOpen, setIsPermissionDropdownOpen] = useState(false); + const [showDirectoryPicker, setShowDirectoryPicker] = useState(false); const [localFileSystemEntries, setLocalFileSystemEntries] = useState(fileSystemEntries); const [localCommands, setLocalCommands] = useState(availableCommands); const [autocomplete, setAutocomplete] = useState({ @@ -899,6 +899,7 @@ export const Composer = forwardRef(function Composer selectedDirectory={selectedDirectory} recentDirectories={recentDirectories} onDirectorySelect={handleDirectorySelect} + onBrowseClick={() => setShowDirectoryPicker(true)} /> )} @@ -1119,6 +1120,17 @@ export const Composer = forwardRef(function Composer isVisible={true} /> )} + + {/* Directory Picker Modal */} + setShowDirectoryPicker(false)} + onSelect={(path) => { + handleDirectorySelect(path); + setShowDirectoryPicker(false); + }} + initialPath={workingDirectory} + /> ); }); \ No newline at end of file diff --git a/src/web/chat/components/DirectoryPicker/DirectoryPicker.tsx b/src/web/chat/components/DirectoryPicker/DirectoryPicker.tsx new file mode 100644 index 00000000..65be10bf --- /dev/null +++ b/src/web/chat/components/DirectoryPicker/DirectoryPicker.tsx @@ -0,0 +1,313 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { ChevronRight, Home, FolderOpen, ArrowLeft } from 'lucide-react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Button } from '../ui/button'; +import { cn } from '../../lib/utils'; +import { api } from '../../services/api'; +import type { FileSystemEntry } from '@/types'; + +interface DirectoryPickerProps { + isOpen: boolean; + onClose: () => void; + onSelect: (path: string) => void; + initialPath?: string; +} + +interface BreadcrumbSegment { + name: string; + path: string; +} + +// Pure utility functions - moved outside component for performance +const getParentPath = (path: string): string | null => { + const segments = path.split('/').filter(Boolean); + if (segments.length <= 1) return null; + return '/' + segments.slice(0, -1).join('/'); +}; + +const joinPath = (basePath: string, segment: string): string => { + return basePath === '/' ? `/${segment}` : `${basePath}/${segment}`; +}; + +const parseBreadcrumbs = (path: string): BreadcrumbSegment[] => { + if (!path || path === '/') { + return [{ name: 'Root', path: '/' }]; + } + + const segments = path.split('/').filter(Boolean); + const breadcrumbs: BreadcrumbSegment[] = [{ name: 'Root', path: '/' }]; + + segments.forEach((segment, index) => { + breadcrumbs.push({ + name: segment, + path: '/' + segments.slice(0, index + 1).join('/') + }); + }); + + return breadcrumbs; +}; + +// Directory item component for cleaner render +interface DirectoryItemProps { + directory: FileSystemEntry; + currentPath: string; + onNavigate: (directory: FileSystemEntry) => void; + onSelect: (path: string) => void; +} + +const DirectoryItem = React.memo(({ directory, currentPath, onNavigate, onSelect }: DirectoryItemProps) => { + const handleDoubleClick = useCallback(() => { + const newPath = joinPath(currentPath, directory.name); + onSelect(newPath); + }, [currentPath, directory.name, onSelect]); + + return ( + + ); +}); + +DirectoryItem.displayName = 'DirectoryItem'; + +export function DirectoryPicker({ isOpen, onClose, onSelect, initialPath }: DirectoryPickerProps) { + const [currentPath, setCurrentPath] = useState(initialPath || ''); + const [directories, setDirectories] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load directories for the current path + const loadDirectories = useCallback(async (path: string) => { + if (!path) return; + + setLoading(true); + setError(null); + + try { + const response = await api.listDirectory({ path }); + // Filter to directories only and sort alphabetically + const dirs = response.entries + .filter(entry => entry.type === 'directory') + .sort((a, b) => a.name.localeCompare(b.name)); + setDirectories(dirs); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load directory'; + setError(errorMessage); + setDirectories([]); + + // If path not found, try to navigate to parent + if (errorMessage.includes('not found') || errorMessage.includes('ENOENT')) { + const parentPath = getParentPath(path); + if (parentPath && parentPath !== path) { + setCurrentPath(parentPath); + return; // loadDirectories will be called again via useEffect + } + } + } finally { + setLoading(false); + } + }, []); + + const breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]); + const parentPath = useMemo(() => getParentPath(currentPath), [currentPath]); + const canGoUp = parentPath !== null; + + // Navigate up to parent directory + const navigateUp = useCallback(() => { + if (parentPath) { + setCurrentPath(parentPath); + } + }, [parentPath]); + + // Handle directory selection + const handleDirectorySelect = useCallback((directory: FileSystemEntry) => { + const newPath = joinPath(currentPath, directory.name); + setCurrentPath(newPath); + }, [currentPath]); + + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + navigateUp(); + } + }, [onClose, navigateUp]); + + // Handle initialization and directory loading + useEffect(() => { + if (!isOpen) return; + + // If no current path, initialize with home directory + if (!currentPath) { + api.getHomeDirectory() + .then(({ homeDirectory }) => { + setCurrentPath(homeDirectory); + }) + .catch((error) => { + console.warn('Failed to get home directory:', error); + setCurrentPath('/'); // Fallback to root + }); + return; + } + + // Load directories for current path + loadDirectories(currentPath); + }, [isOpen, currentPath, loadDirectories]); + + // Render directory list content based on state + const renderDirectoryContent = () => { + if (loading) { + return ( +
+
+ Loading directories... +
+ ); + } + + if (error) { + return ( +
+

Error: {error}

+ +
+ ); + } + + if (directories.length === 0) { + return ( +
+ +

No subdirectories found

+
+ ); + } + + return ( +
+ {directories.map((directory) => ( + + ))} +
+ ); + }; + + return ( + + + + + + Select Directory + + + Browse and select a directory. Use the breadcrumb navigation to navigate between folders, or use the Up button to go to parent directories. + + + {/* Close Button */} + + + + + {/* Breadcrumb Navigation */} + + + {/* Toolbar */} +
+ +
+ + {/* Directory List */} +
+ {renderDirectoryContent()} +
+ + {/* Footer */} +
+
+ Current: {currentPath || '/'} +
+
+ + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/web/chat/components/DirectoryPicker/index.ts b/src/web/chat/components/DirectoryPicker/index.ts new file mode 100644 index 00000000..72f9ee07 --- /dev/null +++ b/src/web/chat/components/DirectoryPicker/index.ts @@ -0,0 +1 @@ +export { DirectoryPicker } from './DirectoryPicker'; \ No newline at end of file diff --git a/src/web/chat/components/DropdownSelector.tsx b/src/web/chat/components/DropdownSelector.tsx index bde4e179..b1dcd72b 100644 --- a/src/web/chat/components/DropdownSelector.tsx +++ b/src/web/chat/components/DropdownSelector.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useCallback, forwardRef } from 'react'; -import { Check, ArrowUp } from 'lucide-react'; +import { Check, ArrowUp, FolderOpen } from 'lucide-react'; import { Popover, PopoverContent, @@ -45,6 +45,7 @@ interface DropdownSelectorProps { onFocusReturn?: () => void; visualFocusOnly?: boolean; focusedIndexControlled?: number; + onBrowseClick?: () => void; } export const DropdownSelector = forwardRef>( @@ -71,6 +72,7 @@ export const DropdownSelector = forwardRef, ref: React.ForwardedRef ) { @@ -407,6 +409,15 @@ export const DropdownSelector = forwardRef + {onBrowseClick && ( + onBrowseClick()} + className="flex items-center gap-2 cursor-pointer px-3 py-2.5 rounded-[10px] hover:bg-black/5 dark:hover:bg-white/5 text-sm text-neutral-700 dark:text-neutral-300 mb-1 border-b border-black/5 dark:border-white/5" + > + + Browse for directory... + + )} {visibleOptions.map((option, index) => ( { + return this.apiCall('/api/filesystem/home'); + } + async getCommands(workingDirectory?: string): Promise { const searchParams = new URLSearchParams(); if (workingDirectory) {