import React from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { GoToLineDialog } from './GoToLineDialog'; import { PreviewToggleButton } from './PreviewToggleButton'; import { JsonTreeView } from '@/components/ui/JsonTreeView'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension'; import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight'; import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { File as PierreFile } from '@pierre/diffs/react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getOutsideFileGrant, resolveOutsideFileReadOptions } from '@/lib/outsideFileGrants'; import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { buildCodeMirrorCommentWidgets, FilePreviewCommentMenu, normalizeLineRange, useInlineCommentController } from '@/components/comments'; import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { useMessageTTS } from '@/hooks/useMessageTTS'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi'; import { useProjectsStore } from '@/stores/useProjectsStore'; type FileNode = { name: string; path: string; type: 'file' | 'directory'; extension?: string; relativePath?: string; }; type FileStatSnapshot = { path: string; size: number; mtimeMs?: number; }; type SelectedLineRange = { start: number; end: number; }; const getParentDirectoryPath = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) { return normalized; } const lastSlash = normalized.lastIndexOf('/'); if (lastSlash < 0) { return normalized; } if (lastSlash === 0) { return '/'; } const parent = normalized.slice(0, lastSlash); if (/^[A-Za-z]:$/.test(parent)) { return `${parent}/`; } return parent; }; const OpenInAppListIcon = ({ label, iconDataUrl }: { label: string; iconDataUrl?: string }) => { const [failed, setFailed] = React.useState(false); const initial = label.trim().slice(0, 1).toUpperCase() || '?'; if (iconDataUrl && !failed) { return ( setFailed(true)} /> ); } return ( {initial} ); }; const sortNodes = (items: FileNode[]) => items.slice().sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); const normalizePath = (value: string): string => { if (!value) return ''; const raw = value.replace(/\\/g, '/'); const hadUncPrefix = raw.startsWith('//'); let normalized = raw.replace(/\/+/g, '/'); if (hadUncPrefix && !normalized.startsWith('//')) { normalized = `/${normalized}`; } const isUnixRoot = normalized === '/'; const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized); if (!isUnixRoot && !isWindowsDriveRoot) { normalized = normalized.replace(/\/+$/, ''); } return normalized; }; const isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; const toComparablePath = (value: string): string => { if (/^[A-Za-z]:\//.test(value)) { return value.toLowerCase(); } return value; }; const isPathWithinRoot = (path: string, root: string): boolean => { const normalizedRoot = normalizePath(root); const normalizedPath = normalizePath(path); if (!normalizedRoot || !normalizedPath) return false; const comparableRoot = toComparablePath(normalizedRoot); const comparablePath = toComparablePath(normalizedPath); return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`); }; const getAncestorPaths = (filePath: string, root: string): string[] => { const normalizedRoot = normalizePath(root); const normalizedFile = normalizePath(filePath); // Ensure file is within root if (!isPathWithinRoot(normalizedFile, normalizedRoot)) return []; const relative = normalizedFile.slice(normalizedRoot.length).replace(/^\//, ''); const parts = relative.split('/'); const ancestors: string[] = []; let current = normalizedRoot; for (let i = 0; i < parts.length - 1; i++) { current = current ? `${current}/${parts[i]}` : parts[i]; ancestors.push(current); } return ancestors; }; const getDisplayPath = (root: string | null, path: string): string => { if (!path) { return ''; } const normalizedFilePath = normalizePath(path); if (!root || !isPathWithinRoot(normalizedFilePath, root)) { return normalizedFilePath; } const relative = normalizedFilePath.slice(root.length); return relative.startsWith('/') ? relative.slice(1) : relative; }; const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']); type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted'; const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => { const color = { open: 'var(--status-info)', modified: 'var(--status-warning)', 'git-modified': 'var(--status-warning)', 'git-added': 'var(--status-success)', 'git-deleted': 'var(--status-error)', }[status]; return ; }; const ScrollingFileName: React.FC<{ name: string }> = ({ name }) => { const containerRef = React.useRef(null); const textRef = React.useRef(null); const [overflowing, setOverflowing] = React.useState(false); React.useLayoutEffect(() => { const container = containerRef.current; const text = textRef.current; if (!container || !text) { return; } const updateOverflow = () => { setOverflowing(text.scrollWidth > container.clientWidth + 1); }; updateOverflow(); const resizeObserver = new ResizeObserver(updateOverflow); resizeObserver.observe(container); resizeObserver.observe(text); return () => { resizeObserver.disconnect(); }; }, [name]); return ( {overflowing ? ( {name} ) : ( {name} )} ); }; const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name); const shouldIgnorePath = (path: string): boolean => { const normalized = normalizePath(path); return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/'); }; const isDirectoryReadError = (error: unknown): boolean => { const message = error instanceof Error ? error.message : String(error ?? ''); const normalized = message.toLowerCase(); return normalized.includes('is a directory') || normalized.includes('eisdir'); }; const isFileMissingError = (error: unknown): boolean => { const message = error instanceof Error ? error.message : String(error ?? ''); const normalized = message.toLowerCase(); return normalized.includes('file not found') || normalized.includes('enoent') || normalized.includes('no such file') || normalized.includes('does not exist'); }; const MAX_VIEW_CHARS = 200_000; type FileLineEnding = '\n' | '\r\n'; const detectFileLineEnding = (content: string): FileLineEnding => { let crlf = 0; let lf = 0; for (let index = 0; index < content.length; index += 1) { if (content.charCodeAt(index) !== 10) { continue; } if (index > 0 && content.charCodeAt(index - 1) === 13) { crlf += 1; } else { lf += 1; } } return crlf > lf ? '\r\n' : '\n'; }; const normalizeEditorLineEndings = (content: string): string => content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const serializeEditorContent = (content: string, lineEnding: FileLineEnding): string => { const normalized = normalizeEditorLineEndings(content); return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized; }; const getFileIcon = (filePath: string, extension?: string): React.ReactNode => { return ; }; const isMarkdownFile = (path: string): boolean => { if (!path) return false; const ext = path.toLowerCase().split('.').pop(); return ext === 'md' || ext === 'markdown'; }; const isJsonFile = (path: string): boolean => { if (!path) return false; const ext = path.toLowerCase().split('.').pop(); return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson'; }; const isHtmlFile = (path: string): boolean => { if (!path) return false; const ext = path.toLowerCase().split('.').pop(); return ext === 'html' || ext === 'htm'; }; interface FileRowProps { node: FileNode; root: string; isExpanded: boolean; isActive: boolean; isMobile: boolean; isBrowserClient: boolean; alwaysShowActions: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; permissions: { canRename: boolean; canCreateFile: boolean; canCreateFolder: boolean; canDelete: boolean; canReveal: boolean; }; downloadFile?: (path: string) => Promise; contextMenuPath: string | null; setContextMenuPath: (path: string | null) => void; rightClickMenuPath: string | null; setRightClickMenuPath: (path: string | null) => void; onSelect: (node: FileNode) => void; onToggle: (path: string) => void; onRevealPath: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; } const FileRow: React.FC = ({ node, root, isExpanded, isActive, isMobile, isBrowserClient, alwaysShowActions, status, badge, permissions, downloadFile, contextMenuPath, setContextMenuPath, rightClickMenuPath, setRightClickMenuPath, onSelect, onToggle, onRevealPath, onOpenDialog, }) => { const { t } = useI18n(); const isDir = node.type === 'directory'; const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; const canDownload = !isDir && Boolean(downloadFile); const canRevealPath = canReveal && !isBrowserClient; const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath; const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { if (!hasMenuActions) { return; } event?.preventDefault(); setRightClickMenuPath(node.path); }, [hasMenuActions, node.path, setRightClickMenuPath]); const handleInteraction = React.useCallback(() => { if (isDir) { onToggle(node.path); } else { onSelect(node); } }, [isDir, node, onSelect, onToggle]); const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); setRightClickMenuPath(null); setContextMenuPath(node.path); }, [node.path, setContextMenuPath, setRightClickMenuPath]); const renderMenuItems = ({ Item, Separator, }: { Item: React.ElementType; Separator: React.ElementType; }) => ( <> {canRename && ( { e.stopPropagation(); onOpenDialog('rename', node); }}> {t('sidebarFilesTree.menu.rename')} )} { e.stopPropagation(); void copyTextToClipboard(node.path).then((result) => { if (result.ok) { toast.success(t('sidebarFilesTree.toast.pathCopied')); return; } toast.error(t('sidebarFilesTree.toast.copyFailed')); }); }}> {t('sidebarFilesTree.menu.copyPath')} { e.stopPropagation(); const relativePath = getDisplayPath(root, node.path) || node.path; void copyTextToClipboard(relativePath).then((result) => { if (result.ok) { toast.success(t('filesView.toast.relativePathCopied')); return; } toast.error(t('sidebarFilesTree.toast.copyFailed')); }); }}> {t('filesView.tree.menu.copyRelativePath')} {!isDir && downloadFile && ( { e.stopPropagation(); void downloadFile(node.path).catch((error) => { console.error('Download failed:', error); toast.error(t('sidebarFilesTree.toast.operationFailed')); }); }}> {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')} )} {canRevealPath && ( { e.stopPropagation(); onRevealPath(node.path); }}> {t(getRevealLabelKey())} )} {isDir && (canCreateFile || canCreateFolder) && ( <> {canCreateFile && ( { e.stopPropagation(); onOpenDialog('createFile', node); }}> {t('sidebarFilesTree.menu.newFile')} )} {canCreateFolder && ( { e.stopPropagation(); onOpenDialog('createFolder', node); }}> {t('sidebarFilesTree.menu.newFolder')} )} )} {canDelete && ( <> { e.stopPropagation(); onOpenDialog('delete', node); }} className="text-destructive focus:text-destructive" > {t('sidebarFilesTree.menu.delete')} )} ); return ( setRightClickMenuPath(open ? node.path : null)}> }> {hasMenuActions && (
setContextMenuPath(open ? node.path : null)} > setContextMenuPath(null)}> {renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })}
)}
{renderMenuItems({ Item: ContextMenuItem, Separator: ContextMenuSeparator })}
); }; interface DialogsProps { activeDialog: 'createFile' | 'createFolder' | 'rename' | 'delete' | null; dialogData: { path: string; name?: string; type?: 'file' | 'directory' } | null; dialogInputValue: string; onDialogInputChange: (value: string) => void; isDialogSubmitting: boolean; onDialogSubmit: (e?: React.FormEvent) => Promise; onClose: () => void; inputRef: React.RefObject; } const Dialogs: React.FC = ({ activeDialog, dialogData, dialogInputValue, onDialogInputChange, isDialogSubmitting, onDialogSubmit, onClose, inputRef, }) => { const { t } = useI18n(); return ( !open && onClose()}> {activeDialog === 'createFile' && t('filesView.dialog.createFile.title')} {activeDialog === 'createFolder' && t('filesView.dialog.createFolder.title')} {activeDialog === 'rename' && t('filesView.dialog.rename.title')} {activeDialog === 'delete' && t('filesView.dialog.delete.title')} {activeDialog === 'createFile' && t('filesView.dialog.createFile.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })} {activeDialog === 'createFolder' && t('filesView.dialog.createFolder.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })} {activeDialog === 'rename' && t('filesView.dialog.rename.description', { name: dialogData?.name ?? '' })} {activeDialog === 'delete' && t('filesView.dialog.delete.description', { name: dialogData?.name ?? '' })} {activeDialog !== 'delete' && (
onDialogInputChange(e.target.value)} placeholder={activeDialog === 'rename' ? t('filesView.dialog.rename.placeholder') : t('filesView.dialog.namePlaceholder')} onKeyDown={(e) => { if (e.key === 'Enter') { void onDialogSubmit(); } }} ref={inputRef} />
)}
); }; interface FilesViewProps { mode?: 'full' | 'editor-only'; } /** * Keeps a token-bearing asset preview (image/HTML/PDF) authenticated. While * `assetKey` is set this registers an active url-token consumer (so runtime-auth * proactively refreshes the shared token before it expires) and subscribes to * token replacements, bumping `nonce` so the iframe/img remounts with the fresh * token — but only when the token actually changed, not on every interval. */ const useAssetAuthRefresh = ( assetKey: string, setFileError: React.Dispatch>, errorFallback: string, ): { readyKey: string; nonce: number } => { const [readyKey, setReadyKey] = React.useState(''); const [nonce, setNonce] = React.useState(0); React.useEffect(() => { if (!assetKey) { setReadyKey(''); return; } let cancelled = false; setReadyKey(''); const apiBaseUrl = getRuntimeApiBaseUrl(); const release = acquireRuntimeUrlAuthToken(apiBaseUrl); void refreshRuntimeUrlAuthToken(apiBaseUrl) .then((token) => { if (cancelled || !token) return; setReadyKey(assetKey); setFileError(null); }) .catch((error) => { if (cancelled) return; setFileError(error instanceof Error ? error.message : errorFallback); setReadyKey(assetKey); }); const unsubscribe = subscribeRuntimeUrlAuthToken(() => { if (cancelled) return; // Token was refreshed underneath us — remount the asset with the fresh URL. setReadyKey(assetKey); setNonce((n) => n + 1); setFileError(null); }); return () => { cancelled = true; release(); unsubscribe(); }; }, [assetKey, setFileError, errorFallback]); return { readyKey, nonce }; }; export const FilesView: React.FC = ({ mode = 'full' }) => { const { t } = useI18n(); const { files, runtime } = useRuntimeAPIs(); const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem(); const { isMobile, isTablet, screenWidth } = useDeviceInfo(); const isBrowserClient = isBrowserClientRuntime(runtime.platform); const alwaysShowActions = isMobile || isTablet; const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); // editor-only hosts (desktop context panel, the mobile Files surface) bring // their own chrome — the open-file tabs row is redundant there. const showEditorTabsRow = mode !== 'editor-only'; const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile; const searchFiles = useFileSearchStore((state) => state.searchFiles); const gitStatus = useGitStatus(currentDirectory); const [searchQuery, setSearchQuery] = React.useState(''); const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); const searchInputRef = React.useRef(null); const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); const [wrapLines, setWrapLines] = React.useState(true); const [isFullscreen, setIsFullscreen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false); const floatingToolbarRef = React.useRef(null); const toolbarDropdownOpenCountRef = React.useRef(0); const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => { toolbarDropdownOpenCountRef.current = Math.max( 0, toolbarDropdownOpenCountRef.current + (open ? 1 : -1), ); }, []); const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => { if (!(target instanceof Element)) return false; return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null; }, []); React.useEffect(() => { if (!isFloatingToolbarOpen) return; const handler = (event: MouseEvent) => { if (toolbarDropdownOpenCountRef.current > 0) return; if (isClickInsidePortalledMenu(event.target)) return; if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) { setIsFloatingToolbarOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [isClickInsidePortalledMenu, isFloatingToolbarOpen]); type TextViewMode = 'view' | 'edit'; type PreviewViewMode = 'preview' | 'edit'; const [textViewMode, setTextViewMode] = React.useState('edit'); const [mdViewMode, setMdViewMode] = React.useState('edit'); const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree'); const [htmlViewMode, setHtmlViewMode] = React.useState('edit'); const [drawioViewMode, setDrawioViewMode] = React.useState('preview'); const [drawioRemountNonce, setDrawioRemountNonce] = React.useState(0); const textViewModeByPathRef = React.useRef>({}); const mdViewModeByPathRef = React.useRef>({}); const htmlViewModeByPathRef = React.useRef>({}); const drawioViewModeByPathRef = React.useRef>({}); const lightTheme = React.useMemo( () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false), [availableThemes, lightThemeId], ); const darkTheme = React.useMemo( () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true), [availableThemes, darkThemeId], ); React.useEffect(() => { ensurePierreThemeRegistered(lightTheme); ensurePierreThemeRegistered(darkTheme); }, [lightTheme, darkTheme]); const EMPTY_PATHS: string[] = React.useMemo(() => [], []); const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix); const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); const expandPaths = useFilesViewTabsStore((state) => state.expandPaths); const toFileNode = React.useCallback((path: string): FileNode => { const normalized = normalizePath(path); const parts = normalized.split('/'); const name = parts[parts.length - 1] || normalized; const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; return { name, path: normalized, type: 'file', extension, }; }, []); const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); const effectiveSelectedPath = React.useMemo(() => { if (selectedPath) { const comparableSelected = toComparablePath(selectedPath); if (openPaths.some((path) => toComparablePath(path) === comparableSelected)) { return selectedPath; } } return openPaths[0] ?? null; }, [openPaths, selectedPath]); const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]); const selectedFilePath = selectedFile?.path ?? ''; const [, setOutsideFileGrantRevision] = React.useState(0); React.useEffect(() => { if (!root || !selectedPath) return; const comparableSelected = toComparablePath(selectedPath); const selectedIsOpen = openPaths.some((path) => toComparablePath(path) === comparableSelected); if (!selectedIsOpen) { setSelectedPath(root, openPaths[0] ?? null); } }, [openPaths, root, selectedPath, setSelectedPath]); const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root)); const selectedOutsideFileGrant = selectedFileIsOutsideWorkspace ? getOutsideFileGrant(selectedFilePath) : undefined; const selectedFileReadOptions = React.useMemo( () => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace, outsideFileGrant: selectedOutsideFileGrant, directory: root || undefined, }), [mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant, root], ); const resolveFileReadOptions = React.useCallback(async (path: string) => { const previousGrant = getOutsideFileGrant(path); const readOptions = await resolveOutsideFileReadOptions(path, root, mode === 'editor-only'); if (readOptions.outsideFileGrant && readOptions.outsideFileGrant !== previousGrant) { setOutsideFileGrantRevision((revision) => revision + 1); } return readOptions; }, [mode, root]); // Editor tabs horizontal scroll fades const editorTabsScrollRef = React.useRef(null); const [editorTabsOverflow, setEditorTabsOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }); const updateEditorTabsOverflow = React.useCallback(() => { const el = editorTabsScrollRef.current; if (!el) return; setEditorTabsOverflow({ left: el.scrollLeft > 2, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2, }); }, []); const updateEditorTabsOverflowRef = React.useRef(updateEditorTabsOverflow); updateEditorTabsOverflowRef.current = updateEditorTabsOverflow; React.useEffect(() => { const el = editorTabsScrollRef.current; if (!el) return; const handler = () => updateEditorTabsOverflowRef.current(); handler(); el.addEventListener('scroll', handler, { passive: true }); const ro = new ResizeObserver(handler); ro.observe(el); return () => { el.removeEventListener('scroll', handler); ro.disconnect(); }; }, [openFiles.length]); const [childrenByDir, setChildrenByDir] = React.useState>({}); const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); const activeDirectoryLoadIdsRef = React.useRef>(new Map()); const nextDirectoryLoadIdRef = React.useRef(0); const [searchResults, setSearchResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [fileContent, setFileContent] = React.useState(''); const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState('\n'); const dialogInputRef = React.useRef(null); const autoSaveTimerRef = React.useRef | null>(null); const diagramAutoSaveTimerRef = React.useRef | null>(null); const diagramXmlRef = React.useRef(''); const diagramSavedXmlRef = React.useRef(''); const pendingDrawioPreviewFrameRef = React.useRef(null); const diagramEditorRef = React.useRef>(null); const lastLoadedFileStatRef = React.useRef(null); const activeFileLoadIdRef = React.useRef(0); const loadingFilePathRef = React.useRef(null); const [fileContentRevision, setFileContentRevision] = React.useState(0); const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); const [diagramSaved, setDiagramSaved] = React.useState(false); const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false); const autoSaveEnabled = useUIStore((state) => state.autoSaveEnabled); const setAutoSaveEnabled = useUIStore((state) => state.setAutoSaveEnabled); const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); const pendingSelectFileRef = React.useRef(null); const pendingTabRef = React.useRef(null); const pendingClosePathRef = React.useRef(null); const skipDirtyOnceRef = React.useRef(false); const copiedContentTimeoutRef = React.useRef(null); const copiedPathTimeoutRef = React.useRef(null); const editorViewRef = React.useRef(null); const editorWrapperRef = React.useRef(null); const [editorViewReadyNonce, setEditorViewReadyNonce] = React.useState(0); const pendingNavigationRafRef = React.useRef(null); const pendingNavigationCycleRef = React.useRef<{ key: string; attempts: number }>({ key: '', attempts: 0 }); React.useEffect(() => { return () => { if (pendingNavigationRafRef.current !== null && typeof window !== 'undefined') { window.cancelAnimationFrame(pendingNavigationRafRef.current); pendingNavigationRafRef.current = null; } }; }, []); const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); const [dialogInputValue, setDialogInputValue] = React.useState(''); const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); const [contextMenuPath, setContextMenuPath] = React.useState(null); const [rightClickMenuPath, setRightClickMenuPath] = React.useState(null); const [copiedContent, setCopiedContent] = React.useState(false); const [copiedPath, setCopiedPath] = React.useState(false); const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false); const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); const canRename = Boolean(files.rename); const canDelete = Boolean(files.delete); const canReveal = Boolean(files.revealPath); const openInApps = useOpenInAppsStore((state) => state.availableApps); const openInCacheStale = useOpenInAppsStore((state) => state.isCacheStale); const initializeOpenInApps = useOpenInAppsStore((state) => state.initialize); const loadOpenInApps = useOpenInAppsStore((state) => state.loadInstalledApps); React.useEffect(() => { initializeOpenInApps(); }, [initializeOpenInApps]); const handleRevealPath = React.useCallback((targetPath: string) => { if (!files.revealPath) return; void files.revealPath(targetPath).catch(() => { toast.error(t('sidebarFilesTree.toast.revealFailed')); }); }, [files, t]); const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => { if (!selectedFile?.path) { return; } const openedInApp = await openDesktopFileInApp(selectedFile.path, app.id, app.appName); if (openedInApp) { return; } const openedFile = await openDesktopPath(selectedFile.path, app.appName); if (openedFile) { return; } const fileDirectory = getParentDirectoryPath(selectedFile.path) || root; if (fileDirectory) { const openedDirectory = await openDesktopPath(fileDirectory, app.appName); if (openedDirectory) { return; } } toast.error(t('filesView.toast.openInAppFailed', { app: app.appName })); }, [root, selectedFile?.path, t]); const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { setActiveDialog(type); setDialogData(data); setDialogInputValue(type === 'rename' ? data.name || '' : ''); setIsDialogSubmitting(false); }, []); // Line selection state for commenting const [lineSelection, setLineSelection] = React.useState(null); const isSelectingRef = React.useRef(false); const selectionStartRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); // Session/config for sending comments const setSurfaceGuard = useUIStore((state) => state.setSurfaceGuard); const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const settingsExpandedEditorToolbar = useUIStore((state) => state.expandedEditorToolbar); // Global mouseup to end drag selection React.useEffect(() => { const handleGlobalMouseUp = () => { isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); }; document.addEventListener('mouseup', handleGlobalMouseUp); return () => document.removeEventListener('mouseup', handleGlobalMouseUp); }, []); React.useEffect(() => { return () => { if (copiedContentTimeoutRef.current !== null) { window.clearTimeout(copiedContentTimeoutRef.current); } if (copiedPathTimeoutRef.current !== null) { window.clearTimeout(copiedPathTimeoutRef.current); } }; }, []); // Extract selected code const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => { const lines = content.split('\n'); const startLine = Math.max(1, range.start); const endLine = Math.min(lines.length, range.end); if (startLine > endLine) return ''; return lines.slice(startLine - 1, endLine).join('\n'); }, []); const markdownPreviewRef = React.useRef(null); const fileCommentController = useInlineCommentController({ source: 'file', fileLabel: selectedFile?.path ?? null, language: selectedFile?.path ? getLanguageFromExtension(selectedFile.path) || 'text' : 'text', getCodeForRange: (range) => extractSelectedCode(fileContent, normalizeLineRange(range)), toStoreRange: (range) => ({ startLine: range.start, endLine: range.end }), fromDraftRange: (draft) => ({ start: draft.startLine, end: draft.endLine }), }); const { drafts: filesFileDrafts, commentText, setCommentText, editingDraftId, setSelection: setCommentSelection, saveComment, cancel, reset, startEdit, deleteDraft, } = fileCommentController; React.useEffect(() => { setLineSelection(null); reset(); setSurfaceGuard(null); setDraftContent(''); setIsSaving(false); }, [selectedFile?.path, reset, setSurfaceGuard]); React.useEffect(() => { setCommentSelection(lineSelection); }, [lineSelection, setCommentSelection]); React.useEffect(() => { if (!lineSelection && !editingDraftId) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('[data-comment-input="true"]') || target.closest('[data-comment-card="true"]')) return; if (target.closest('.cm-gutterElement')) return; if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; if (!commentText.trim()) { setLineSelection(null); cancel(); } }; const timeoutId = setTimeout(() => { document.addEventListener('click', handleClickOutside); }, 100); return () => { clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; }, [cancel, commentText, editingDraftId, lineSelection]); const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { const finalRange = range ?? lineSelection ?? undefined; if (range) { setLineSelection(range); } saveComment(text, finalRange); setLineSelection(null); }, [lineSelection, saveComment]); const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => { const nodes: FileNode[] = []; for (const entry of entries) { if (!(entry && typeof entry.name === 'string' && entry.name.length > 0)) continue; if (!showHidden && entry.name.startsWith('.')) continue; if (!showGitignored && shouldIgnoreEntryName(entry.name)) continue; const name = entry.name; const normalizedEntryPath = normalizePath(entry.path || ''); const path = normalizedEntryPath ? (isAbsolutePath(normalizedEntryPath) ? normalizedEntryPath : normalizePath(`${dirPath}/${normalizedEntryPath}`)) : normalizePath(`${dirPath}/${name}`); const type = entry.isDirectory ? 'directory' : 'file'; const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; nodes.push({ name, path, type, extension }); } return sortNodes(nodes); }, [showGitignored, showHidden]); const loadDirectory = React.useCallback(async (dirPath: string) => { const normalizedDir = normalizePath(dirPath.trim()); if (!normalizedDir) { return; } if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) { return; } inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.add(normalizedDir); const requestId = nextDirectoryLoadIdRef.current + 1; nextDirectoryLoadIdRef.current = requestId; activeDirectoryLoadIdsRef.current = new Map(activeDirectoryLoadIdsRef.current); activeDirectoryLoadIdsRef.current.set(normalizedDir, requestId); const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; const listPromise = files.listDirectory ? files.listDirectory(normalizedDir).then((result) => result.entries.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, }))) : opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, }))); await listPromise .then((entries) => { if (!isCurrentRequest()) { return; } const mapped = mapDirectoryEntries(normalizedDir, entries); loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.add(normalizedDir); setLoadErrorsByDir((prev) => { if (!prev[normalizedDir]) return prev; const next = { ...prev }; delete next[normalizedDir]; return next; }); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); }) .catch((error) => { if (!isCurrentRequest()) { return; } const message = error instanceof Error ? error.message : String(error ?? ''); if (message === 'Directory not found' && root && normalizedDir !== root) { removeExpandedPathsByPrefix(root, normalizedDir); setLoadErrorsByDir((prev) => { if (!prev[normalizedDir]) return prev; const next = { ...prev }; delete next[normalizedDir]; return next; }); return; } console.error('Failed to load files directory:', error); setLoadErrorsByDir((prev) => ({ ...prev, [normalizedDir]: message, })); }) .finally(() => { if (!isCurrentRequest()) { return; } activeDirectoryLoadIdsRef.current = new Map(activeDirectoryLoadIdsRef.current); activeDirectoryLoadIdsRef.current.delete(normalizedDir); inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); }); }, [files, mapDirectoryEntries, removeExpandedPathsByPrefix, root]); const refreshRoot = React.useCallback(async () => { if (!root) { return; } loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); activeDirectoryLoadIdsRef.current = new Map(); setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); await loadDirectory(root); }, [loadDirectory, root]); /** * Incrementally refresh a single directory without nuking the rest of the * tree. After the operation the parent directory is reloaded in-place so * the new/renamed/deleted entry becomes visible immediately while every * other expanded directory keeps its cached children. */ const refreshDirectory = React.useCallback(async (dirPath: string) => { if (!dirPath) { await refreshRoot(); return; } const normalized = normalizePath(dirPath); // Remove from loaded set so loadDirectory will actually fetch again. loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.delete(normalized); // Also cancel any in-flight request for this dir so the new fetch wins. inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalized); await loadDirectory(normalized); }, [loadDirectory, refreshRoot]); const lastFilesViewDirRef = React.useRef(''); const lastFilesViewTreeKeyRef = React.useRef(''); React.useEffect(() => { if (!root) { return; } const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`; const dirChanged = lastFilesViewDirRef.current !== root; const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey; if (!dirChanged && !treeKeyChanged) { return; } if (dirChanged) { lastFilesViewDirRef.current = root; setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); setShowMobilePageContent(false); } if (treeKeyChanged) { lastFilesViewTreeKeyRef.current = treeKey; loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); activeDirectoryLoadIdsRef.current = new Map(); setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); void loadDirectory(root); } }, [loadDirectory, root, showGitignored, showHidden]); // Auto-refresh expanded directories when user returns to the tab React.useEffect(() => { if (!files.listDirectory) return; const handleVisibilityChange = () => { if (!document.hidden && expandedPaths.length > 0) { for (const dir of expandedPaths) { void refreshDirectory(dir); } } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => document.removeEventListener('visibilitychange', handleVisibilityChange); }, [expandedPaths, files.listDirectory, refreshDirectory]); // Poll expanded directories for external changes React.useEffect(() => { if (!files.listDirectory) return; if (expandedPaths.length === 0) return; const interval = setInterval(() => { if (document.hidden) return; for (const dir of expandedPaths) { void refreshDirectory(dir); } }, 8000); return () => clearInterval(interval); }, [expandedPaths, files.listDirectory, refreshDirectory]); const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => { e?.preventDefault(); if (!dialogData || !activeDialog) return; setIsDialogSubmitting(true); const finishDialogOperation = () => { setActiveDialog(null); }; const failDialogOperation = (message: string) => { toast.error(message); }; const done = () => { setIsDialogSubmitting(false); }; if (activeDialog === 'createFile') { if (!dialogInputValue.trim()) { failDialogOperation(t('sidebarFilesTree.toast.filenameRequired')); done(); return; } if (!files.writeFile) { failDialogOperation(t('sidebarFilesTree.toast.writeNotSupported')); done(); return; } const parentPath = dialogData.path; const prefix = parentPath ? `${parentPath}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); await files.writeFile(newPath, '') .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.fileCreated')); await refreshDirectory(parentPath); } finishDialogOperation(); }) .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } if (activeDialog === 'createFolder') { if (!dialogInputValue.trim()) { failDialogOperation(t('sidebarFilesTree.toast.folderNameRequired')); done(); return; } const parentPath = dialogData.path; const prefix = parentPath ? `${parentPath}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); await files.createDirectory(newPath) .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.folderCreated')); await refreshDirectory(parentPath); } finishDialogOperation(); }) .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } if (activeDialog === 'rename') { if (!dialogInputValue.trim()) { failDialogOperation(t('sidebarFilesTree.toast.nameRequired')); done(); return; } if (!files.rename) { failDialogOperation(t('sidebarFilesTree.toast.renameNotSupported')); done(); return; } const oldPath = dialogData.path; const parentDir = oldPath.split('/').slice(0, -1).join('/'); const prefix = parentDir ? `${parentDir}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); await files.rename(oldPath, newPath) .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.renamedSuccessfully')); await refreshDirectory(parentDir); if (root) { removeOpenPathsByPrefix(root, oldPath); } if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } finishDialogOperation(); }) .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } if (activeDialog === 'delete') { if (!files.delete) { failDialogOperation(t('sidebarFilesTree.toast.deleteNotSupported')); done(); return; } const deletedPath = dialogData.path; const parentDir = deletedPath.split('/').slice(0, -1).join('/'); await files.delete(deletedPath) .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.deletedSuccessfully')); await refreshDirectory(parentDir); if (root) { removeOpenPathsByPrefix(root, deletedPath); } if (selectedFile?.path === deletedPath || selectedFile?.path.startsWith(`${deletedPath}/`)) { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } finishDialogOperation(); }) .catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } done(); }, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath, t]); React.useEffect(() => { if (!currentDirectory) { setSearchResults([]); setSearching(false); return; } const trimmedQuery = debouncedSearchQuery.trim(); if (!trimmedQuery) { setSearchResults([]); setSearching(false); return; } let cancelled = false; setSearching(true); searchFiles(currentDirectory, trimmedQuery, 150, { includeHidden: showHidden, respectGitignore: !showGitignored, type: 'file', }) .then((hits) => { if (cancelled) { return; } const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path)); const mapped: FileNode[] = filtered.map((hit) => ({ name: hit.name, path: normalizePath(hit.path), type: 'file', extension: hit.extension, relativePath: hit.relativePath, })); setSearchResults(mapped); }) .catch(() => { if (!cancelled) { setSearchResults([]); } }) .finally(() => { if (!cancelled) { setSearching(false); } }); return () => { cancelled = true; }; }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); const readFile = React.useCallback(async (path: string): Promise => { const options = await resolveFileReadOptions(path); if (files.readFile) { const result = await files.readFile(path, { ...options, directory: root || undefined }); return result.content ?? ''; } const params = new URLSearchParams({ path }); if (options.allowOutsideWorkspace) { params.set('allowOutsideWorkspace', 'true'); } if (options.outsideFileGrant) { params.set('outsideFileGrant', options.outsideFileGrant); } if (root) { params.set('directory', root); } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed')); } return response.text(); }, [files, resolveFileReadOptions, root, t]); const readFileStat = React.useCallback(async (path: string): Promise => { if (files.statFile) { const options = await resolveFileReadOptions(path); const result = await files.statFile(path, { ...options, directory: root || undefined }); return { path: result.path, size: result.size, mtimeMs: result.mtimeMs, }; } return null; }, [files, resolveFileReadOptions, root]); React.useEffect(() => { if (!root || !files.statFile || openPaths.length === 0) { return; } let cancelled = false; const paths = [...openPaths]; void Promise.all(paths.map(async (path) => { try { const options = await resolveFileReadOptions(path); const stat = await files.statFile?.(path, { ...options, directory: root || undefined }); if (!cancelled && stat && !stat.isFile) { removeOpenPathsByPrefix(root, path); } } catch (error) { if (!cancelled && isFileMissingError(error)) { removeOpenPathsByPrefix(root, path); } } })); return () => { cancelled = true; }; }, [files, openPaths, removeOpenPathsByPrefix, resolveFileReadOptions, root]); const displayedContent = React.useMemo(() => fileContent.length > MAX_VIEW_CHARS ? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` : fileContent, [fileContent] ); const isDirty = draftContent !== displayedContent; const saveDraft = React.useCallback(async () => { if (!selectedFile || !files.writeFile) { toast.error(t('filesView.toast.savingNotSupported')); return false; } const selectedIsBinary = isBinaryFile(selectedFile.path) || contentDetectedBinary; if (!shouldAllowFileDraftSave({ selectedFilePath: selectedFile.path, loadedFilePath, fileLoading, isDirty, draftContent, fileContent, isNonEditableBinary: selectedIsBinary, })) { if (selectedIsBinary) { console.warn(`[saveDraft] refusing to save binary file "${selectedFile.path}".`); } else if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) { console.warn( `[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` + 'The file may have been read during a concurrent write (O_TRUNC race). ' + 'Try again after content finishes loading if the save was intentional.', ); } return false; } // Clean draft: treat as success so discard/save dialogs and Ctrl+S are not stranded. if (!isDirty) { return true; } setIsSaving(true); try { const contentToWrite = serializeEditorContent(draftContent, loadedFileLineEnding); const result = await files.writeFile(selectedFile.path, contentToWrite); if (!result?.success) { toast.error(t('filesView.toast.writeFileFailed')); return false; } setFileContent(draftContent); if (root && isPathWithinRoot(selectedFile.path, root)) { const relativePath = getDisplayPath(root, selectedFile.path); if (relativePath) { sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] }); } } if (root && /(?:^|\/)\.agents\/loops\/[^/]+\.md$/i.test(normalizePath(selectedFile.path))) { const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === normalizePath(root)); if (project) { try { await syncScheduledTaskLoops(project.id); } catch { toast.error(t('sessions.scheduledTasks.dialog.toast.updateFailed')); } } } if (selectedFile?.path && isDrawioFile(selectedFile.path)) { diagramXmlRef.current = draftContent; diagramSavedXmlRef.current = draftContent; } // Refresh stat after write so polling doesn't see a stale metadata change. void readFileStat(selectedFile.path) .then((stat) => { if (stat) { lastLoadedFileStatRef.current = stat; } }) .catch(() => {}); return true; } catch (error) { toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed')); return false; } finally { setIsSaving(false); } }, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, root, selectedFile, t]); React.useEffect(() => { if (!isDirty) { setSurfaceGuard(null); return; } const guard = (_nextTab: import('@/stores/useUIStore').WorkspaceSurface) => { if (skipDirtyOnceRef.current) { skipDirtyOnceRef.current = false; return true; } setConfirmDiscardOpen(true); pendingTabRef.current = _nextTab; return false; }; setSurfaceGuard(guard); return () => { const currentGuard = useUIStore.getState().surfaceGuard; if (currentGuard === guard) { setSurfaceGuard(null); } }; }, [isDirty, setSurfaceGuard]); React.useEffect(() => { if (autoSaveEnabled) { return; } setAutoSaveStatus('idle'); if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }, [autoSaveEnabled]); // Auto-save: debounce 1.5s after user stops typing const AUTO_SAVE_DELAY = 1500; React.useEffect(() => { const canWrite = Boolean(selectedFile && files.writeFile); const selectedIsBinary = Boolean(selectedFile?.path && (isBinaryFile(selectedFile.path) || contentDetectedBinary)); if (!shouldScheduleFileAutosave({ autoSaveEnabled, isDirty, canWrite, isSaving, fileLoading, selectedFilePath: selectedFile?.path, loadedFilePath, isNonEditableBinary: selectedIsBinary, })) { return; } autoSaveTimerRef.current = setTimeout(() => { void saveDraft().then((saved) => { if (!saved) return; setAutoSaveStatus('saved'); setTimeout(() => setAutoSaveStatus('idle'), 2000); }); }, AUTO_SAVE_DELAY); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }; }, [autoSaveEnabled, contentDetectedBinary, draftContent, fileLoading, isDirty, loadedFilePath, selectedFile, files.writeFile, isSaving, saveDraft]); // Reset auto-save status when switching files React.useEffect(() => { setAutoSaveStatus('idle'); }, [selectedFile?.path]); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!hasModifier(e)) { return; } if (e.key.toLowerCase() === 's') { e.preventDefault(); // Cancel pending auto-save; user wants immediate save if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } if (!isSaving) { void saveDraft().then((saved) => { if (!saved) return; setAutoSaveStatus('saved'); setTimeout(() => setAutoSaveStatus('idle'), 2000); }); } } else if (e.key.toLowerCase() === 'f') { e.preventDefault(); setIsSearchOpen(true); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isSaving, saveDraft]); const loadSelectedFile = React.useCallback(async (node: FileNode) => { const loadId = activeFileLoadIdRef.current + 1; activeFileLoadIdRef.current = loadId; const isCurrentLoad = () => { if (!root) return false; const rootState = useFilesViewTabsStore.getState().byRoot[root]; const currentPath = rootState?.selectedPath ?? rootState?.openPaths[0] ?? null; return activeFileLoadIdRef.current === loadId && currentPath === node.path; }; setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); setContentDetectedBinary(false); setFileLoading(true); // Prime asset URLs; read and stat resolve again immediately before their calls. await resolveFileReadOptions(node.path); if (!isCurrentLoad()) { return; } const selectedIsImage = isImageFile(node.path); const isSvg = isSvgFile(node.path); const selectedIsPdf = isPdfFile(node.path); const selectedIsBinary = isBinaryFile(node.path); if (isMobile) { setShowMobilePageContent(true); } // Desktop: binary images are loaded via readFileBinary (data URL). if (runtime.isDesktop && selectedIsImage && !isSvg) { setFileContent(''); setDraftContent(''); return; } // Web: binary images should not be read as utf8. if (!runtime.isDesktop && selectedIsImage && !isSvg) { setFileContent(''); setDraftContent(''); setLoadedFilePath(node.path); setFileLoading(false); return; } if (selectedIsPdf) { setFileContent(''); setDraftContent(''); setLoadedFilePath(node.path); setFileLoading(false); return; } // Other known binaries (docx/xlsx/zip/…) must never be opened as text — // a later autosave would corrupt them. if (selectedIsBinary) { setFileContent(''); setDraftContent(''); setLoadedFilePath(node.path); setFileLoading(false); return; } await readFile(node.path) .then((content) => { if (!isCurrentLoad()) { return; } if (looksLikeBinaryText(content)) { setContentDetectedBinary(true); setFileContent(''); setDraftContent(''); setLoadedFilePath(node.path); return; } const editorContent = normalizeEditorLineEndings(content); setLoadedFileLineEnding(detectFileLineEnding(content)); setFileContent(editorContent); diagramXmlRef.current = editorContent; diagramSavedXmlRef.current = editorContent; setDraftContent(editorContent.length > MAX_VIEW_CHARS ? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` : editorContent); setLoadedFilePath(node.path); void readFileStat(node.path) .then((stat) => { if (stat && isCurrentLoad()) { lastLoadedFileStatRef.current = stat; } }) .catch(() => {}); }) .catch((error) => { if (!isCurrentLoad()) { return; } if (isDirectoryReadError(error)) { setFileLoading(false); if (root) { setSelectedPath(root, null); } setFileError(null); setFileContent(''); setDraftContent(''); setLoadedFilePath(null); lastLoadedFileStatRef.current = null; if (searchQuery.trim().length > 0) { setSearchQuery(''); } if (isMobile) { setShowMobilePageContent(false); } if (root) { const ancestors = getAncestorPaths(node.path, root); const pathsToExpand = [...ancestors, node.path]; if (pathsToExpand.length > 0) { expandPaths(root, pathsToExpand); } for (const path of pathsToExpand) { if (!loadedDirsRef.current.has(path)) { void loadDirectory(path); } } } return; } if (isFileMissingError(error)) { if (root) { removeOpenPathsByPrefix(root, node.path); } setFileContent(''); setDraftContent(''); setFileError(null); lastLoadedFileStatRef.current = null; if (isMobile) { setShowMobilePageContent(false); } return; } setFileContent(''); setDraftContent(''); setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); lastLoadedFileStatRef.current = null; }) .finally(() => { if (isCurrentLoad()) { setFileLoading(false); } }); }, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, removeOpenPathsByPrefix, resolveFileReadOptions, root, runtime.isDesktop, searchQuery, setSelectedPath, t]); const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => { if (!root) { return; } const ancestors = getAncestorPaths(targetPath, root); const pathsToExpand = includeTarget ? [...ancestors, targetPath] : ancestors; if (pathsToExpand.length > 0) { expandPaths(root, pathsToExpand); } const loadPromises = pathsToExpand.map((path) => { if (!loadedDirsRef.current.has(path)) { return loadDirectory(path); } return undefined; }).filter(Boolean); await Promise.all(loadPromises); }, [expandPaths, loadDirectory, root]); const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => { const index = filesList.findIndex((file) => file.path === path); if (index === -1 || filesList.length <= 1) { return null; } return filesList[index + 1] ?? filesList[index - 1] ?? null; }, []); const handleSelectFile = React.useCallback(async (node: FileNode) => { if (skipDirtyOnceRef.current) { skipDirtyOnceRef.current = false; } else if (isDirty) { setConfirmDiscardOpen(true); pendingSelectFileRef.current = node; return; } if (root) { setSelectedPath(root, node.path); void ensurePathVisible(node.path, false); } setFileError(null); setDesktopImageSrc(''); setFileContent(''); diagramXmlRef.current = ''; diagramSavedXmlRef.current = ''; setDraftContent(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(true); } }, [ensurePathVisible, isDirty, isMobile, root, setSelectedPath]); React.useEffect(() => { if (!selectedFile?.path) { return; } void ensurePathVisible(selectedFile.path, false); }, [ensurePathVisible, selectedFile?.path]); React.useEffect(() => { if (!selectedFile) { activeFileLoadIdRef.current += 1; loadingFilePathRef.current = null; setFileLoading(false); return; } if (loadedFilePath === selectedFile.path || loadingFilePathRef.current === selectedFile.path) { return; } // Selection changes are guarded; this effect is also what restores persisted tabs on mount. const loadingPath = selectedFile.path; loadingFilePathRef.current = loadingPath; void loadSelectedFile(selectedFile).finally(() => { if (loadingFilePathRef.current === loadingPath) { loadingFilePathRef.current = null; } }); }, [fileContentRevision, loadSelectedFile, loadedFilePath, selectedFile]); // Sync isDirty to a ref so the polling interval can read the latest value // without isDirty in its dependency array (avoids interval restart on every edit/save). const isDirtyRef = React.useRef(isDirty); isDirtyRef.current = isDirty; React.useEffect(() => subscribeToFileContentInvalidation(({ runtimeKey, paths }) => { const selectedPath = selectedFile?.path; if ( runtimeKey !== getRuntimeKey() || !selectedPath || isDirtyRef.current || !paths.includes(normalizePath(selectedPath)) ) { return; } activeFileLoadIdRef.current += 1; loadingFilePathRef.current = null; lastLoadedFileStatRef.current = null; setDesktopImageSrc(''); setFileError(null); setLoadedFilePath(null); setFileContentRevision((revision) => revision + 1); }), [selectedFile?.path]); // Poll open file for external changes. // When a change is detected, reset loadedFilePath so the effect above // triggers a single reload — no double-load. React.useEffect(() => { if (!selectedFile?.path || loadedFilePath !== selectedFile.path) { return; } let cancelled = false; const interval = window.setInterval(() => { if (document.hidden) { return; } void readFileStat(selectedFile.path) .then((latestStat) => { if (cancelled || !latestStat) { return; } const previousStat = lastLoadedFileStatRef.current; if (!previousStat || previousStat.path !== selectedFile.path) { lastLoadedFileStatRef.current = latestStat; return; } const changedByMtime = latestStat.mtimeMs !== undefined && previousStat.mtimeMs !== undefined && latestStat.mtimeMs !== previousStat.mtimeMs; const changedBySize = latestStat.size !== previousStat.size; if (!changedByMtime && !changedBySize) { return; } if (isDirtyRef.current) { return; } lastLoadedFileStatRef.current = latestStat; // Reset loadedFilePath so the effect above triggers a single reload. setLoadedFilePath(null); }) .catch(() => {}); }, 2000); return () => { cancelled = true; window.clearInterval(interval); }; }, [loadedFilePath, readFileStat, selectedFile?.path]); const discardAndContinue = React.useCallback(() => { const nextFile = pendingSelectFileRef.current; const nextTab = pendingTabRef.current; const closePath = pendingClosePathRef.current; pendingSelectFileRef.current = null; pendingTabRef.current = null; pendingClosePathRef.current = null; // Allow one guarded navigation (tab/file) without re-opening dialog. skipDirtyOnceRef.current = true; setConfirmDiscardOpen(false); // Discard draft by reverting back to last loaded content setDraftContent(displayedContent); if (closePath) { if (root) { removeOpenPath(root, closePath); } if (selectedFile?.path === closePath) { if (nextFile) { void handleSelectFile(nextFile); } else { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } return; } if (nextFile) { void handleSelectFile(nextFile); return; } if (nextTab) { setSurfaceGuard(null); useUIStore.getState().setActiveSurface(nextTab); } }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setSurfaceGuard, setSelectedPath]); const saveAndContinue = React.useCallback(async () => { const nextFile = pendingSelectFileRef.current; const nextTab = pendingTabRef.current; const closePath = pendingClosePathRef.current; const saved = await saveDraft(); if (!saved) { skipDirtyOnceRef.current = false; return; } pendingSelectFileRef.current = null; pendingTabRef.current = null; pendingClosePathRef.current = null; // We'll proceed after saving; suppress guard reopening. skipDirtyOnceRef.current = true; setConfirmDiscardOpen(false); if (closePath) { if (root) { removeOpenPath(root, closePath); } if (selectedFile?.path === closePath) { if (nextFile) { await handleSelectFile(nextFile); } else { if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } } } return; } if (nextFile) { await handleSelectFile(nextFile); return; } if (nextTab) { setSurfaceGuard(null); useUIStore.getState().setActiveSurface(nextTab); } }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setSurfaceGuard, setSelectedPath]); const handleCloseFile = React.useCallback((path: string) => { const isActive = selectedFile?.path === path; const nextFile = getNextOpenFile(path, openFiles); if (isActive && isDirty) { setConfirmDiscardOpen(true); pendingSelectFileRef.current = nextFile; pendingClosePathRef.current = path; return; } if (root) { removeOpenPath(root, path); } if (!isActive) { return; } if (nextFile) { void handleSelectFile(nextFile); return; } if (root) { setSelectedPath(root, null); } setFileContent(''); setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]); const getFileStatus = React.useCallback((path: string): FileStatus | null => { // Check open status if (openPaths.includes(path)) return 'open'; // Check git status if (gitStatus?.files) { const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; const file = gitStatus.files.find(f => f.path === relative); if (file) { if (file.index === 'A' || file.working_dir === '?') return 'git-added'; if (file.index === 'D') return 'git-deleted'; if (file.index === 'M' || file.working_dir === 'M') return 'git-modified'; } } return null; }, [openPaths, gitStatus, root]); const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => { if (!gitStatus?.files) return null; const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath; const prefix = relativeDir ? `${relativeDir}/` : ''; let modified = 0, added = 0; for (const f of gitStatus.files) { if (f.path.startsWith(prefix)) { if (f.index === 'M' || f.working_dir === 'M') modified++; if (f.index === 'A' || f.working_dir === '?') added++; } } return modified + added > 0 ? { modified, added } : null; }, [gitStatus, root]); const toggleDirectory = React.useCallback(async (dirPath: string) => { const normalized = normalizePath(dirPath); if (!root) return; toggleExpandedPath(root, normalized); if (!loadedDirsRef.current.has(normalized)) { await loadDirectory(normalized); } }, [loadDirectory, root, toggleExpandedPath]); const fileRowPermissions = React.useMemo( () => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }), [canRename, canCreateFile, canCreateFolder, canDelete, canReveal] ); function renderTree(dirPath: string, depth: number): React.ReactNode { const nodes = childrenByDir[dirPath] ?? []; return nodes.map((node, index) => { const isDir = node.type === 'directory'; const isExpanded = isDir && expandedPaths.includes(node.path); const isActive = selectedFile?.path === node.path; const isLast = index === nodes.length - 1; return (
  • {depth > 0 && ( <> {isLast && ( )} )} {isDir && isExpanded && (
      {loadErrorsByDir[node.path] ? (
    • {loadErrorsByDir[node.path]}
    • ) : null} {renderTree(node.path, depth + 1)}
    )}
  • ); }); } const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); const isSelectedSvg = Boolean(selectedFile?.path && isSvgFile(selectedFile.path)); const isSelectedPdf = Boolean(selectedFile?.path && isPdfFile(selectedFile.path)); const isSelectedBinary = Boolean( selectedFile?.path && (isBinaryFile(selectedFile.path) || contentDetectedBinary) ); const isUnsupportedBinary = isSelectedBinary && !isSelectedImage && !isSelectedPdf; const pendingNavigationTargetPath = React.useMemo( () => normalizePath(pendingFileNavigation?.path ?? ''), [pendingFileNavigation?.path], ); const shouldMaskEditorForPendingNavigation = Boolean( pendingFileNavigation && pendingNavigationTargetPath && selectedFilePath && selectedFilePath === pendingNavigationTargetPath && !fileLoading && !fileError && !isSelectedImage && !isSelectedPdf && !isUnsupportedBinary, ); const displaySelectedPath = React.useMemo(() => { return getDisplayPath(root, selectedFilePath); }, [selectedFilePath, root]); const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && !isUnsupportedBinary && fileContent.length > 0); const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); // Keep image/SVG on the preview path: `isBinaryFile` excludes `.svg`, so binary // alone would flip canEdit/isTextFile true and show a dead edit toggle + no-op Save. const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedBinary && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path)); const isTextFile = Boolean(selectedFile && !isSelectedBinary && !isSelectedImage); const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview'); const isEditingFile = (isMarkdown && mdViewMode === 'edit') || (isHtml && htmlViewMode === 'edit') || (isJson && jsonViewMode === 'text') || (!isMarkdown && !isHtml && !isJson && textViewMode === 'edit'); const staticLanguageExtension = React.useMemo( () => (selectedFilePath ? languageByExtension(selectedFilePath) : null), [selectedFilePath], ); const [dynamicLanguageExtension, setDynamicLanguageExtension] = React.useState(null); React.useEffect(() => { let cancelled = false; const selectedPath = selectedFile?.path; if (!selectedPath || staticLanguageExtension) { setDynamicLanguageExtension(null); return; } setDynamicLanguageExtension(null); void loadLanguageByExtension(selectedPath).then((extension) => { if (!cancelled) { setDynamicLanguageExtension(extension); } }); return () => { cancelled = true; }; }, [selectedFile?.path, staticLanguageExtension]); React.useEffect(() => { if (!canEdit && textViewMode === 'edit') { setTextViewMode('view'); } }, [canEdit, textViewMode]); const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode'; const JSON_VIEWER_MODE_KEY = 'openchamber:files:json-viewer-mode'; React.useEffect(() => { const selectedPath = selectedFile?.path; if (!selectedPath) { return; } setTextViewMode(textViewModeByPathRef.current[selectedPath] ?? 'edit'); // Respect per-type localStorage preference when available, // falling back to the setting-derived default when nothing is stored. let mdDefault: PreviewViewMode = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; try { const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); if (stored === 'preview' || stored === 'edit') { mdDefault = stored; } } catch { // Ignore localStorage errors } setMdViewMode(mdViewModeByPathRef.current[selectedPath] ?? mdDefault); let htmlDefault: PreviewViewMode = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; try { const stored = localStorage.getItem(HTML_VIEWER_MODE_KEY); if (stored === 'preview' || stored === 'edit') { htmlDefault = stored; } } catch { // Ignore localStorage errors } setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault); setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? (settingsDefaultFileViewerPreview ? 'preview' : 'edit')); let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text'; try { const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY); if (stored === 'tree' || stored === 'text') { jsonDefault = stored; } } catch { // Ignore localStorage errors } setJsonViewMode(jsonDefault); }, [selectedFile?.path, settingsDefaultFileViewerPreview]); const saveTextViewMode = React.useCallback((mode: TextViewMode) => { const selectedPath = selectedFile?.path; if (selectedPath) { textViewModeByPathRef.current[selectedPath] = mode; } setTextViewMode(mode); }, [selectedFile?.path]); const saveMdViewMode = React.useCallback((mode: PreviewViewMode) => { const selectedPath = selectedFile?.path; if (selectedPath) { mdViewModeByPathRef.current[selectedPath] = mode; } setMdViewMode(mode); try { localStorage.setItem(MD_VIEWER_MODE_KEY, mode); } catch { // Ignore localStorage errors } }, [selectedFile?.path]); const getMdViewMode = React.useCallback((): PreviewViewMode => { return mdViewMode; }, [mdViewMode]); const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => { setJsonViewMode(mode); try { localStorage.setItem(JSON_VIEWER_MODE_KEY, mode); } catch { // Ignore localStorage errors } }, []); const saveHtmlViewMode = React.useCallback((mode: PreviewViewMode) => { const selectedPath = selectedFile?.path; if (selectedPath) { htmlViewModeByPathRef.current[selectedPath] = mode; } setHtmlViewMode(mode); try { localStorage.setItem(HTML_VIEWER_MODE_KEY, mode); } catch { // Ignore localStorage errors } }, [selectedFile?.path]); const saveDrawioViewMode = React.useCallback((mode: PreviewViewMode) => { const selectedPath = selectedFile?.path; if (selectedPath) { drawioViewModeByPathRef.current[selectedPath] = mode; } if (diagramAutoSaveTimerRef.current) { clearTimeout(diagramAutoSaveTimerRef.current); diagramAutoSaveTimerRef.current = null; } if (pendingDrawioPreviewFrameRef.current !== null) { cancelAnimationFrame(pendingDrawioPreviewFrameRef.current); pendingDrawioPreviewFrameRef.current = null; } if (mode === 'edit') { setDraftContent(diagramXmlRef.current || fileContent); setDrawioViewMode(mode); } else { diagramXmlRef.current = draftContent; const pathAtToggle = selectedPath; setDrawioViewMode('edit'); pendingDrawioPreviewFrameRef.current = requestAnimationFrame(() => { pendingDrawioPreviewFrameRef.current = requestAnimationFrame(() => { pendingDrawioPreviewFrameRef.current = null; if (root && pathAtToggle && useFilesViewTabsStore.getState().byRoot[root]?.selectedPath !== pathAtToggle) { return; } setDrawioRemountNonce((value) => value + 1); setDrawioViewMode('preview'); }); }); return; } }, [draftContent, fileContent, root, selectedFile?.path]); const saveDiagramXml = React.useCallback(async (path: string, xml: string) => { if (!files.writeFile || xml === diagramSavedXmlRef.current) { return false; } const result = await files.writeFile(path, xml); if (!result?.success) { toast.error(t('filesView.toast.writeFileFailed')); return false; } diagramXmlRef.current = xml; diagramSavedXmlRef.current = xml; setDraftContent(xml); const stat = await readFileStat(path).catch(() => null); if (stat) { lastLoadedFileStatRef.current = stat; } return true; }, [files, readFileStat, t]); React.useEffect(() => { return () => { if (diagramAutoSaveTimerRef.current) { clearTimeout(diagramAutoSaveTimerRef.current); diagramAutoSaveTimerRef.current = null; } if (pendingDrawioPreviewFrameRef.current !== null) { cancelAnimationFrame(pendingDrawioPreviewFrameRef.current); pendingDrawioPreviewFrameRef.current = null; } }; }, [drawioViewMode, selectedFile?.path]); const handleDiagramChange = React.useCallback((xml: string) => { diagramXmlRef.current = xml; if (!autoSaveEnabled || !selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) { return; } if (diagramAutoSaveTimerRef.current) { clearTimeout(diagramAutoSaveTimerRef.current); } const path = selectedFile.path; diagramAutoSaveTimerRef.current = setTimeout(() => { diagramAutoSaveTimerRef.current = null; void saveDiagramXml(path, xml).then((saved) => { if (!saved) return; setDiagramSaved(true); setTimeout(() => setDiagramSaved(false), 1500); }).catch((error) => { toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed')); }); }, AUTO_SAVE_DELAY); }, [autoSaveEnabled, drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]); const diagramEditorXml = React.useMemo(() => { if (!isDrawio) { return fileContent; } return diagramXmlRef.current || draftContent || fileContent; }, [draftContent, fileContent, isDrawio]); const getHtmlViewMode = React.useCallback((): PreviewViewMode => { return htmlViewMode; }, [htmlViewMode]); React.useEffect(() => { const applyDefaultFileViewerMode = (enabled: boolean) => { const previewMode: PreviewViewMode = enabled ? 'preview' : 'edit'; const nextJsonMode: 'tree' | 'text' = enabled ? 'tree' : 'text'; for (const path of openPaths) { textViewModeByPathRef.current[path] = 'edit'; if (isMarkdownFile(path)) { mdViewModeByPathRef.current[path] = previewMode; } if (isHtmlFile(path)) { htmlViewModeByPathRef.current[path] = previewMode; } if (isDrawioFile(path)) { drawioViewModeByPathRef.current[path] = previewMode; } } setTextViewMode('edit'); setMdViewMode(previewMode); setHtmlViewMode(previewMode); setDrawioViewMode(previewMode); setJsonViewMode(nextJsonMode); try { localStorage.setItem(MD_VIEWER_MODE_KEY, previewMode); localStorage.setItem(HTML_VIEWER_MODE_KEY, previewMode); localStorage.setItem(JSON_VIEWER_MODE_KEY, nextJsonMode); } catch { // Ignore localStorage errors } }; const handleFileViewerModeChanged = (event: Event) => { const enabled = Boolean((event as CustomEvent<{ enabled?: boolean }>).detail?.enabled); applyDefaultFileViewerMode(enabled); }; window.addEventListener('openchamber:file-viewer-preview-mode-changed', handleFileViewerModeChanged); return () => { window.removeEventListener('openchamber:file-viewer-preview-mode-changed', handleFileViewerModeChanged); }; }, [openPaths]); React.useEffect(() => { if (!pendingFileNavigation || !root) { return; } const scheduleNavigationRetry = () => { if (typeof window === 'undefined') { return; } if (pendingNavigationRafRef.current !== null) { return; } pendingNavigationRafRef.current = window.requestAnimationFrame(() => { pendingNavigationRafRef.current = null; setEditorViewReadyNonce((value) => value + 1); }); }; const isEditorSyncedWithDraft = (view: EditorView, expectedContent: string): boolean => { if (view.state.doc.length !== expectedContent.length) { return false; } if (expectedContent.length === 0) { return true; } const sampleSize = Math.min(128, expectedContent.length); const startSample = view.state.sliceDoc(0, sampleSize); if (startSample !== expectedContent.slice(0, sampleSize)) { return false; } const endFrom = Math.max(0, expectedContent.length - sampleSize); const endSample = view.state.sliceDoc(endFrom, expectedContent.length); return endSample === expectedContent.slice(endFrom); }; const targetPath = normalizePath(pendingFileNavigation.path); if (!targetPath) { setPendingFileNavigation(null); pendingNavigationCycleRef.current = { key: '', attempts: 0 }; return; } const navigationKey = `${targetPath}:${pendingFileNavigation.line}:${pendingFileNavigation.column ?? 1}`; if (pendingNavigationCycleRef.current.key !== navigationKey) { pendingNavigationCycleRef.current = { key: navigationKey, attempts: 0 }; } if (selectedFile?.path !== targetPath) { if (confirmDiscardOpen) { return; } void handleSelectFile(toFileNode(targetPath)); return; } if (fileLoading || loadedFilePath !== targetPath) { return; } if (fileError || isSelectedImage || isSelectedPdf || isUnsupportedBinary) { setPendingFileNavigation(null); pendingNavigationCycleRef.current = { key: '', attempts: 0 }; return; } if (!canEdit) { return; } if (textViewMode !== 'edit') { setTextViewMode('edit'); return; } const view = editorViewRef.current; if (!view) { scheduleNavigationRetry(); return; } if (!isEditorSyncedWithDraft(view, draftContent)) { scheduleNavigationRetry(); return; } const targetLineNumber = Math.max(1, Math.min(pendingFileNavigation.line, view.state.doc.lines)); const targetLine = view.state.doc.line(targetLineNumber); const targetColumn = Math.max(1, pendingFileNavigation.column || 1); const lineLength = Math.max(0, targetLine.to - targetLine.from); const clampedColumnOffset = Math.min(lineLength, targetColumn - 1); const targetPosition = targetLine.from + clampedColumnOffset; const isAtTarget = view.state.selection.main.head === targetPosition; const shouldDispatch = !isAtTarget || pendingNavigationCycleRef.current.attempts === 0; if (shouldDispatch) { pendingNavigationCycleRef.current.attempts += 1; view.dispatch({ selection: { anchor: targetPosition }, effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }), }); view.focus(); scheduleNavigationRetry(); return; } if (typeof window !== 'undefined') { window.requestAnimationFrame(() => { const syncedView = editorViewRef.current; if (!syncedView) { return; } syncedView.dispatch({ selection: { anchor: targetPosition }, effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }), }); syncedView.focus(); }); } setPendingFileNavigation(null); pendingNavigationCycleRef.current = { key: '', attempts: 0 }; }, [ canEdit, confirmDiscardOpen, draftContent, editorViewReadyNonce, fileError, fileLoading, isSelectedImage, isSelectedPdf, isUnsupportedBinary, loadedFilePath, handleSelectFile, pendingFileNavigation, root, selectedFile?.path, setPendingFileNavigation, textViewMode, toFileNode, ]); React.useEffect(() => { if (!pendingFileFocusPath || !root) { return; } const targetPath = normalizePath(pendingFileFocusPath); if (!targetPath) { setPendingFileFocusPath(null); return; } if (selectedFile?.path !== targetPath) { // Selection is owned by the tab sync / user. A pending focus request must // not steal selection back (e.g. after the user switched to another tab // while this file was still loading). Wait; clear once it loads or the // request is superseded. return; } if (fileLoading || loadedFilePath !== targetPath) { return; } // Best-effort focus: preview renderers (markdown/html preview, drawio, // JSON tree, images, PDFs) never mount a CodeMirror editor, so the request // must clear regardless — otherwise it lingers and replays on every // dependency change. if (!fileError && !isSelectedImage && !isSelectedPdf && !isUnsupportedBinary && canEdit && textViewMode === 'edit') { editorViewRef.current?.focus(); } setPendingFileFocusPath(null); }, [ canEdit, fileError, fileLoading, isSelectedImage, isSelectedPdf, isUnsupportedBinary, loadedFilePath, pendingFileFocusPath, root, selectedFile?.path, setPendingFileFocusPath, textViewMode, ]); const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => { if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') { return; } const viewport = window.visualViewport; if (!viewport) { return; } const layoutHeight = document.documentElement.clientHeight || window.innerHeight; const occludedBottom = Math.max(0, layoutHeight - (viewport.offsetTop + viewport.height)); if (occludedBottom <= 0) { return; } const head = view.state.selection.main.head; const cursorRect = view.coordsAtPos(head); if (!cursorRect) { return; } const visibleBottom = Math.round(viewport.offsetTop + viewport.height); const clearance = 20; const overlap = cursorRect.bottom + clearance - visibleBottom; if (overlap <= 0) { return; } view.scrollDOM.scrollTop += overlap; }, [isMobile]); React.useEffect(() => { if (!isMobile || typeof window === 'undefined') { return; } const runNudge = () => { window.requestAnimationFrame(() => { nudgeEditorSelectionAboveKeyboard(editorViewRef.current); }); }; const viewport = window.visualViewport; viewport?.addEventListener('resize', runNudge); viewport?.addEventListener('scroll', runNudge, { passive: true }); document.addEventListener('selectionchange', runNudge); return () => { viewport?.removeEventListener('resize', runNudge); viewport?.removeEventListener('scroll', runNudge); document.removeEventListener('selectionchange', runNudge); }; }, [isMobile, nudgeEditorSelectionAboveKeyboard]); React.useEffect(() => { if (!canEdit || textViewMode !== 'edit' || isMobile) { return; } const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides); const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as Element | null; if (target?.closest('[role="dialog"]')) { return; } const isEditorTarget = Boolean(target?.closest('.cm-editor')); const isTypingTarget = Boolean( target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]') ); if (isTypingTarget && !isEditorTarget) { return; } const activeElement = document.activeElement as Element | null; const editorHasFocus = Boolean(activeElement?.closest('.cm-editor')); if (!editorHasFocus) { return; } if (eventMatchesShortcut(event, goToLineCombo)) { event.preventDefault(); setIsGoToLineOpen(true); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [canEdit, isMobile, shortcutOverrides, textViewMode]); const editorFontSize = useUIStore((state) => state.editorFontSize); const editorExtensions = React.useMemo(() => { if (!selectedFile?.path) { return [createFlexokiCodeMirrorTheme(currentTheme, { fontSize: editorFontSize })]; } // Shiki token colors (worker-backed) match the Shiki file view exactly. // Same language resolver as the view, so both agree on the language. When // Shiki is the color source, drop the lezer token colors to avoid a // competing highlighter (Keep the lezer language for indentation/folding). const shikiLanguage = getLanguageFromExtension(selectedFile.path); const extensions = [createFlexokiCodeMirrorTheme(currentTheme, shikiLanguage ? { syntaxColors: false, fontSize: editorFontSize } : { fontSize: editorFontSize })]; const language = staticLanguageExtension ?? dynamicLanguageExtension; if (language) { extensions.push(language); } if (shikiLanguage) { extensions.push(shikiHighlightExtension({ language: shikiLanguage, themeName: currentTheme.metadata.id, theme: getResolvedShikiTheme(currentTheme), })); } if (wrapLines) { extensions.push(EditorView.lineWrapping); } if (isMobile) { extensions.push(EditorView.updateListener.of((update) => { if (!update.view.hasFocus) { return; } if (!(update.selectionSet || update.focusChanged || update.viewportChanged || update.geometryChanged)) { return; } window.requestAnimationFrame(() => { nudgeEditorSelectionAboveKeyboard(update.view); }); })); } return extensions; }, [currentTheme, selectedFile?.path, staticLanguageExtension, dynamicLanguageExtension, wrapLines, isMobile, nudgeEditorSelectionAboveKeyboard, editorFontSize]); const pierreTheme = React.useMemo( () => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }), [lightTheme.metadata.id, darkTheme.metadata.id], ); const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf && (!selectedFileReadOptions.allowOutsideWorkspace || selectedFileReadOptions.outsideFileGrant) ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}|${fileContentRevision}` : ''; const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode ? `${selectedFile.path}|${fileContentRevision}` : ''; const assetAuthErrorFallback = t('filesView.error.readFileFailed'); const { readyKey: htmlAssetAuthReadyKey, nonce: htmlPreviewNonce } = useAssetAuthRefresh(htmlAssetAuthKey, setFileError, assetAuthErrorFallback); const { readyKey: pdfAssetAuthReadyKey, nonce: pdfPreviewNonce } = useAssetAuthRefresh(pdfAssetAuthKey, setFileError, assetAuthErrorFallback); const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey); const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey); const imageSrc = selectedFile?.path && isSelectedImage ? (isSelectedSvg ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` : desktopImageSrc) : ''; const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthKey && pdfAssetAuthReadyKey === pdfAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path: selectedFile.path, allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, outsideFileGrant: selectedFileReadOptions.outsideFileGrant, directory: root || undefined, }) : ''; const renderPdfPreview = React.useCallback((file: FileNode) => (