(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, selectedFileReadOptions).catch(() => null);
- if (stat) {
- lastLoadedFileStatRef.current = stat;
- }
- return true;
- }, [files, readFileStat, selectedFileReadOptions, 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 (!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);
- }, [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) {
- 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,
- 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 && canEdit && textViewMode === 'edit') {
- editorViewRef.current?.focus();
- }
-
- setPendingFileFocusPath(null);
- }, [
- canEdit,
- fileError,
- fileLoading,
- isSelectedImage,
- isSelectedPdf,
- 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 imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
- ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
- : '';
-
- const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
- ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
- : '';
-
- const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode
- ? selectedFile.path
- : '';
-
- const assetAuthErrorFallback = t('filesView.error.readFileFailed');
- const { readyKey: imageAssetAuthReadyKey, nonce: imagePreviewNonce } =
- useAssetAuthRefresh(imageAssetAuthKey, setFileError, assetAuthErrorFallback);
- const { readyKey: htmlAssetAuthReadyKey, nonce: htmlPreviewNonce } =
- useAssetAuthRefresh(htmlAssetAuthKey, setFileError, assetAuthErrorFallback);
- const { readyKey: pdfAssetAuthReadyKey, nonce: pdfPreviewNonce } =
- useAssetAuthRefresh(pdfAssetAuthKey, setFileError, assetAuthErrorFallback);
-
- const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
- const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey);
- const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
-
- const imageSrc = selectedFile?.path && isSelectedImage
- ? (runtime.isDesktop
- ? (isSelectedSvg
- ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
- : desktopImageSrc)
- : (isSelectedSvg
- ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
- : imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
- path: selectedFile.path,
- allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
- outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
- directory: root || undefined,
- }) : ''))
- : '';
-
- const pdfSrc = selectedFile?.path && isSelectedPdf && 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) => (
-
-
-
- ), [pdfSrc, pdfPreviewNonce]);
-
- React.useEffect(() => {
- let cancelled = false;
-
- const resolveDesktopImage = async () => {
- if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) {
- if (desktopImageBlobUrlRef.current) {
- URL.revokeObjectURL(desktopImageBlobUrlRef.current);
- desktopImageBlobUrlRef.current = '';
- }
- setDesktopImageSrc('');
- return;
- }
-
- setFileError(null);
-
- if (desktopImageBlobUrlRef.current) {
- URL.revokeObjectURL(desktopImageBlobUrlRef.current);
- desktopImageBlobUrlRef.current = '';
- }
-
- const srcPromise = files.readFileBinary
- ? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
- : (async () => {
- const response = await runtimeFetch('/api/fs/raw', {
- query: {
- path: selectedFile.path,
- allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
- outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
- directory: root || undefined,
- },
- });
- if (!response.ok) {
- throw new Error(t('filesView.error.readFileFailed'));
- }
- const blob = await response.blob();
- const url = URL.createObjectURL(blob);
- if (cancelled) {
- URL.revokeObjectURL(url);
- return '';
- }
- desktopImageBlobUrlRef.current = url;
- return url;
- })();
-
- await srcPromise
- .then((src) => {
- if (!cancelled) {
- setDesktopImageSrc(src);
- setLoadedFilePath(selectedFile.path);
- }
- })
- .catch((error) => {
- if (desktopImageBlobUrlRef.current) {
- URL.revokeObjectURL(desktopImageBlobUrlRef.current);
- desktopImageBlobUrlRef.current = '';
- }
- if (!cancelled) {
- setDesktopImageSrc('');
- setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
- setLoadedFilePath(null);
- }
- })
- .finally(() => {
- if (!cancelled) {
- setFileLoading(false);
- }
- });
- };
-
- void resolveDesktopImage();
-
- return () => {
- cancelled = true;
- };
- }, [files, isSelectedImage, isSelectedSvg, root, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
-
- React.useEffect(() => {
- return () => {
- if (desktopImageBlobUrlRef.current) {
- URL.revokeObjectURL(desktopImageBlobUrlRef.current);
- desktopImageBlobUrlRef.current = '';
- }
- };
- }, []);
-
- const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
-
- const blockWidgets = React.useMemo(() => {
- return buildCodeMirrorCommentWidgets({
- drafts: filesFileDrafts,
- editingDraftId,
- commentText,
- onTextChange: setCommentText,
- selection: lineSelection,
- isDragging,
- fileLabel: selectedFile?.path ?? '',
- newWidgetId: 'files-new-comment-input',
- mapDraftToRange: (draft) => ({ start: draft.startLine, end: draft.endLine }),
- onSave: handleSaveComment,
- onCancel: () => {
- setLineSelection(null);
- cancel();
- },
- onEdit: (draft) => {
- startEdit(draft);
- setLineSelection({ start: draft.startLine, end: draft.endLine });
- },
- onDelete: deleteDraft,
- });
- }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, setCommentText, startEdit]);
-
- const renderShikiFileView = React.useCallback((file: FileNode, content: string) => {
- return (
-
- );
- }, [currentTheme.metadata.variant, pierreTheme, wrapLines]);
-
- const renderFloatingFileControls = ({
- exitFullscreenOnly = false,
- layout = 'floating',
- }: { exitFullscreenOnly?: boolean; layout?: 'floating' | 'docked' } = {}) => {
- if (!selectedFile) {
- return null;
- }
-
- const docked = layout === 'docked';
- const wrapperCls = docked
- ? 'pointer-events-auto flex flex-wrap items-center gap-1'
- : 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm';
-
- const withTooltip = (label: React.ReactNode, trigger: React.ReactElement) => (
-
-
-
- {trigger}
-
-
- {label}
-
- );
-
- return (
-
- {canEdit && isEditingFile && (
- <>
- {isSaving ? (
-
-
- {t('filesView.editor.saving')}
-
- ) : autoSaveEnabled && autoSaveStatus === 'saved' && !isDirty ? (
-
-
- {t('filesView.editor.saved')}
-
- ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }),
-
- ) : null}
- {withTooltip(autoSaveEnabled ? t('filesView.editor.autoSaveOn') : t('filesView.editor.manualSave'),
-
- )}
- >
- )}
-
-
-
-
-
-
-
-
-
-
- {t('filesView.editor.openInDesktopApp')}
-
-
- {openInApps.map((app) => (
- void handleOpenInApp(app)}
- >
-
- {app.label}
-
- ))}
- {openInCacheStale ? (
- void loadOpenInApps(true)}
- >
-
- {t('filesView.editor.refreshApps')}
-
- ) : null}
-
-
-
- {!isSelectedImage && !isSelectedPdf && (
- <>
- {withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'),
-
- )}
- {textViewMode === 'edit' && (
- <>
- {withTooltip(t('filesView.editor.findInFile'),
-
- )}
- {withTooltip(t('filesView.editor.goToLine'),
-
- )}
-
- >
- )}
- >
- )}
-
- {canUseShikiFileView && canEdit && !isJson && !isHtml && (
-
{
- saveTextViewMode(textViewMode === 'view' ? 'edit' : 'view');
- }}
- />
- )}
-
- {isMarkdown && (
- withTooltip(
- t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode'),
-
- )
- )}
-
- {isHtmlFile(selectedFile?.path ?? '') && (
- {
- saveHtmlViewMode(getHtmlViewMode() === 'preview' ? 'edit' : 'preview');
- }}
- />
- )}
-
- {isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
-
-
-
-
-
- {isTTSPlaying ? t('filesView.tts.stopSpeaking') : t('filesView.tts.readAloud')}
-
-
- )}
-
- {isDrawio && (
- <>
- saveDrawioViewMode(drawioViewMode === 'preview' ? 'edit' : 'preview')}
- />
- {drawioViewMode === 'preview' && (
-
- )}
- >
- )}
-
- {isJson && (
- withTooltip(jsonViewMode === 'tree' ? t('filesView.editor.switchToTextView') : t('filesView.editor.switchToTreeView'),
-
- )
- )}
-
- {canCopy && (
- withTooltip(t('filesView.editor.copyFileContents'),
-
- )
- )}
-
- {canCopyPath && (
- withTooltip(t('filesView.editor.copyFilePathTitle', { path: displaySelectedPath }),
-
- )
- )}
-
- {files.downloadFile && (
- withTooltip(t('filesView.editor.saveFile'),
-
- )
- )}
-
- {exitFullscreenOnly ? (
- withTooltip(t('filesView.editor.exitFullscreen'),
-
- )
- ) : (!isMobile && mode === 'full' && (
- withTooltip(isFullscreen ? t('filesView.editor.exitFullscreen') : t('filesView.editor.fullscreen'),
-
- )
- ))}
-
- );
- };
-
- const fileViewer = (
-
-
-
- {/* Row 1: Tabs */}
- {showEditorTabsRow ? (
-
- {isMobile && showMobilePageContent && (
-
- )}
-
- {isMobile ? (
- selectedFile ? (
-
-
-
-
-
- {openFiles.map((file) => {
- const isActive = selectedFile?.path === file.path;
- return (
- {
- const target = event.target as HTMLElement;
- if (target.closest('[data-close-open-file]')) {
- event.preventDefault();
- return;
- }
- if (!isActive) {
- void handleSelectFile(file);
- }
- }}
- className={cn(
- 'flex min-w-0 items-center justify-between gap-2 overflow-hidden',
- isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
- )}
- >
-
-
-
-
-
-
- );
- })}
-
-
- ) : (
-
{t('filesView.editor.selectFile')}
- )
- ) : (
- openFiles.length > 0 ? (
-
- {editorTabsOverflow.left && (
-
- )}
- {editorTabsOverflow.right && (
-
- )}
-
- {openFiles.map((file) => {
- const isActive = selectedFile?.path === file.path;
- return (
-
-
-
-
-
- );
- })}
-
-
- ) : (
-
{t('filesView.editor.selectFile')}
- )
- )}
-
- ) : null}
-
- {/* Row 2: Docked editor toolbar (expanded). Desktop-only opt-in. */}
- {settingsExpandedEditorToolbar && !isMobile && selectedFile ? (
-
- {displaySelectedPath ? (
-
- {displaySelectedPath}
-
- ) : null}
-
- {renderFloatingFileControls({ layout: 'docked' })}
-
-
- ) : null}
-
-
-
-
- {selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar && !isMobile) && (
-
{
- if (toolbarDropdownOpenCountRef.current > 0) return;
- setIsFloatingToolbarOpen(false);
- }}
- >
- {isFloatingToolbarOpen ? (
- renderFloatingFileControls()
- ) : (
-
- {isMarkdown ? (
-
-
-
-
-
-
-
- {t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
-
-
- ) : null}
-
-
- setIsFloatingToolbarOpen(true)}
- >
-
-
-
- {t('filesView.editor.controlsTitle')}
-
-
- )}
-
- )}
-
- {!selectedFile ? (
- {t('filesView.editor.pickFileFromTree')}
- ) : (fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
- suppressFileLoadingIndicator
- ?
- : (
-
-
- {t('filesView.state.loading')}
-
- )
- ) : fileError ? (
- {fileError}
- ) : isSelectedImage ? (
-
-

-
- ) : isSelectedPdf ? (
- renderPdfPreview(selectedFile)
- ) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
-
-
-
- ) : selectedFile && isJson && jsonViewMode === 'tree' ? (
-
- {t('filesView.error.jsonViewerUnavailable')}
-
- {t('filesView.error.switchToTextMode')}
-
-
- }
- >
-
-
-
-
- ) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
-
- {fileContent.length > 500 * 1024 && (
-
- {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
-
- )}
-
- {t('filesView.error.previewUnavailable')}
-
- {t('filesView.error.switchToEditMode')}
-
-
- }
- >
-
-
-
- ) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
- isHtmlAssetAuthLoading ? (
-
- {t('common.loading')}
-
- ) : (
-
-
- )
- ) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
- renderShikiFileView(selectedFile, draftContent)
- ) : (
-
-
- {
- editorViewRef.current = view;
- setEditorViewReadyNonce((value) => value + 1);
- window.requestAnimationFrame(() => {
- nudgeEditorSelectionAboveKeyboard(view);
- });
- }}
- onViewDestroy={() => {
- if (editorViewRef.current) {
- editorViewRef.current = null;
- }
- setEditorViewReadyNonce((value) => value + 1);
- }}
- enableSearch
- searchOpen={isSearchOpen}
- onSearchOpenChange={setIsSearchOpen}
- highlightLines={lineSelection
- ? {
- start: Math.min(lineSelection.start, lineSelection.end),
- end: Math.max(lineSelection.start, lineSelection.end),
- }
- : undefined}
- lineNumbersConfig={{
- domEventHandlers: {
- mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
- if (!(event instanceof MouseEvent)) {
- return false;
- }
- if (event.button !== 0) {
- return false;
- }
- event.preventDefault();
-
- const lineNumber = view.state.doc.lineAt(line.from).number;
-
- if (
- lineSelection &&
- !event.shiftKey &&
- Math.min(lineSelection.start, lineSelection.end) === lineNumber &&
- Math.max(lineSelection.start, lineSelection.end) === lineNumber
- ) {
- setLineSelection(null);
- cancel();
- isSelectingRef.current = false;
- selectionStartRef.current = null;
- setIsDragging(false);
- return true;
- }
-
- // Mobile: tap-to-extend selection
- if (isMobile && lineSelection && !event.shiftKey) {
- const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
- const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
- setLineSelection({ start, end });
- isSelectingRef.current = false;
- selectionStartRef.current = null;
- setIsDragging(false);
- return true;
- }
-
- isSelectingRef.current = true;
- selectionStartRef.current = lineNumber;
- setIsDragging(true);
-
- if (lineSelection && event.shiftKey) {
- const start = Math.min(lineSelection.start, lineNumber);
- const end = Math.max(lineSelection.end, lineNumber);
- setLineSelection({ start, end });
- } else {
- setLineSelection({ start: lineNumber, end: lineNumber });
- }
-
- return true;
- },
- mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
- if (!(event instanceof MouseEvent)) {
- return false;
- }
- if (event.buttons !== 1) {
- return false;
- }
- if (!isSelectingRef.current || selectionStartRef.current === null) {
- return false;
- }
-
- const lineNumber = view.state.doc.lineAt(line.from).number;
- const start = Math.min(selectionStartRef.current, lineNumber);
- const end = Math.max(selectionStartRef.current, lineNumber);
- setLineSelection({ start, end });
- setIsDragging(true);
- return false;
- },
- mouseup: () => {
- isSelectingRef.current = false;
- selectionStartRef.current = null;
- setIsDragging(false);
- return false;
- },
- },
- }}
- />
-
- {shouldMaskEditorForPendingNavigation && (
-
-
-
- {t('filesView.state.openingFileAtChange')}
-
-
- )}
-
- )}
-
-
-
- );
-
- const hasTree = Boolean(root && childrenByDir[root]);
- const rootLoadError = root ? loadErrorsByDir[root] : null;
-
- const treePanel = (
-
-
-
-
-
- setSearchQuery(e.target.value)}
- placeholder={t('filesView.tree.search.placeholder')}
- className="h-8 pl-8 pr-8 typography-meta"
- />
- {searchQuery.trim().length > 0 && (
-
- )}
-
-
-
-
-
-
-
- {t('filesView.tree.actions.newFileTitle')}
-
-
-
-
-
-
-
- {t('filesView.tree.actions.newFolderTitle')}
-
-
-
-
-
-
-
- {t('filesView.tree.actions.refreshTitle')}
-
-
-
-
-
-
- {searching ? (
- -
-
- {t('filesView.tree.search.searching')}
-
- ) : searchResults.length > 0 ? (
- searchResults.map((node) => {
- const isActive = selectedFile?.path === node.path;
- return (
- -
-
-
- );
- })
- ) : rootLoadError ? (
- -
- {rootLoadError}
-
-
- ) : hasTree ? (
- renderTree(root, 0)
- ) : (
- - {t('filesView.state.loading')}
- )}
-
-
-
- );
-
- // Fullscreen file viewer overlay
- const fullscreenViewer = mode === 'full' && isFullscreen && selectedFile && (
-
- {/* Fullscreen content */}
-
-
- {renderFloatingFileControls({ exitFullscreenOnly: true })}
-
-
- {(fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
- suppressFileLoadingIndicator
- ?
- : (
-
-
- Loading…
-
- )
- ) : fileError ? (
- {fileError}
- ) : isSelectedImage ? (
-
-

-
- ) : isSelectedPdf ? (
- renderPdfPreview(selectedFile)
- ) : isMarkdown && getMdViewMode() === 'preview' ? (
-
- {fileContent.length > 500 * 1024 && (
-
- {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
-
- )}
-
- {t('filesView.error.previewUnavailable')}
-
- {t('filesView.error.switchToEditMode')}
-
-
- }
- >
-
-
-
- ) : canUseShikiFileView && textViewMode === 'view' ? (
- renderShikiFileView(selectedFile, draftContent)
- ) : (
-
-
- {
- editorViewRef.current = view;
- window.requestAnimationFrame(() => {
- nudgeEditorSelectionAboveKeyboard(view);
- });
- }}
- onViewDestroy={() => {
- if (editorViewRef.current) {
- editorViewRef.current = null;
- }
- }}
- />
-
- {shouldMaskEditorForPendingNavigation && (
-
-
-
- {t('filesView.state.openingFileAtChange')}
-
-
- )}
-
- )}
-
-
-
- );
-
- return (
-
-
- {fullscreenViewer}
- {isMobile ? (
- showMobilePageContent ? (
- fileViewer
- ) : (
- treePanel
- )
- ) : mode === 'editor-only' ? (
-
- ) : (
-
- {screenWidth >= 700 && (
-
- {treePanel}
-
- )}
-
- {fileViewer}
-
-
- )}
-
- );
-};
+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, isDrawioFile, isImageFile, isPdfFile } from '@/lib/toolHelpers';
+import { getRuntimeUrlResolver } from '@/lib/runtime-url';
+import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
+import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
+import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
+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, 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 { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
+import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
+import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
+import { useI18n } from '@/lib/i18n';
+
+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 (
+
+ {name}
+ {overflowing ? (
+
+ {name}
+ {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;
+const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
+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 getInitialAutoSaveEnabled = (): boolean => {
+ if (typeof window === 'undefined') {
+ return true;
+ }
+
+ try {
+ return window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_KEY) !== 'false';
+ } catch {
+ return true;
+ }
+};
+
+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;
+ 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,
+ 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 handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
+ if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) {
+ return;
+ }
+ event?.preventDefault();
+ setRightClickMenuPath(node.path);
+ }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, 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('sidebarFilesTree.menu.save')}
+
+ )}
+ {canReveal && (
+ - { 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)}>
+ }>
+
+ {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
+
+ 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 (
+
+ );
+};
+
+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 alwaysShowActions = isMobile || isTablet;
+ const showHidden = useDirectoryShowHidden();
+ const showGitignored = useFilesViewShowGitignored();
+
+ const currentDirectory = useEffectiveDirectory() ?? '';
+ const root = normalizePath(currentDirectory.trim());
+ const showEditorTabsRow = isMobile || 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 addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
+ 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 ?? '';
+
+ 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],
+ );
+
+ // 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