import React from 'react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { IdentityDropdown } from '@/components/views/git/GitHeader'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import { opencodeClient } from '@/lib/opencode/client'; import { useI18n } from '@/lib/i18n'; import { formatShortcutForDisplay } from '@/lib/shortcuts'; import { isFilesystemError, type FilesystemErrorReason, } from '@/lib/api/files-errors'; interface DirectoryExplorerDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } type BrowseEntry = { name: string; path: string; }; type BrowseRow = | { type: 'up'; value: 'browse:up'; name: string; path: string | null; disabled?: false } | { type: 'directory'; value: string; name: string; path: string; disabled: boolean }; const isRootPath = (value: string): boolean => value === '/'; const normalizeSeparators = (value: string): string => value.replace(/\\/g, '/'); const trimTrailingSeparators = (value: string): string => { if (!value || isRootPath(value)) return value; let result = value; while (result.length > 1 && result.endsWith('/')) { result = result.slice(0, -1); } return result; }; const hasTrailingPathSeparator = (value: string): boolean => value.endsWith('/'); const ensureBrowseDirectoryPath = (value: string): string => { const trimmed = value.trim(); if (!trimmed || hasTrailingPathSeparator(trimmed)) return trimmed; return `${trimmed}/`; }; const getLastPathSeparatorIndex = (value: string): number => value.lastIndexOf('/'); const getBrowseDirectoryPath = (value: string): string => { if (hasTrailingPathSeparator(value)) return value; const lastSeparator = getLastPathSeparatorIndex(value); if (lastSeparator < 0) return value; return value.slice(0, lastSeparator + 1); }; const getBrowseLeafPathSegment = (value: string): string => { const lastSeparator = getLastPathSeparatorIndex(value); return value.slice(lastSeparator + 1); }; const getBrowseParentPath = (value: string): string | null => { const trimmed = trimTrailingSeparators(value.trim()); if (!trimmed || trimmed === '~' || trimmed === '~/' || trimmed === '/') return null; const lastSeparator = getLastPathSeparatorIndex(trimmed); if (lastSeparator < 0) return null; if (trimmed.startsWith('~/') && lastSeparator <= 1) return '~/'; if (lastSeparator === 0) return '/'; return `${trimmed.slice(0, lastSeparator)}/`; }; const canNavigateUp = (value: string): boolean => hasTrailingPathSeparator(value) && getBrowseParentPath(value) !== null; const appendBrowsePathSegment = (currentPath: string, segment: string): string => ( `${getBrowseDirectoryPath(currentPath)}${segment}/` ); const normalizeDirectoryPath = (path: string | null | undefined): string | null => { if (!path) return null; const normalized = trimTrailingSeparators(normalizeSeparators(path.trim())); if (!normalized) return null; return normalized.toLowerCase(); }; const displayPathToAbsolutePath = (value: string, homeDirectory: string): string => { const trimmed = value.trim(); if (trimmed === '~') return homeDirectory; if (trimmed.startsWith('~/')) return `${homeDirectory}${trimmed.slice(1)}`; return trimmed; }; const isPrimaryModifierPressed = (event: React.KeyboardEvent): boolean => { const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; }; const focusPathInput = (input: HTMLInputElement | null): void => { if (!input) return; input.focus({ preventScroll: true }); const valueLength = input.value.length; input.setSelectionRange(valueLength, valueLength); input.scrollLeft = input.scrollWidth; }; const resolveFreshFilesystemHome = async (): Promise => { try { const response = await runtimeFetch('/api/fs/home', { method: 'GET', headers: { Accept: 'application/json' }, }); if (response.ok) { const data = await response.json() as { home?: unknown }; if (typeof data.home === 'string' && data.home.trim().length > 0) { return normalizeSeparators(data.home.trim()); } } } catch { // Fall back to the client helper below. } return opencodeClient.getFilesystemHome().catch(() => null); }; export const DirectoryExplorerDialog: React.FC = ({ open, onOpenChange, }) => { const { t } = useI18n(); const homeDirectory = useDirectoryStore((s) => s.homeDirectory); const projects = useProjectsStore((s) => s.projects); const addProject = useProjectsStore((s) => s.addProject); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const addProjects = useProjectsStore((s) => s.addProjects); const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles); const globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity); const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles); const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity); const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId); const { canRequestAccess, requestAccess, startAccessing } = useFileSystemAccess(); const { isMobile } = useDeviceInfo(); const inputRef = React.useRef(null); const addButtonRef = React.useRef(null); const rowRefs = React.useRef(new Map()); const [dialogHomeDirectory, setDialogHomeDirectory] = React.useState(''); const [query, setQuery] = React.useState('~/'); const [entries, setEntries] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false); const [browseErrorReason, setBrowseErrorReason] = React.useState(null); const [browseReloadKey, setBrowseReloadKey] = React.useState(0); const [highlightedIndex, setHighlightedIndex] = React.useState(0); const [isConfirming, setIsConfirming] = React.useState(false); const [isOpeningFinder, setIsOpeningFinder] = React.useState(false); const [addButtonWidth, setAddButtonWidth] = React.useState(0); const [isCloneMode, setIsCloneMode] = React.useState(false); const [cloneRemoteUrl, setCloneRemoteUrl] = React.useState(''); const [selectedGitIdentityId, setSelectedGitIdentityId] = React.useState(null); const [showHidden, setShowHidden] = React.useState(false); const [selectedPaths, setSelectedPaths] = React.useState([]); const explorerRootDirectory = dialogHomeDirectory || homeDirectory; const addedProjectPaths = React.useMemo(() => new Set( projects .map((project) => normalizeDirectoryPath(project.path)) .filter((path): path is string => Boolean(path)) ), [projects]); React.useEffect(() => { if (!open) return; setQuery('~/'); setEntries([]); setHighlightedIndex(0); setIsConfirming(false); setIsOpeningFinder(false); setIsCloneMode(false); setCloneRemoteUrl(''); setSelectedGitIdentityId(null); setShowHidden(false); setSelectedPaths([]); requestAnimationFrame(() => focusPathInput(inputRef.current)); let cancelled = false; const resolveHome = async () => { const resolved = await resolveFreshFilesystemHome(); if (cancelled) return; setDialogHomeDirectory(resolved || homeDirectory || ''); requestAnimationFrame(() => focusPathInput(inputRef.current)); }; void resolveHome(); return () => { cancelled = true; }; }, [homeDirectory, open]); React.useEffect(() => { if (!open) return; void loadGitIdentityProfiles(); void loadGlobalGitIdentity(); void loadDefaultGitIdentityId(); }, [loadDefaultGitIdentityId, loadGitIdentityProfiles, loadGlobalGitIdentity, open]); const availableGitIdentities = React.useMemo(() => { const unique = new Map>(); if (globalGitIdentity) { unique.set(globalGitIdentity.id, globalGitIdentity); } for (const profile of gitIdentityProfiles) { unique.set(profile.id, profile); } return Array.from(unique.values()); }, [gitIdentityProfiles, globalGitIdentity]); React.useEffect(() => { if (!open || !isCloneMode || selectedGitIdentityId !== null) return; const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : ''; if (defaultId && availableGitIdentities.some((identity) => identity.id === defaultId)) { setSelectedGitIdentityId(defaultId); return; } const firstSshIdentity = availableGitIdentities.find((identity) => identity.authType === 'ssh' || identity.sshKey); if (firstSshIdentity) { setSelectedGitIdentityId(firstSshIdentity.id); } }, [availableGitIdentities, defaultGitIdentityId, isCloneMode, open, selectedGitIdentityId]); const selectedGitIdentity = React.useMemo( () => availableGitIdentities.find((identity) => identity.id === selectedGitIdentityId) ?? null, [availableGitIdentities, selectedGitIdentityId] ); const browseDirectoryDisplayPath = React.useMemo(() => getBrowseDirectoryPath(query), [query]); const browseFilterQuery = React.useMemo( () => (hasTrailingPathSeparator(query) ? '' : getBrowseLeafPathSegment(query)), [query] ); const browseDirectoryAbsolutePath = React.useMemo( () => explorerRootDirectory ? displayPathToAbsolutePath(browseDirectoryDisplayPath, explorerRootDirectory) : '', [browseDirectoryDisplayPath, explorerRootDirectory] ); React.useEffect(() => { if (!open || !browseDirectoryAbsolutePath) { setEntries([]); setBrowseErrorReason(null); return; } let cancelled = false; setIsLoading(true); setIsBrowseDirectoryMissing(false); setBrowseErrorReason(null); opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath) .then((result) => { if (cancelled) return; setIsBrowseDirectoryMissing(false); setBrowseErrorReason(null); const nextEntries = result .filter((entry) => entry.isDirectory) .map((entry) => ({ name: entry.name, path: normalizeSeparators(entry.path), })) .sort((left, right) => left.name.localeCompare(right.name)); setEntries(nextEntries); }) .catch((error) => { if (!cancelled) { setEntries([]); const reason = isFilesystemError(error) ? error.reason : 'unknown'; setBrowseErrorReason(reason); setIsBrowseDirectoryMissing(reason === 'not-found'); } }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; }, [browseDirectoryAbsolutePath, browseReloadKey, open]); const filteredEntries = React.useMemo(() => { const lowerFilter = browseFilterQuery.toLowerCase(); const includeHidden = showHidden || browseFilterQuery.startsWith('.'); return entries.filter((entry) => ( entry.name.toLowerCase().startsWith(lowerFilter) && (includeHidden || !entry.name.startsWith('.')) )); }, [browseFilterQuery, entries, showHidden]); const rows = React.useMemo(() => { const nextRows: BrowseRow[] = []; if (canNavigateUp(query)) { nextRows.push({ type: 'up', value: 'browse:up', name: '..', path: getBrowseParentPath(query) }); } for (const entry of filteredEntries) { const normalized = normalizeDirectoryPath(entry.path); nextRows.push({ type: 'directory', value: `browse:${entry.path}`, name: entry.name, path: entry.path, disabled: Boolean(normalized && addedProjectPaths.has(normalized)), }); } return nextRows; }, [addedProjectPaths, filteredEntries, query]); React.useEffect(() => { setHighlightedIndex(0); }, [query, rows.length]); // Selections apply to the currently browsed directory: navigating into // another folder clears the pending batch so the primary action always // reflects the visible picker state. React.useEffect(() => { setSelectedPaths([]); }, [browseDirectoryAbsolutePath]); const selectionPaths = React.useMemo( () => selectedPaths.filter((path) => { const normalized = normalizeDirectoryPath(path); return Boolean(normalized && !addedProjectPaths.has(normalized)); }), [addedProjectPaths, selectedPaths] ); const togglePathSelection = React.useCallback((path: string) => { setSelectedPaths((prev) => ( prev.includes(path) ? prev.filter((entry) => entry !== path) : [...prev, path] )); }, []); const targetPath = React.useMemo(() => { if (!explorerRootDirectory) return ''; return trimTrailingSeparators(displayPathToAbsolutePath(query, explorerRootDirectory)); }, [explorerRootDirectory, query]); const normalizedTargetPath = normalizeDirectoryPath(targetPath); const isAlreadyAdded = Boolean(normalizedTargetPath && addedProjectPaths.has(normalizedTargetPath)); const exactEntry = React.useMemo(() => { if (!browseFilterQuery) return null; return filteredEntries.find((entry) => entry.name === browseFilterQuery) ?? null; }, [browseFilterQuery, filteredEntries]); const shouldCreateTarget = Boolean( targetPath && !isAlreadyAdded && (browseErrorReason === null || browseErrorReason === 'not-found') && ( (hasTrailingPathSeparator(query) && isBrowseDirectoryMissing) || (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null) ) ); const canAddProject = !isConfirming && !isOpeningFinder && browseErrorReason !== 'os-permission' && browseErrorReason !== 'invalid-response' && browseErrorReason !== 'unknown' && ((!isCloneMode && selectionPaths.length > 0) || (!isAlreadyAdded && Boolean(targetPath))); const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0; const highlightedRow = rows[highlightedIndex] ?? null; const hasHighlightedBrowseItem = Boolean( highlightedRow && (highlightedRow.type === 'up' || highlightedRow.type === 'directory') ); const submitModifierLabel = formatShortcutForDisplay('mod'); const submitActionLabel = !isCloneMode && selectionPaths.length > 0 ? t('directoryExplorerDialog.actions.addSelected') : isAlreadyAdded ? t('directoryExplorerDialog.actions.alreadyAdded') : isCloneMode ? isConfirming ? t('directoryExplorerDialog.actions.cloning') : t('directoryExplorerDialog.actions.cloneAndAdd') : isConfirming ? t('directoryExplorerDialog.actions.adding') : shouldCreateTarget ? t('directoryExplorerDialog.actions.createAndAdd') : t('directoryExplorerDialog.actions.addProject'); React.useLayoutEffect(() => { const button = addButtonRef.current; if (!button) return; const updateWidth = () => setAddButtonWidth(Math.ceil(button.getBoundingClientRect().width)); updateWidth(); if (typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(updateWidth); observer.observe(button); return () => observer.disconnect(); }, [submitActionLabel]); React.useLayoutEffect(() => { const input = inputRef.current; if (!input) return; input.scrollLeft = input.scrollWidth; }, [addButtonWidth, query]); React.useLayoutEffect(() => { if (!open) return; focusPathInput(inputRef.current); }, [open]); React.useLayoutEffect(() => { const row = rows[highlightedIndex]; if (!row) return; rowRefs.current.get(row.value)?.scrollIntoView({ block: 'nearest' }); }, [highlightedIndex, rows]); const handleClose = React.useCallback(() => { onOpenChange(false); }, [onOpenChange]); const openProjectDraft = React.useCallback((projectId: string, projectPath: string) => { if (isMobile) setSessionSwitcherOpen(false); openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath }); handleClose(); }, [handleClose, isMobile, openNewSessionDraft, setSessionSwitcherOpen]); const handleQuickAdd = React.useCallback(async (event: React.MouseEvent, path: string) => { event.stopPropagation(); const normalized = normalizeDirectoryPath(path); if (normalized && addedProjectPaths.has(normalized)) return; const project = await addProject(path); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); return; } openProjectDraft(project.id, project.path); }, [addProject, addedProjectPaths, openProjectDraft, t]); const finalizeSelection = React.useCallback(async (target: string) => { if (isConfirming) return; const normalized = normalizeDirectoryPath(target); // Batch selections supersede the single-target flow. Only the single-target // flow is blocked by an already-added (or missing) directory. const selectionToAdd = isCloneMode ? [] : selectedPaths.filter((path) => { const selectionNormalized = normalizeDirectoryPath(path); return Boolean(selectionNormalized && !addedProjectPaths.has(selectionNormalized)); }); if (selectionToAdd.length === 0 && (!target || (normalized && addedProjectPaths.has(normalized)))) return; let selectedTarget = target; setIsConfirming(true); try { const shouldCreateSelection = !isCloneMode && shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath); if (isCloneMode) { const remoteUrl = cloneRemoteUrl.trim(); if (!remoteUrl) { toast.error(t('directoryExplorerDialog.toast.cloneUrlRequired')); return; } const result = await opencodeClient.cloneRepository({ remoteUrl, destinationPath: target, gitIdentityId: selectedGitIdentity?.id ?? null, }); selectedTarget = result.path; } else if (selectionToAdd.length > 0) { // Batch path wins over single-target create: with checkboxes ticked, // the user wants the selections added, not a fresh directory created // for whatever happens to be typed in the filter. const added = await addProjects(selectionToAdd); if (added.length === 0) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); return; } toast.success(t('directoryExplorerDialog.toast.addedProjects', { count: added.length })); setSelectedPaths([]); handleClose(); return; } else if (shouldCreateSelection) { await opencodeClient.createDirectory(target, { asProject: true }); } const project = await addProject(selectedTarget); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); return; } openProjectDraft(project.id, project.path); } catch (error) { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'), }); } finally { setIsConfirming(false); } }, [addProject, addProjects, addedProjectPaths, cloneRemoteUrl, handleClose, isCloneMode, isConfirming, openProjectDraft, selectedGitIdentity?.id, selectedPaths, shouldCreateTarget, targetPath, t]); const browseToDisplayPath = React.useCallback((displayPath: string) => { setQuery(ensureBrowseDirectoryPath(displayPath)); }, []); const browseToEntry = React.useCallback((entry: BrowseEntry) => { setQuery(appendBrowsePathSegment(query, entry.name)); }, [query]); const executeRow = React.useCallback((row: BrowseRow | null) => { if (!row) return; if (row.type === 'up') { if (row.path) browseToDisplayPath(row.path); return; } browseToEntry(row); }, [browseToDisplayPath, browseToEntry]); const handleOpenInFinder = React.useCallback(async () => { if (!canRequestAccess || isOpeningFinder) return; setIsOpeningFinder(true); try { const result = await requestAccess(targetPath); if (!result.success || !result.path) { if (result.error && result.error !== 'Directory selection cancelled') { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { description: result.error, }); } return; } const accessResult = await startAccessing(result.path); if (!accessResult.success) { toast.error(t('directoryExplorerDialog.toast.failedToOpenDirectory'), { description: accessResult.error || t('directoryExplorerDialog.toast.desktopCouldNotGrantAccess'), }); return; } // Clear pending selections so the Finder-sourced target is honored // instead of silently being absorbed by the batch branch. setSelectedPaths([]); await finalizeSelection(result.path); } catch (error) { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'), }); } finally { setIsOpeningFinder(false); } }, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]); const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { event.preventDefault(); setHighlightedIndex((index) => Math.min(rows.length - 1, index + 1)); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setHighlightedIndex((index) => Math.max(0, index - 1)); return; } if (event.key === ' ') { // Only treat Space as a selection toggle when the user is actively // browsing a directory (trailing slash or no filter typing). When // the input is in path-entry mode, Space is a literal character // and must reach the input value. if (hasTrailingPathSeparator(query)) { event.preventDefault(); if (highlightedRow && highlightedRow.type === 'directory' && !highlightedRow.disabled) { togglePathSelection(highlightedRow.path); } return; } return; } if (event.key === 'Enter') { event.preventDefault(); if (isPrimaryModifierPressed(event)) { void finalizeSelection(targetPath); return; } if (hasHighlightedBrowseItem) { executeRow(highlightedRow); } return; } if (event.key === 'Backspace' && query === '') { event.preventDefault(); handleClose(); } }, [executeRow, finalizeSelection, handleClose, hasHighlightedBrowseItem, highlightedRow, query, rows.length, targetPath, togglePathSelection]); const showHiddenToggle = ( ); const inputSection = (
{isCloneMode ? (
setCloneRemoteUrl(event.target.value)} placeholder={t('directoryExplorerDialog.clone.remoteUrlPlaceholder')} className="min-w-0 flex-1 border-border/60 bg-[var(--surface-elevated)] font-mono typography-ui-label shadow-none" spellCheck={false} autoComplete="off" autoCorrect="off" autoCapitalize="off" /> setSelectedGitIdentityId(profile.id)} isApplying={isConfirming} iconOnly />
) : null}
setQuery(normalizeSeparators(event.target.value))} onKeyDown={handleKeyDown} placeholder={t('directoryExplorerDialog.pathInput.placeholder')} className="border-transparent bg-transparent pl-9 font-mono typography-ui-label shadow-none focus-visible:ring-0" style={!isMobile && addButtonWidth > 0 ? { paddingRight: `${addButtonWidth + 24}px` } : undefined} spellCheck={false} autoComplete="off" autoCorrect="off" autoCapitalize="off" /> {!isMobile ? ( ) : null}
); const resultsSection = (
{t('directoryExplorerDialog.browse.directories')}
{isLoading ? (
{t('directoryExplorerDialog.browse.loading')}
) : browseErrorReason && browseErrorReason !== 'not-found' ? (
{browseErrorReason === 'os-permission' ? t('directoryExplorerDialog.browse.permissionDenied') : t('directoryExplorerDialog.browse.loadFailed')}
{browseErrorReason === 'os-permission' && canRequestAccess ? ( ) : null}
) : rows.length === 0 ? (
{t('directoryExplorerDialog.browse.empty')}
) : (
{rows.map((row, index) => { const isActive = index === highlightedIndex; return ( ) : null} ); })}
)}
); const content = (
{inputSection} {resultsSection}
); const footerHints = (
{t('directoryExplorerDialog.footer.navigate')} {t('directoryExplorerDialog.footer.select')} {submitModifierLabel} {t('directoryExplorerDialog.footer.add')}
); const renderFooter = () => ( <> {!isMobile ? footerHints : null}
{canRequestAccess ? ( ) : null} {isMobile ? ( ) : null}
); if (isMobile) { return ( {renderFooter()}} >
{showHiddenToggle}
{content}
); } return (
{t('directoryExplorerDialog.title')} {t('directoryExplorerDialog.description')}
{showHiddenToggle}
{content}
{renderFooter()}
); };