import { useState, useCallback, useRef, useEffect } from 'react'; import { useI18n } from '../../i18n'; import { DialogShell } from './DialogShell'; import { Button } from '../ui/button'; import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, } from '../ui/field'; import { Input } from '../ui/input'; import { Switch } from '../ui/switch'; import { FolderOpenIcon } from 'lucide-react'; export interface WorkspacePathSuggestion { name: string; path: string; } export interface WorkspacePathSuggestions { dir: string; sep: string; suggestions: WorkspacePathSuggestion[]; truncated: boolean; } interface AddWorkspaceDialogProps { onClose: () => void; onAdd: (cwd: string, persist: boolean, displayName?: string) => Promise; displayNameEnabled?: boolean; /** * Directory autocomplete backend. When provided, typing an absolute path * surfaces matching subdirectories in a listbox under the input. */ onSuggest?: (prefix: string) => Promise; onPick?: () => Promise; persistenceSupported?: boolean; } const HINT_ID = 'add-workspace-hint'; const DISPLAY_NAME_HINT_ID = 'add-workspace-display-name-hint'; const ERROR_ID = 'add-workspace-error'; const LISTBOX_ID = 'add-workspace-suggestions'; const SUGGEST_DEBOUNCE_MS = 150; function isAbsoluteLike(value: string): boolean { return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value); } export function AddWorkspaceDialog({ onClose, onAdd, displayNameEnabled = false, onSuggest, onPick, persistenceSupported = true, }: AddWorkspaceDialogProps) { const { t } = useI18n(); const [path, setPath] = useState(''); const [displayName, setDisplayName] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [persist, setPersist] = useState(true); const [suggestions, setSuggestions] = useState([]); const [listOpen, setListOpen] = useState(false); const [highlight, setHighlight] = useState(-1); const [hostSep, setHostSep] = useState('/'); const [browsing, setBrowsing] = useState(false); const inputRef = useRef(null); const listOpenRef = useRef(false); listOpenRef.current = listOpen && suggestions.length > 0; const suggestSeqRef = useRef(0); // Set when a suggestion is accepted or the list is dismissed, so the // path-change effect knows whether to reopen the list for that update. const suppressNextFetchOpenRef = useRef(false); useEffect(() => { inputRef.current?.focus(); }, []); const closeList = useCallback(() => { setListOpen(false); setHighlight(-1); }, []); // Debounced suggestion fetch, keyed off the current path value. A stale // response (older sequence number) never overwrites a newer one. useEffect(() => { if (!onSuggest) return undefined; if (!isAbsoluteLike(path)) { setSuggestions([]); closeList(); return undefined; } const seq = ++suggestSeqRef.current; const openOnResult = !suppressNextFetchOpenRef.current; suppressNextFetchOpenRef.current = false; const timer = setTimeout(() => { onSuggest(path).then( (result) => { if (seq !== suggestSeqRef.current) return; setSuggestions(result.suggestions); setHostSep(result.sep || '/'); setHighlight(-1); if (openOnResult || listOpenRef.current) { setListOpen(result.suggestions.length > 0); } }, () => { if (seq !== suggestSeqRef.current) return; // Autocomplete is best-effort; a failed lookup just yields no list. setSuggestions([]); closeList(); }, ); }, SUGGEST_DEBOUNCE_MS); return () => clearTimeout(timer); }, [path, onSuggest, closeList]); // Radix listens for Escape on document capture; intercept one step earlier // (window capture) so an open suggestion list consumes the Escape instead // of closing the whole dialog (DialogShell skips `defaultPrevented`). useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key !== 'Escape' || !listOpenRef.current) return; if (event.isComposing || event.keyCode === 229) return; event.preventDefault(); closeList(); }; window.addEventListener('keydown', handler, { capture: true }); return () => window.removeEventListener('keydown', handler, { capture: true }); }, [closeList]); const acceptSuggestion = useCallback( (suggestion: WorkspacePathSuggestion) => { // Append the host separator so the next keystroke (or the immediate // refetch below) descends into the accepted directory. suppressNextFetchOpenRef.current = false; setPath(suggestion.path + hostSep); setError(null); closeList(); inputRef.current?.focus(); }, [hostSep, closeList], ); const pickDirectory = useCallback(async () => { if (!onPick) return; setBrowsing(true); setError(null); try { const selectedPath = await onPick(); if (selectedPath) { ++suggestSeqRef.current; setPath(selectedPath); setSuggestions([]); closeList(); } } catch { setError(t('sidebar.addWorkspaceBrowseError')); } finally { setBrowsing(false); } }, [onPick, closeList, t]); const handleInputKeyDown = useCallback( (event: React.KeyboardEvent) => { const open = listOpen && suggestions.length > 0; if (event.key === 'ArrowDown') { event.preventDefault(); if (!open) { if (suggestions.length > 0) setListOpen(true); return; } setHighlight((current) => (current + 1) % suggestions.length); return; } if (event.key === 'ArrowUp') { if (!open) return; event.preventDefault(); setHighlight( (current) => (current <= 0 ? suggestions.length : current) - 1, ); return; } if (event.key === 'Tab' && open && !event.shiftKey) { const target = highlight >= 0 ? suggestions[highlight] : suggestions.length === 1 ? suggestions[0] : undefined; if (target) { event.preventDefault(); acceptSuggestion(target); } return; } if (event.key === 'Enter' && open && highlight >= 0) { // Enter accepts the highlighted directory instead of submitting. event.preventDefault(); acceptSuggestion(suggestions[highlight]); } }, [listOpen, suggestions, highlight, acceptSuggestion], ); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); const trimmed = path.trim(); if (!trimmed) return; if (!isAbsoluteLike(trimmed)) { setError(t('sidebar.addWorkspaceAbsError')); return; } setError(null); setSubmitting(true); closeList(); try { const effectivePersist = persistenceSupported ? persist : false; const trimmedDisplayName = displayNameEnabled ? displayName.trim() : ''; if (trimmedDisplayName) { await onAdd(trimmed, effectivePersist, trimmedDisplayName); } else { await onAdd(trimmed, effectivePersist); } onClose(); } catch (err) { setError( err instanceof Error ? err.message : t('sidebar.addWorkspaceError'), ); } finally { setSubmitting(false); } }, [ path, displayName, displayNameEnabled, persist, persistenceSupported, onAdd, onClose, closeList, t, ], ); const showList = listOpen && suggestions.length > 0; return (
{t('sidebar.addWorkspacePath')}
{ setPath(e.target.value); if (error) setError(null); }} onKeyDown={handleInputKeyDown} onBlur={() => { // Delay so a mousedown on a suggestion wins over blur. setTimeout(() => { suppressNextFetchOpenRef.current = true; closeList(); }, 100); }} disabled={submitting || browsing} autoCapitalize="off" autoCorrect="off" autoComplete="off" spellCheck={false} role="combobox" aria-expanded={showList} aria-controls={showList ? LISTBOX_ID : undefined} aria-activedescendant={ showList && highlight >= 0 ? `${LISTBOX_ID}-${highlight}` : undefined } aria-describedby={error ? `${ERROR_ID} ${HINT_ID}` : HINT_ID} aria-invalid={error ? true : undefined} /> {onPick && ( )}
{showList && (
    {suggestions.map((suggestion, index) => (
  • { // Keep focus in the input; blur would close the list // before click lands. event.preventDefault(); acceptSuggestion(suggestion); }} onMouseEnter={() => setHighlight(index)} > {suggestion.name} {hostSep}
  • ))}
)}
{t('sidebar.addWorkspaceHint')} {error && {error}}
{displayNameEnabled && ( {t('sidebar.addWorkspaceDisplayName')} setDisplayName(event.target.value)} disabled={submitting} maxLength={256} autoComplete="off" aria-describedby={DISPLAY_NAME_HINT_ID} /> {t('sidebar.addWorkspaceDisplayNameHint')} )} {persistenceSupported && ( {t('sidebar.addWorkspacePersist')} {t('sidebar.addWorkspacePersistHint')} )}
); }