Files
autocoder/ui/src/components/FolderBrowser.tsx
Auto a12e4aa3b8 refactor(ui): extract keyboard utilities and add padding constant
- Create shared `isSubmitEnter()` utility in `ui/src/lib/keyboard.ts`
  for IME-aware Enter key handling across all input components
- Extract magic number 48 to named constant `COLLAPSED_DEBUG_PANEL_CLEARANCE`
  with explanatory comment (40px panel header + 8px margin)
- Update 5 components to use the new utility:
  - AssistantChat.tsx
  - ExpandProjectChat.tsx
  - SpecCreationChat.tsx
  - FolderBrowser.tsx
  - TerminalTabs.tsx

This follows up on PR #121 which added IME composition checks. The
refactoring centralizes the logic for easier maintenance and documents
the padding value that prevents Kanban cards from being cut off when
the debug panel is collapsed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:28:48 +02:00

344 lines
11 KiB
TypeScript

/**
* Folder Browser Component
*
* Server-side filesystem browser for selecting project directories.
* Cross-platform support for Windows, macOS, and Linux.
*/
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Folder,
FolderOpen,
ChevronRight,
HardDrive,
Loader2,
AlertCircle,
FolderPlus,
ArrowLeft,
} from 'lucide-react'
import * as api from '../lib/api'
import { isSubmitEnter } from '../lib/keyboard'
import type { DirectoryEntry, DriveInfo } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
interface FolderBrowserProps {
onSelect: (path: string) => void
onCancel: () => void
initialPath?: string
}
export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowserProps) {
const [currentPath, setCurrentPath] = useState<string | undefined>(initialPath)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
// Fetch directory listing
const {
data: directoryData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['filesystem', 'list', currentPath],
queryFn: () => api.listDirectory(currentPath),
})
// Update selected path when directory changes
useEffect(() => {
if (directoryData?.current_path) {
setSelectedPath(directoryData.current_path)
}
}, [directoryData?.current_path])
const handleNavigate = (path: string) => {
setCurrentPath(path)
setSelectedPath(path)
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}
const handleNavigateUp = () => {
if (directoryData?.parent_path) {
handleNavigate(directoryData.parent_path)
}
}
const handleDriveSelect = (drive: DriveInfo) => {
handleNavigate(`${drive.letter}:/`)
}
const handleEntryClick = (entry: DirectoryEntry) => {
if (entry.is_directory) {
handleNavigate(entry.path)
}
}
const handleCreateFolder = async () => {
if (!newFolderName.trim()) {
setCreateError('Folder name is required')
return
}
// Basic validation
if (!/^[a-zA-Z0-9_\-. ]+$/.test(newFolderName)) {
setCreateError('Invalid folder name')
return
}
const newPath = `${directoryData?.current_path}/${newFolderName.trim()}`
try {
await api.createDirectory(newPath)
// Refresh the directory listing
await refetch()
// Navigate to the new folder
handleNavigate(newPath)
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create folder')
}
}
const handleSelect = () => {
if (selectedPath) {
onSelect(selectedPath)
}
}
// Parse breadcrumb segments from path
const getBreadcrumbs = (path: string): { name: string; path: string }[] => {
if (!path) return []
const segments: { name: string; path: string }[] = []
// Handle Windows drive letters
if (/^[A-Za-z]:/.test(path)) {
const drive = path.slice(0, 2)
segments.push({ name: drive, path: `${drive}/` })
path = path.slice(3)
} else if (path.startsWith('/')) {
segments.push({ name: '/', path: '/' })
path = path.slice(1)
}
// Split remaining path
const parts = path.split('/').filter(Boolean)
let currentPath = segments.length > 0 ? segments[0].path : ''
for (const part of parts) {
currentPath = currentPath.endsWith('/') ? currentPath + part : currentPath + '/' + part
segments.push({ name: part, path: currentPath })
}
return segments
}
const breadcrumbs = directoryData?.current_path ? getBreadcrumbs(directoryData.current_path) : []
return (
<div className="flex flex-col h-full max-h-[70vh]">
{/* Header with breadcrumb navigation */}
<div className="flex-shrink-0 p-4 border-b bg-card">
<div className="flex items-center gap-2 mb-3">
<Folder size={20} className="text-primary" />
<span className="font-semibold">Select Project Folder</span>
</div>
{/* Breadcrumb navigation */}
<div className="flex items-center gap-1 flex-wrap text-sm">
{directoryData?.parent_path && (
<Button
variant="ghost"
size="icon-sm"
onClick={handleNavigateUp}
title="Go up"
>
<ArrowLeft size={16} />
</Button>
)}
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path} className="flex items-center">
{index > 0 && <ChevronRight size={14} className="text-muted-foreground mx-1" />}
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigate(crumb.path)}
className={index === breadcrumbs.length - 1 ? 'font-semibold' : ''}
>
{crumb.name}
</Button>
</div>
))}
</div>
</div>
{/* Drive selector (Windows only) */}
{directoryData?.drives && directoryData.drives.length > 0 && (
<div className="flex-shrink-0 p-3 border-b bg-muted/50">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Drives:</span>
{directoryData.drives.map((drive) => (
<Button
key={drive.letter}
variant={currentPath?.startsWith(drive.letter) ? 'default' : 'outline'}
size="sm"
onClick={() => handleDriveSelect(drive)}
>
<HardDrive size={14} />
{drive.letter}: {drive.label && `(${drive.label})`}
</Button>
))}
</div>
</div>
)}
{/* Directory listing */}
<div className="flex-1 min-h-0 overflow-y-auto bg-card">
<div className="p-2">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : error ? (
<div className="p-4 text-center">
<AlertCircle size={32} className="mx-auto mb-2 text-destructive" />
<p className="text-destructive">
{error instanceof Error ? error.message : 'Failed to load directory'}
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-2">
Retry
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-1">
{/* Directory entries - only show directories */}
{directoryData?.entries
.filter((entry) => entry.is_directory)
.map((entry) => (
<button
key={entry.path}
onClick={() => handleEntryClick(entry)}
onDoubleClick={() => handleNavigate(entry.path)}
className={`
w-full text-left p-2 rounded-md
flex items-center gap-2
hover:bg-muted
border-2 border-transparent transition-colors
${selectedPath === entry.path ? 'bg-primary/10 border-primary' : ''}
`}
>
{selectedPath === entry.path ? (
<FolderOpen size={18} className="text-primary flex-shrink-0" />
) : (
<Folder size={18} className="text-muted-foreground flex-shrink-0" />
)}
<span className="truncate flex-1">{entry.name}</span>
{entry.has_children && (
<ChevronRight size={14} className="ml-auto text-muted-foreground flex-shrink-0" />
)}
</button>
))}
{/* Empty state */}
{directoryData?.entries.filter((e) => e.is_directory).length === 0 && (
<div className="p-4 text-center text-muted-foreground">
<Folder size={32} className="mx-auto mb-2 opacity-50" />
<p>No subfolders</p>
<p className="text-sm">You can create a new folder or select this directory.</p>
</div>
)}
</div>
)}
{/* New folder creation */}
{isCreatingFolder && (
<Card className="mt-2">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<FolderPlus size={18} className="text-primary" />
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
autoFocus
onKeyDown={(e) => {
if (isSubmitEnter(e, false)) handleCreateFolder()
if (e.key === 'Escape') {
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}
}}
/>
<Button onClick={handleCreateFolder} size="sm">
Create
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}}
>
Cancel
</Button>
</div>
{createError && (
<p className="text-sm text-destructive mt-1">{createError}</p>
)}
</CardContent>
</Card>
)}
</div>
</div>
{/* Footer with selected path and actions */}
<div className="flex-shrink-0 p-4 border-t bg-card">
{/* Selected path display */}
<Card className="mb-3">
<CardContent className="p-2">
<div className="text-xs text-muted-foreground mb-1">Selected path:</div>
<div className="font-mono text-sm truncate">{selectedPath || 'No folder selected'}</div>
{selectedPath && (
<div className="text-xs text-muted-foreground mt-2 italic">
This folder will contain all project files
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<Button
variant="outline"
onClick={() => setIsCreatingFolder(true)}
disabled={isCreatingFolder}
>
<FolderPlus size={16} />
New Folder
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSelect} disabled={!selectedPath}>
Select This Folder
</Button>
</div>
</div>
</div>
</div>
)
}