import React from 'react'; 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 { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; 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'; 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 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 { isDesktop, 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 [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 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); 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([]); return; } let cancelled = false; setIsLoading(true); setIsBrowseDirectoryMissing(false); opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath) .then((result) => { if (cancelled) return; setIsBrowseDirectoryMissing(false); 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(() => { if (!cancelled) { setEntries([]); setIsBrowseDirectoryMissing(true); } }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; }, [browseDirectoryAbsolutePath, 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]); 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 && ( (hasTrailingPathSeparator(query) && isBrowseDirectoryMissing) || (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null) ) ); const canAddProject = !isConfirming && !isOpeningFinder && !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' && !highlightedRow.disabled)) ); const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'; const submitActionLabel = 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 handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => { event.stopPropagation(); const normalized = normalizeDirectoryPath(path); if (normalized && addedProjectPaths.has(normalized)) return; const added = addProject(path); if (!added) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); } }, [addProject, addedProjectPaths, t]); const finalizeSelection = React.useCallback(async (target: string) => { if (!target || isConfirming) return; const normalized = normalizeDirectoryPath(target); if (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 (shouldCreateSelection) { await opencodeClient.createDirectory(target); } const added = addProject(selectedTarget); if (!added) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); return; } handleClose(); } catch (error) { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'), }); } finally { setIsConfirming(false); } }, [addProject, addedProjectPaths, cloneRemoteUrl, handleClose, isCloneMode, isConfirming, selectedGitIdentity?.id, 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; } if (row.disabled) return; browseToEntry(row); }, [browseToDisplayPath, browseToEntry]); const handleOpenInFinder = React.useCallback(async () => { if (!isDesktop || 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; } 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); } }, [finalizeSelection, isDesktop, 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 === '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]); 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')}
) : 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}
{isDesktop ? ( ) : null} {isMobile ? ( ) : null}
); if (isMobile) { return ( {renderFooter()}} >
{showHiddenToggle}
{content}
); } return (
{t('directoryExplorerDialog.title')} {t('directoryExplorerDialog.description')}
{showHiddenToggle}
{content}
{renderFooter()}
); };