diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index 793775ac..d927ead9 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -38,8 +38,6 @@ import { CSS } from '@dnd-kit/utilities'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { toast } from '@/components/ui'; -import { isTauriShell, isDesktopLocalOriginActive, requestDirectoryAccess } from '@/lib/desktop'; import { sessionEvents } from '@/lib/sessionEvents'; import { Dialog, @@ -1450,7 +1448,6 @@ export const MobileSessionStatusBar: React.FC = ({ const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProject = useProjectsStore((state) => state.setActiveProject); - const addProject = useProjectsStore((state) => state.addProject); const removeProject = useProjectsStore((state) => state.removeProject); const getActiveProject = useProjectsStore((state) => state.getActiveProject); @@ -1492,7 +1489,6 @@ export const MobileSessionStatusBar: React.FC = ({ const contextUsage = getContextUsage(contextLimit, outputLimit); const [isExpanded, setIsExpanded] = React.useState(false); - const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) { return null; @@ -1520,29 +1516,7 @@ export const MobileSessionStatusBar: React.FC = ({ }; const handleAddProject = () => { - if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) { - sessionEvents.requestDirectoryDialog(); - return; - } - requestDirectoryAccess('') - .then((result) => { - if (result.success && result.path) { - const added = addProject(result.path, { id: result.projectId }); - if (!added) { - toast.error(t('chat.mobileStatus.toast.addProjectFailed'), { - description: t('chat.mobileStatus.toast.selectValidDirectory'), - }); - } - } else if (result.error && result.error !== 'Directory selection cancelled') { - toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed'), { - description: result.error, - }); - } - }) - .catch((error) => { - console.error('Failed to select directory:', error); - toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed')); - }); + sessionEvents.requestDirectoryDialog(); }; if (isMobileSessionStatusBarCollapsed) { diff --git a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx index ba381fdd..35126443 100644 --- a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx +++ b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx @@ -7,52 +7,24 @@ import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSideba import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { RiAddLine, RiFolderLine } from '@remixicon/react'; -import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { sessionEvents } from '@/lib/sessionEvents'; -import { toast } from '@/components/ui'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useI18n } from '@/lib/i18n'; export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => { const { t } = useI18n(); const projects = useProjectsStore((state) => state.projects); - const addProject = useProjectsStore((state) => state.addProject); const selectedId = useUIStore((state) => state.settingsProjectsSelectedId); const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId); const { currentTheme } = useThemeSystem(); const [brokenIconIds, setBrokenIconIds] = React.useState>(new Set()); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); const handleAddProject = React.useCallback(() => { - if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) { - sessionEvents.requestDirectoryDialog(); - return; - } - - requestDirectoryAccess('') - .then((result) => { - if (result.success && result.path) { - const added = addProject(result.path, { id: result.projectId }); - if (!added) { - toast.error(t('sessions.sidebar.directory.errorAddProjectTitle'), { - description: t('sessions.sidebar.directory.errorAddProjectDescription'), - }); - return; - } - setSelectedId(added.id); - } else if (result.error && result.error !== 'Directory selection cancelled') { - toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'), { - description: result.error, - }); - } - }) - .catch((error) => { - console.error('Failed to select directory:', error); - toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle')); - }); - }, [addProject, setSelectedId, tauriIpcAvailable, t]); + sessionEvents.requestDirectoryDialog(); + }, []); React.useEffect(() => { if (projects.length === 0) { diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index dbbfaac8..cc5009e9 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -9,19 +9,24 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { DirectoryTree } from './DirectoryTree'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; -import { cn, formatPathForDisplay } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { + RiArrowDownSLine, + RiArrowLeftSLine, + RiArrowUpSLine, RiCheckboxBlankLine, RiCheckboxLine, + RiCornerDownLeftLine, + RiFolder6Line, + RiFolderAddLine, } from '@remixicon/react'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; -import { DirectoryAutocomplete, type DirectoryAutocompleteHandle } from './DirectoryAutocomplete'; +import { opencodeClient } from '@/lib/opencode/client'; import { setDirectoryShowHidden, useDirectoryShowHidden, @@ -33,100 +38,327 @@ interface DirectoryExplorerDialogProps { 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 fetch('/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 currentDirectory = useDirectoryStore((s) => s.currentDirectory); const homeDirectory = useDirectoryStore((s) => s.homeDirectory); - const isHomeReady = useDirectoryStore((s) => s.isHomeReady); + const projects = useProjectsStore((s) => s.projects); const addProject = useProjectsStore((s) => s.addProject); - const getActiveProject = useProjectsStore((s) => s.getActiveProject); - const [pendingPath, setPendingPath] = React.useState(null); - const [pathInputValue, setPathInputValue] = React.useState(''); - const [hasUserSelection, setHasUserSelection] = React.useState(false); - const [isConfirming, setIsConfirming] = React.useState(false); const showHidden = useDirectoryShowHidden(); const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess(); const { isMobile } = useDeviceInfo(); - const [autocompleteVisible, setAutocompleteVisible] = React.useState(false); - const autocompleteRef = React.useRef(null); + 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); - // Helper to format path for display - const formatPath = React.useCallback((path: string | null) => { - if (!path) return ''; - return formatPathForDisplay(path, homeDirectory); - }, [homeDirectory]); + const explorerRootDirectory = dialogHomeDirectory || homeDirectory; + + const addedProjectPaths = React.useMemo(() => new Set( + projects + .map((project) => normalizeDirectoryPath(project.path)) + .filter((path): path is string => Boolean(path)) + ), [projects]); - // Reset state when dialog opens React.useEffect(() => { - if (open) { - setHasUserSelection(false); - setIsConfirming(false); - setAutocompleteVisible(false); - // Initialize with active project or current directory - const activeProject = getActiveProject(); - const initialPath = activeProject?.path || currentDirectory || homeDirectory || ''; - setPendingPath(initialPath); - setPathInputValue(formatPath(initialPath)); - } - }, [open, currentDirectory, homeDirectory, formatPath, getActiveProject]); + if (!open) return; + setQuery('~/'); + setEntries([]); + setHighlightedIndex(0); + setIsConfirming(false); + setIsOpeningFinder(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]); + + 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] + ); - // Set initial pending path to home when ready (only if not yet selected) React.useEffect(() => { - if (!open || hasUserSelection || pendingPath) { + if (!open || !browseDirectoryAbsolutePath) { + setEntries([]); return; } - if (homeDirectory && isHomeReady) { - setPendingPath(homeDirectory); - setHasUserSelection(true); - setPathInputValue('~'); - } - }, [open, hasUserSelection, pendingPath, homeDirectory, isHomeReady]); + 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 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') + : 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 finalizeSelection = React.useCallback(async (targetPath: string) => { - if (!targetPath || isConfirming) { - return; - } + const finalizeSelection = React.useCallback(async (target: string) => { + if (!target || isConfirming) return; + const normalized = normalizeDirectoryPath(target); + if (normalized && addedProjectPaths.has(normalized)) return; + setIsConfirming(true); try { - let resolvedPath = targetPath; - let projectId: string | undefined; - - if (isDesktop) { - const accessResult = await requestAccess(targetPath); - if (!accessResult.success) { - toast.error(t('directoryExplorerDialog.toast.unableToAccessDirectory'), { - description: accessResult.error || t('directoryExplorerDialog.toast.desktopDeniedAccess'), - }); - return; - } - resolvedPath = accessResult.path ?? targetPath; - projectId = accessResult.projectId; - - const startResult = await startAccessing(resolvedPath); - if (!startResult.success) { - toast.error(t('directoryExplorerDialog.toast.failedToOpenDirectory'), { - description: startResult.error || t('directoryExplorerDialog.toast.desktopCouldNotGrantAccess'), - }); - return; - } + const shouldCreateSelection = shouldCreateTarget && normalizeDirectoryPath(target) === normalizeDirectoryPath(targetPath); + if (shouldCreateSelection) { + await opencodeClient.createDirectory(target, { allowOutsideWorkspace: true }); } - - const added = addProject(resolvedPath, { id: projectId }); + const added = addProject(target); if (!added) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), }); return; } - handleClose(); } catch (error) { toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), { @@ -135,197 +367,241 @@ export const DirectoryExplorerDialog: React.FC = ( } finally { setIsConfirming(false); } - }, [ - addProject, - handleClose, - isDesktop, - requestAccess, - startAccessing, - isConfirming, - t, - ]); + }, [addProject, addedProjectPaths, handleClose, isConfirming, shouldCreateTarget, targetPath, t]); - const handleConfirm = React.useCallback(async () => { - const pathToUse = pathInputValue.trim() || pendingPath; - if (!pathToUse) { - return; - } - await finalizeSelection(pathToUse); - }, [finalizeSelection, pathInputValue, pendingPath]); - - const handleSelectPath = React.useCallback((path: string) => { - setPendingPath(path); - setHasUserSelection(true); - setPathInputValue(formatPath(path)); - }, [formatPath]); - - const handleDoubleClickPath = React.useCallback(async (path: string) => { - setPendingPath(path); - setHasUserSelection(true); - setPathInputValue(formatPath(path)); - await finalizeSelection(path); - }, [finalizeSelection, formatPath]); - - const handlePathInputChange = React.useCallback((e: React.ChangeEvent) => { - 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 - const expandedPath = value.startsWith('~') && homeDirectory - ? value.replace(/^~/, homeDirectory) - : value; - setPendingPath(expandedPath); - } - }, [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 browseToDisplayPath = React.useCallback((displayPath: string) => { + setQuery(ensureBrowseDirectoryPath(displayPath)); }, []); - const toggleShowHidden = React.useCallback(() => { - setDirectoryShowHidden(!showHidden); - }, [showHidden]); + 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 dialogHeader = ( - - {t('directoryExplorerDialog.title')} -
- - {t('directoryExplorerDialog.description')} - - {showHiddenToggle} -
-
- ); - - const pathInputSection = ( -
+ const inputSection = ( +
+ setQuery(normalizeSeparators(event.target.value))} + onKeyDown={handleKeyDown} placeholder={t('directoryExplorerDialog.pathInput.placeholder')} - className="font-mono typography-meta" + 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 treeSection = ( -
- -
- ); - - // Mobile: use flex layout where tree takes remaining space - const mobileContent = ( -
-
{pathInputSection}
-
- {showHiddenToggle} -
-
- + 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 ( + + ); + })} +
+ )}
); - const desktopContent = ( -
- {pathInputSection} - {treeSection} + const content = ( +
+ {inputSection} + {resultsSection}
); - const renderActionButtons = () => ( + const footerHints = ( +
+ + + + {t('directoryExplorerDialog.footer.navigate')} + + + + {t('directoryExplorerDialog.footer.select')} + + + {submitModifierLabel} + + {t('directoryExplorerDialog.footer.add')} + + + Esc + {t('directoryExplorerDialog.footer.close')} + +
+ ); + + const renderFooter = () => ( <> - - + {!isMobile ? footerHints : null} +
+ {isDesktop ? ( + + ) : null} + + {isMobile ? ( + + ) : null} +
); @@ -333,13 +609,16 @@ export const DirectoryExplorerDialog: React.FC = ( return ( onOpenChange(false)} + onClose={handleClose} title={t('directoryExplorerDialog.title')} className="h-[88dvh] max-h-[720px] max-w-full" contentMaxHeightClassName="flex-1" - footer={
{renderActionButtons()}
} + footer={
{renderFooter()}
} > - {mobileContent} +
+
{showHiddenToggle}
+ {content} +
); } @@ -347,20 +626,21 @@ export const DirectoryExplorerDialog: React.FC = ( return ( { - // Prevent auto-focus on input to avoid text selection - e.preventDefault(); - }} + className="flex w-full max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-h-[80vh]" + onOpenAutoFocus={(event) => event.preventDefault()} > - {dialogHeader} - {desktopContent} - - {renderActionButtons()} + +
+
+ {t('directoryExplorerDialog.title')} + {t('directoryExplorerDialog.description')} +
+ {showHiddenToggle} +
+
+
{content}
+ + {renderFooter()}
diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index 59e1633c..19cebe4c 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -39,6 +39,7 @@ interface DirectoryTreeProps { isRootReady?: boolean; /** Always show action icons (add, pin) instead of only on hover */ alwaysShowActions?: boolean; + disabledPaths?: Iterable; } export const DirectoryTree: React.FC = ({ @@ -53,6 +54,7 @@ export const DirectoryTree: React.FC = ({ rootDirectory = null, isRootReady, alwaysShowActions = false, + disabledPaths, }) => { const { t } = useI18n(); const { isMobile } = useDeviceInfo(); @@ -85,6 +87,20 @@ export const DirectoryTree: React.FC = ({ return trimmed.length === 0 ? '/' : trimmed; }, []); + const normalizedDisabledPaths = React.useMemo(() => { + const normalized = new Set(); + for (const path of disabledPaths ?? []) { + const value = stripTrailingSlashes(path.replace(/\\/g, '/')); + if (value) normalized.add(value.toLowerCase()); + } + return normalized; + }, [disabledPaths, stripTrailingSlashes]); + + const isPathDisabled = React.useCallback((path: string) => { + const normalized = stripTrailingSlashes(path.replace(/\\/g, '/')); + return normalized ? normalizedDisabledPaths.has(normalized.toLowerCase()) : false; + }, [normalizedDisabledPaths, stripTrailingSlashes]); + const normalizedHomeDirectory = React.useMemo(() => { if (!homeDirectory) { return null; @@ -665,6 +681,7 @@ export const DirectoryTree: React.FC = ({ const isPinned = pinnedPaths.has(item.path); const isSelected = currentPath === item.path; const isInlineVariant = variant === 'inline'; + const isDisabled = isPathDisabled(item.path); const rowContent = ( <> @@ -688,6 +705,9 @@ export const DirectoryTree: React.FC = ({