(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) => (
), [pdfSrc, pdfPreviewNonce]);
React.useEffect(() => {
let cancelled = false;
let objectUrl = '';
const resolveDesktopImage = async () => {
if (!selectedFile?.path || !isSelectedImage || isSelectedSvg) {
setDesktopImageSrc('');
return;
}
setFileError(null);
const readOptions = await resolveFileReadOptions(selectedFile.path);
if (cancelled) {
return;
}
const srcPromise = files.readFileBinary
? files.readFileBinary(selectedFile.path, readOptions).then((result) => result.dataUrl)
: (async () => {
const response = await runtimeFetch('/api/fs/raw', {
query: {
path: selectedFile.path,
allowOutsideWorkspace: readOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: readOptions.outsideFileGrant,
directory: root || undefined,
},
});
if (!response.ok) {
throw new Error(t('filesView.error.readFileFailed'));
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
objectUrl = '';
return '';
}
return objectUrl;
})();
await srcPromise
.then((src) => {
if (!cancelled) {
setDesktopImageSrc(src);
setLoadedFilePath(selectedFile.path);
}
})
.catch((error) => {
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;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [fileContentRevision, files, isSelectedImage, isSelectedSvg, resolveFileReadOptions, root, selectedFile?.path, selectedFileReadOptions, t]);
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 && !isUnsupportedBinary && (
<>
{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 opt-in; ALWAYS on
for mobile — floating hover controls don't work with touch. */}
{(settingsExpandedEditorToolbar || isMobile) && selectedFile ? (
{/* Mobile hosts already show the file name in their own header;
a truncated duplicate here just eats toolbar width. */}
{displaySelectedPath && !isMobile ? (
{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 || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator
?
: (
{t('filesView.state.loading')}
)
) : fileError ? (
{fileError}
) : isSelectedImage ? (
) : isSelectedPdf ? (
renderPdfPreview(selectedFile)
) : isUnsupportedBinary ? (
{t('filesView.editor.cannotPreviewBinary')}
{t('filesView.editor.binaryFileDescription')}
{files.downloadFile ? (
) : null}
) : 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 = (
{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 || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator
?
: (
Loading…
)
) : fileError ? (
{fileError}
) : isSelectedImage ? (
) : isSelectedPdf ? (
renderPdfPreview(selectedFile)
) : isUnsupportedBinary ? (
{t('filesView.editor.cannotPreviewBinary')}
{t('filesView.editor.binaryFileDescription')}
{files.downloadFile ? (
) : null}
) : isMarkdown && getMdViewMode() === 'preview' ? (
{selectedFile ? (
) : null}
{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}
)}
);
};