From e134f0f14e8b54ec61406cf8cdc70561b1fb7765 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 8 Jan 2026 23:53:03 +0200 Subject: [PATCH] feat: implement DirectoryAutocomplete component and integrate it into DirectoryExplorerDialog for enhanced path selection --- CHANGELOG.md | 1 + .../session/DirectoryAutocomplete.tsx | 314 ++++++++++++++++++ .../session/DirectoryExplorerDialog.tsx | 132 +++++--- .../src/components/session/DirectoryTree.tsx | 149 +++++---- 4 files changed, 479 insertions(+), 117 deletions(-) create mode 100644 packages/ui/src/components/session/DirectoryAutocomplete.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 65269f29..9c0d1c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Autocomplete: added scope badges for commands/agents/skills. - Compact: changed /summarize command to be /compact and use sdk for compaction. - MCP: added ability to dynamically enabled/disabled configured MCP. +- Web: refactored project adding UI with autocomplete. ## [1.4.4] - 2026-01-08 diff --git a/packages/ui/src/components/session/DirectoryAutocomplete.tsx b/packages/ui/src/components/session/DirectoryAutocomplete.tsx new file mode 100644 index 00000000..30903679 --- /dev/null +++ b/packages/ui/src/components/session/DirectoryAutocomplete.tsx @@ -0,0 +1,314 @@ +import React from 'react'; +import { RiFolderLine, RiRefreshLine } from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { opencodeClient, type FilesystemEntry } from '@/lib/opencode/client'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; + +interface DirectoryAutocompleteProps { + inputValue: string; + homeDirectory: string | null; + onSelectSuggestion: (path: string) => void; + visible: boolean; + onClose: () => void; + showHidden: boolean; +} + +export interface DirectoryAutocompleteHandle { + handleKeyDown: (e: React.KeyboardEvent) => boolean; +} + +export const DirectoryAutocomplete = React.forwardRef(({ + inputValue, + homeDirectory, + onSelectSuggestion, + visible, + onClose, + showHidden, +}, ref) => { + const [suggestions, setSuggestions] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const containerRef = React.useRef(null); + const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + + // Fuzzy matching score - returns null if no match, higher score = better match + const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { + const q = query.trim().toLowerCase(); + if (!q) { + return 0; + } + + const c = candidate.toLowerCase(); + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i += 1) { + const ch = q[i]; + if (!ch || ch === ' ') { + continue; + } + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; // Character not found - no match + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive += 1; + } else { + consecutive = 0; + } + + score += 10; // Base score per matched char + score += Math.max(0, 18 - idx); // Bonus for early matches + score -= Math.max(0, gap); // Penalty for gaps + + // Bonus for match at start or after separator + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.round(c.length / 3)); // Shorter names score higher + return score; + }, []); + + // Expand ~ to home directory + const expandPath = React.useCallback((path: string): string => { + if (path.startsWith('~') && homeDirectory) { + return path.replace(/^~/, homeDirectory); + } + return path; + }, [homeDirectory]); + + // Get the directory part of the path for listing + const getParentDir = React.useCallback((path: string): string => { + const expanded = expandPath(path); + // If ends with /, list that directory + if (expanded.endsWith('/')) { + return expanded; + } + // Otherwise, get parent directory + const lastSlash = expanded.lastIndexOf('/'); + if (lastSlash === -1) return ''; + if (lastSlash === 0) return '/'; + return expanded.substring(0, lastSlash + 1); + }, [expandPath]); + + // Get the partial name being typed (for filtering) + const getPartialName = React.useCallback((path: string): string => { + const expanded = expandPath(path); + if (expanded.endsWith('/')) return ''; + const lastSlash = expanded.lastIndexOf('/'); + if (lastSlash === -1) return expanded; + return expanded.substring(lastSlash + 1); + }, [expandPath]); + + const debouncedInputValue = useDebouncedValue(inputValue, 150); + + // Fetch directory suggestions + React.useEffect(() => { + if (!visible || !debouncedInputValue) { + setSuggestions([]); + return; + } + + const parentDir = getParentDir(debouncedInputValue); + const partialName = getPartialName(debouncedInputValue).toLowerCase(); + + if (!parentDir) { + setSuggestions([]); + return; + } + + let cancelled = false; + setLoading(true); + + opencodeClient.listLocalDirectory(parentDir) + .then((entries) => { + if (cancelled) return; + + // Filter to directories only, respect hidden setting + const directories = entries.filter((entry) => { + if (!entry.isDirectory) return false; + if (!showHidden && entry.name.startsWith('.')) return false; + return true; + }); + + // Apply fuzzy matching and sort by score + const scored = partialName + ? directories + .map((entry) => { + const score = fuzzyScore(partialName, entry.name); + return score !== null ? { entry, score } : null; + }) + .filter((item): item is { entry: FilesystemEntry; score: number } => item !== null) + .sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name)) + .map((item) => item.entry) + : directories.sort((a, b) => a.name.localeCompare(b.name)); + + setSuggestions(scored.slice(0, 10)); // Limit suggestions + setSelectedIndex(0); + }) + .catch(() => { + if (!cancelled) { + setSuggestions([]); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [visible, debouncedInputValue, getParentDir, getPartialName, showHidden, fuzzyScore]); + + // Scroll selected item into view + React.useEffect(() => { + itemRefs.current[selectedIndex]?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + }, [selectedIndex]); + + // Handle outside click + React.useEffect(() => { + if (!visible) return; + + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null; + if (!target || !containerRef.current) return; + if (containerRef.current.contains(target)) return; + onClose(); + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + }; + }, [visible, onClose]); + + const handleSelectSuggestion = React.useCallback((entry: FilesystemEntry) => { + // Append the selected directory name to current path, with trailing slash + const path = entry.path.endsWith('/') ? entry.path : entry.path + '/'; + onSelectSuggestion(path); + }, [onSelectSuggestion]); + + // Expose key handler to parent + React.useImperativeHandle(ref, () => ({ + handleKeyDown: (e: React.KeyboardEvent): boolean => { + if (!visible || suggestions.length === 0) { + return false; + } + + const total = suggestions.length; + + if (e.key === 'Tab') { + e.preventDefault(); + if (e.shiftKey) { + // Shift+Tab: previous suggestion + setSelectedIndex((prev) => (prev - 1 + total) % total); + } else { + // Tab: next suggestion or select if only one + if (total === 1) { + const selected = suggestions[0]; + if (selected) { + handleSelectSuggestion(selected); + } + } else { + setSelectedIndex((prev) => (prev + 1) % total); + } + } + return true; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % total); + return true; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + total) % total); + return true; + } + + if (e.key === 'Enter') { + e.preventDefault(); + // Select current item and close autocomplete + const safeIndex = ((selectedIndex % total) + total) % total; + const selected = suggestions[safeIndex]; + if (selected) { + handleSelectSuggestion(selected); + } + onClose(); + return true; // Consume the event, don't let parent confirm yet + } + + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return true; + } + + return false; + } + }), [visible, suggestions, selectedIndex, handleSelectSuggestion, onClose]); + + if (!visible || (suggestions.length === 0 && !loading)) { + return null; + } + + return ( +
+ {loading ? ( +
+ +
+ ) : ( +
+ {suggestions.map((entry, index) => { + const isSelected = selectedIndex === index; + return ( +
{ itemRefs.current[index] = el; }} + className={cn( + "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label", + isSelected && "bg-muted" + )} + onClick={() => { handleSelectSuggestion(entry); onClose(); }} + onMouseEnter={() => setSelectedIndex(index)} + > + + {entry.name} +
+ ); + })} +
+ )} +
+ Tab cycle • ↑↓ navigate • Enter select +
+
+ ); +}); + +DirectoryAutocomplete.displayName = 'DirectoryAutocomplete'; diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 7db1b6a1..b926a7df 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -19,9 +19,9 @@ import { RiCheckboxBlankLine, RiCheckboxLine, } from '@remixicon/react'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { DirectoryAutocomplete, type DirectoryAutocompleteHandle } from './DirectoryAutocomplete'; const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden'; @@ -57,6 +57,8 @@ export const DirectoryExplorerDialog: React.FC = ( }); const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess(); const { isMobile } = useDeviceInfo(); + const [autocompleteVisible, setAutocompleteVisible] = React.useState(false); + const autocompleteRef = React.useRef(null); // Helper to format path for display const formatPath = React.useCallback((path: string | null) => { @@ -69,6 +71,7 @@ export const DirectoryExplorerDialog: React.FC = ( if (open) { setHasUserSelection(false); setIsConfirming(false); + setAutocompleteVisible(false); // Initialize with active project or current directory const activeProject = getActiveProject(); const initialPath = activeProject?.path || currentDirectory || homeDirectory || ''; @@ -182,6 +185,8 @@ export const DirectoryExplorerDialog: React.FC = ( const value = e.target.value; setPathInputValue(value); setHasUserSelection(true); + // Show autocomplete when typing a path + setAutocompleteVisible(value.startsWith('/') || value.startsWith('~')); // Update pending path if it looks like a valid path if (value.startsWith('/') || value.startsWith('~')) { // Expand ~ to home directory @@ -193,62 +198,38 @@ export const DirectoryExplorerDialog: React.FC = ( }, [homeDirectory]); const handlePathInputKeyDown = React.useCallback((e: React.KeyboardEvent) => { + // Let autocomplete handle the key first if visible + if (autocompleteRef.current?.handleKeyDown(e)) { + return; + } if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); } }, [handleConfirm]); + const handleAutocompleteSuggestion = React.useCallback((path: string) => { + setPendingPath(path); + setHasUserSelection(true); + setPathInputValue(formatPath(path)); + // Keep autocomplete open to allow further drilling down + }, [formatPath]); + + const handleAutocompleteClose = React.useCallback(() => { + setAutocompleteVisible(false); + }, []); + const toggleShowHidden = React.useCallback(() => { setShowHidden(prev => !prev); }, []); - const dialogHeader = ( - - Add project directory - - Choose a folder to add as a project. - - - ); - - const pathInputSection = ( - - ); - - const treeSection = ( -
- -
- ); - const showHiddenToggle = ( ); + const dialogHeader = ( + + Add project directory +
+ + Choose a folder to add as a project. + + {showHiddenToggle} +
+
+ ); + + const pathInputSection = ( +
+ + +
+ ); + + const treeSection = ( +
+ +
+ ); + // Mobile: use flex layout where tree takes remaining space const mobileContent = (
@@ -266,13 +300,13 @@ export const DirectoryExplorerDialog: React.FC = (
{showHiddenToggle}
-
+
= ( ); const desktopContent = ( - +
{pathInputSection} -
- {showHiddenToggle} -
{treeSection} - +
); const renderActionButtons = () => ( @@ -345,7 +373,7 @@ export const DirectoryExplorerDialog: React.FC = ( {dialogHeader} {desktopContent} {renderActionButtons()} diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index c839cc26..b3a02e08 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -8,9 +8,10 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; -import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiFolder6Line, RiPushpin2Line, RiPushpinLine, RiStarLine } from '@remixicon/react'; +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 { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -54,6 +55,7 @@ export const DirectoryTree: React.FC = ({ alwaysShowActions = false, }) => { const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []); + const { isMobile } = useDeviceInfo(); const [directories, setDirectories] = React.useState([]); const [expandedPaths, setExpandedPaths] = React.useState>(new Set()); const [isLoading, setIsLoading] = React.useState(true); @@ -314,10 +316,12 @@ export const DirectoryTree: React.FC = ({ }); }, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]); + // Reload directories when showHidden changes, but keep expanded state React.useEffect(() => { if (previousShowHidden.current !== showHidden) { previousShowHidden.current = showHidden; - setExpandedPaths(new Set()); + // Silently reload without clearing state - loadInitialDirectories will be called + // via its dependency on loadDirectory which depends on showHidden } }, [showHidden]); @@ -434,6 +438,8 @@ export const DirectoryTree: React.FC = ({ } }, [showHidden, effectiveRoot, stripTrailingSlashes, rootReady]); + const hasLoadedOnce = React.useRef(false); + const loadInitialDirectories = React.useCallback(async () => { if (!rootReady || !effectiveRoot) { setIsLoading(true); @@ -441,10 +447,14 @@ export const DirectoryTree: React.FC = ({ return; } - setIsLoading(true); + // 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); } @@ -616,16 +626,16 @@ export const DirectoryTree: React.FC = ({ e.stopPropagation(); toggleExpanded(item); }} - className="p-0.5 hover:bg-accent rounded" + className={cn("hover:bg-accent rounded", isMobile ? "p-0.5" : "p-0.5")} > {isExpanded ? ( - + ) : ( - + )} )} - {!hasChildren &&
} + {!hasChildren &&
} @@ -710,10 +716,13 @@ export const DirectoryTree: React.FC = ({
{rowContent}
@@ -721,8 +730,8 @@ export const DirectoryTree: React.FC = ({ <> {creatingInPath === item.path && (
@@ -872,7 +881,11 @@ export const DirectoryTree: React.FC = ({
); @@ -969,29 +984,33 @@ export const DirectoryTree: React.FC = ({ {isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))} {variant === 'dropdown' && } {variant === 'inline' && isPinnedExpanded && ( -
+
)} )} -
- +
Browse
@@ -1015,8 +1034,8 @@ export const DirectoryTree: React.FC = ({ if (variant === 'inline') { return ( -
- +
+ {directoryContent}