import React from 'react'; import { RiArrowLeftLine, RiArrowRightSLine, RiCloseLine, RiFolder3Fill, RiFolderOpenFill, RiLoader4Line, RiRefreshLine, RiSearchLine, } from '@remixicon/react'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { Input } from '@/components/ui/input'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useI18n } from '@/lib/i18n'; import type { FileListEntry, FileSearchResult } from '@/lib/api/types'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useUIStore } from '@/stores/useUIStore'; import { cn } from '@/lib/utils'; // The full desktop file editor, loaded on demand — it's a heavy chunk and only // needed once a file is actually opened. const LazyFilesEditor = React.lazy(() => import('@/components/views/FilesView').then((module) => ({ default: module.FilesView })), ); type MobileFilesRoute = | { type: 'browser'; directory: string } | { type: 'file'; path: string; returnDirectory: string }; const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); const getNameFromPath = (path: string): string => { const normalized = normalizePath(path); if (!normalized || normalized === '/') return normalized || '/'; return normalized.split('/').filter(Boolean).at(-1) ?? normalized; }; const getParentDirectory = (path: string): string | null => { const normalized = normalizePath(path); if (!normalized || normalized === '/') return null; const index = normalized.lastIndexOf('/'); if (index <= 0) return normalized.startsWith('/') ? '/' : null; return normalized.slice(0, index); }; const getRelativePath = (path: string, root: string): string => { const normalizedPath = normalizePath(path); const normalizedRoot = normalizePath(root); if (!normalizedRoot || normalizedPath === normalizedRoot) return getNameFromPath(normalizedPath); if (normalizedPath.startsWith(`${normalizedRoot}/`)) return normalizedPath.slice(normalizedRoot.length + 1); return normalizedPath; }; const formatFileSize = (size?: number): string => { if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return ''; if (size < 1024) return `${size} B`; const units = ['KB', 'MB', 'GB']; let value = size / 1024; for (const unit of units) { if (value < 1024 || unit === units[units.length - 1]) return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`; value /= 1024; } return ''; }; type MobileFilesSurfaceProps = { /** When provided, the header gets a close X that calls this. */ onClose?: () => void; }; export const MobileFilesSurface: React.FC = ({ onClose }) => { const { t } = useI18n(); const { files } = useRuntimeAPIs(); const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); const root = normalizePath(useEffectiveDirectory() ?? null); const [route, setRoute] = React.useState(() => ({ type: 'browser', directory: root })); const [entries, setEntries] = React.useState([]); const [isLoadingDirectory, setIsLoadingDirectory] = React.useState(false); const [directoryError, setDirectoryError] = React.useState(null); const [query, setQuery] = React.useState(''); const [searchResults, setSearchResults] = React.useState([]); const [isSearching, setIsSearching] = React.useState(false); const directoryLoadRequestIdRef = React.useRef(0); React.useEffect(() => { if (!root) return; setRoute((current) => { if (current.type === 'browser' && current.directory) return current; return { type: 'browser', directory: root }; }); }, [root]); const currentDirectory = route.type === 'browser' ? route.directory : route.returnDirectory; const loadDirectory = React.useCallback(async (directory: string) => { if (!directory) return; const requestId = directoryLoadRequestIdRef.current + 1; directoryLoadRequestIdRef.current = requestId; setIsLoadingDirectory(true); setDirectoryError(null); try { const result = await files.listDirectory(directory); if (directoryLoadRequestIdRef.current !== requestId) return; setEntries(result.entries.slice().sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return a.name.localeCompare(b.name); })); } catch (error) { if (directoryLoadRequestIdRef.current !== requestId) return; setEntries([]); setDirectoryError(error instanceof Error ? error.message : t('mobile.files.error.listFailed')); } finally { if (directoryLoadRequestIdRef.current === requestId) { setIsLoadingDirectory(false); } } }, [files, t]); React.useEffect(() => { if (route.type !== 'browser') return; void loadDirectory(route.directory); }, [loadDirectory, route]); React.useEffect(() => { if (route.type !== 'browser') return; const normalizedQuery = query.trim(); if (!normalizedQuery) { setSearchResults([]); setIsSearching(false); return; } let cancelled = false; const timeoutId = window.setTimeout(() => { setIsSearching(true); void files.search({ directory: route.directory, query: normalizedQuery, maxResults: 40 }) .then((results) => { if (!cancelled) setSearchResults(results); }) .catch(() => { if (!cancelled) setSearchResults([]); }) .finally(() => { if (!cancelled) setIsSearching(false); }); }, 250); return () => { cancelled = true; window.clearTimeout(timeoutId); }; }, [files, query, route]); const openDirectory = (directory: string) => { setQuery(''); setRoute({ type: 'browser', directory }); }; const openFile = (path: string) => { // FilesView (editor-only) reads its target from the files-view tabs store. setSelectedPath(root, path); setRoute({ type: 'file', path, returnDirectory: currentDirectory || root }); }; // Chat tool rows (read/skill/edit) stage a pending file focus/navigation in // the UI store — the same channel desktop's context panel consumes. Route // straight to the editor for targets inside this workspace; the editor // itself consumes pendingFileNavigation to jump to the requested line. const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); React.useEffect(() => { const target = normalizePath(pendingFileNavigation?.path ?? pendingFileFocusPath ?? ''); if (!target || !root) return; if (target !== root && !target.startsWith(`${root}/`)) return; setSelectedPath(root, target); setRoute({ type: 'file', path: target, returnDirectory: root }); if (pendingFileFocusPath) useUIStore.getState().setPendingFileFocusPath(null); }, [pendingFileFocusPath, pendingFileNavigation, root, setSelectedPath]); if (!root) { return ; } if (route.type === 'file') { // Full desktop file editor (toolbar, dirty/save, wrap, search, md/html // preview, open-file tabs) — FilesView is already mobile-aware (keyboard // nudge, touch menus); this host only adds the back row. return (

{getNameFromPath(route.path)}

}>
); } const directoryLabel = route.directory === root ? t('mobile.files.rootDirectory') : getNameFromPath(route.directory); const visibleSearchResults = query.trim() ? searchResults : []; // Cap parent navigation at the project root: only allow stepping up while // the parent stays inside (or equal to) the root. const rawParent = getParentDirectory(route.directory); const parentWithinRoot = route.directory !== root && rawParent !== null && (rawParent === root || rawParent.startsWith(`${root}/`)); const canGoBack = parentWithinRoot && !query.trim(); const parentDirectory = parentWithinRoot ? rawParent : null; return (
{onClose ? ( ) : null} {canGoBack && parentDirectory ? ( ) : null}

{directoryLabel}

setQuery(event.target.value)} placeholder={t('mobile.files.search.placeholder')} className="h-11 pl-9" />
{directoryError ? ( ) : query.trim() ? ( ) : (
{entries.length === 0 && !isLoadingDirectory ? (
{t('mobile.files.empty.directory')}
) : null} {entries.map((entry) => ( entry.isDirectory ? openDirectory(entry.path) : openFile(entry.path)} /> ))}
)}
); }; const MobileFileRow: React.FC<{ name: string; path: string; directory: boolean; meta?: string; onClick: () => void; }> = ({ name, path, directory, meta, onClick }) => ( ); const MobileSearchResults: React.FC<{ results: FileSearchResult[]; isSearching: boolean; onOpenFile: (path: string) => void; }> = ({ results, isSearching, onOpenFile }) => { const { t } = useI18n(); const root = normalizePath(useEffectiveDirectory() ?? null); if (isSearching) return ; if (results.length === 0) return ; return (
{results.map((result) => ( onOpenFile(result.path)} /> ))}
); }; const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => (
{loading ? : }

{message}

);