From 57c5808ef23c3b3a4e4bb83a08e6fbae266f097a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 18 Jun 2026 01:24:37 +0300 Subject: [PATCH] fix(files): refresh URL auth token proactively for asset previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oc_url_token has a ~50s effective lifetime and was only fetched once at preview mount, so HTML/image/PDF previews cycled to 'authentication required' when it expired and nothing forced a re-render with a fresh token. Add a consumer-gated proactive refresh in runtime-auth: while at least one url-token consumer is active, a single scheduler mints a fresh token just before the skew window and swaps it in atomically (the previous token stays valid until the new one lands — no empty-token window for other consumers). acquire/release manage the consumer count; subscribe fires only on a real token replacement. FilesView consumes this via a shared useAssetAuthRefresh hook (replacing three near-duplicate effects) and remounts the iframe/img only when the token actually changes, not on a blind interval. --- CHANGELOG.md | 1 + .../ui/src/components/views/FilesView.tsx | 1555 ++++++++--------- packages/ui/src/lib/runtime-auth.ts | 108 +- 3 files changed, 881 insertions(+), 783 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b63c2d2..5e4adbbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Files: HTML, image, and PDF previews no longer cycle to "authentication required" every ~50 seconds. The short-lived URL auth token is now refreshed proactively before it expires (centrally, only while a preview is open), and previews remount only when the token actually changes. - Chat: adjacent paragraphs in assistant messages now render with a visible gap instead of collapsing into a single visual line. Reasoning and tool-card markdown stay compact, and messages don't gain trailing space at the bottom. ## [1.13.1] - 2026-06-17 diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 3bd45c44..63a6e0c9 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -10,14 +10,14 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from '@/components/ui/context-menu'; +} 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'; @@ -29,8 +29,8 @@ 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 { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight'; +import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { File as PierreFile } from '@pierre/diffs/react'; import { Dialog, @@ -44,11 +44,11 @@ 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 { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; -import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; -import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; +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'; @@ -64,10 +64,10 @@ 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 { 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'; @@ -360,13 +360,13 @@ const isJsonFile = (path: string): boolean => { 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 { +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; @@ -383,10 +383,10 @@ interface FileRowProps { canReveal: boolean; }; downloadFile?: (path: string) => Promise; - contextMenuPath: string | null; - setContextMenuPath: (path: string | null) => void; - rightClickMenuPath: string | null; - setRightClickMenuPath: (path: string | null) => void; + 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; @@ -404,10 +404,10 @@ const FileRow: React.FC = ({ badge, permissions, downloadFile, - contextMenuPath, - setContextMenuPath, - rightClickMenuPath, - setRightClickMenuPath, + contextMenuPath, + setContextMenuPath, + rightClickMenuPath, + setRightClickMenuPath, onSelect, onToggle, onRevealPath, @@ -420,10 +420,10 @@ const FileRow: React.FC = ({ 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]); + } + event?.preventDefault(); + setRightClickMenuPath(node.path); + }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]); const handleInteraction = React.useCallback(() => { if (isDir) { @@ -434,97 +434,97 @@ const FileRow: React.FC = ({ }, [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')} - - - )} - - ); + 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)}> - }> + setRightClickMenuPath(open ? node.path : null)}> + }> - setContextMenuPath(null)}> - {renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })} - + setContextMenuPath(null)}> + {renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })} + )} - - - {renderMenuItems({ Item: ContextMenuItem, Separator: ContextMenuSeparator })} - - - ); -}; + + + {renderMenuItems({ Item: ContextMenuItem, Separator: ContextMenuSeparator })} + + + ); +}; interface DialogsProps { activeDialog: 'createFile' | 'createFolder' | 'rename' | 'delete' | null; @@ -670,6 +670,62 @@ 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(); @@ -727,14 +783,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { 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 [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), @@ -774,38 +830,38 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, []); - 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], - ); + 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); @@ -841,31 +897,28 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const activeDirectoryLoadIdsRef = React.useRef>(new Map()); const nextDirectoryLoadIdRef = React.useRef(0); - const [searchResults, setSearchResults] = React.useState([]); - const [searching, setSearching] = React.useState(false); - - const [fileContent, setFileContent] = React.useState(''); - const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); - const [fileLoading, setFileLoading] = React.useState(false); - const [fileError, setFileError] = React.useState(null); - const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); - const desktopImageBlobUrlRef = React.useRef(''); - const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); - const [htmlAssetAuthReadyKey, setHtmlAssetAuthReadyKey] = React.useState(''); - const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState(''); + const [searchResults, setSearchResults] = React.useState([]); + const [searching, setSearching] = React.useState(false); + + const [fileContent, setFileContent] = React.useState(''); + const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); + const [fileLoading, setFileLoading] = React.useState(false); + const [fileError, setFileError] = React.useState(null); + const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); + const desktopImageBlobUrlRef = React.useRef(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState('\n'); - const dialogInputRef = React.useRef(null); - const autoSaveTimerRef = React.useRef | null>(null); - const diagramAutoSaveTimerRef = React.useRef | null>(null); - const diagramXmlRef = React.useRef(''); - const diagramSavedXmlRef = React.useRef(''); - const pendingDrawioPreviewFrameRef = React.useRef(null); - const diagramEditorRef = React.useRef>(null); + const dialogInputRef = React.useRef(null); + const autoSaveTimerRef = React.useRef | null>(null); + const diagramAutoSaveTimerRef = React.useRef | null>(null); + const diagramXmlRef = React.useRef(''); + const diagramSavedXmlRef = React.useRef(''); + const pendingDrawioPreviewFrameRef = React.useRef(null); + const diagramEditorRef = React.useRef>(null); const lastLoadedFileStatRef = React.useRef(null); const activeFileLoadIdRef = React.useRef(0); const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); @@ -898,8 +951,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); const [dialogInputValue, setDialogInputValue] = React.useState(''); const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); - const [contextMenuPath, setContextMenuPath] = React.useState(null); - const [rightClickMenuPath, setRightClickMenuPath] = React.useState(null); + const [contextMenuPath, setContextMenuPath] = React.useState(null); + const [rightClickMenuPath, setRightClickMenuPath] = React.useState(null); const [copiedContent, setCopiedContent] = React.useState(false); const [copiedPath, setCopiedPath] = React.useState(false); const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false); @@ -967,13 +1020,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); - const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); - const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); + const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); + const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); - const settingsExpandedEditorToolbar = useUIStore((state) => state.expandedEditorToolbar); + const settingsExpandedEditorToolbar = useUIStore((state) => state.expandedEditorToolbar); // Global mouseup to end drag selection React.useEffect(() => { @@ -1016,10 +1069,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }); const { - drafts: filesFileDrafts, - commentText, - setCommentText, - editingDraftId, + drafts: filesFileDrafts, + commentText, + setCommentText, + editingDraftId, setSelection: setCommentSelection, saveComment, cancel, @@ -1050,10 +1103,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (target.closest('.cm-gutterElement')) return; if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; - if (!commentText.trim()) { - setLineSelection(null); - cancel(); - } + if (!commentText.trim()) { + setLineSelection(null); + cancel(); + } }; const timeoutId = setTimeout(() => { @@ -1064,7 +1117,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; - }, [cancel, commentText, editingDraftId, lineSelection]); + }, [cancel, commentText, editingDraftId, lineSelection]); const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { const finalRange = range ?? lineSelection ?? undefined; @@ -1480,25 +1533,25 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); - const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise => { - if (files.readFile) { - const result = await files.readFile(path, { ...(options ?? {}), directory: root || undefined }); - return result.content ?? ''; - } + const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise => { + if (files.readFile) { + const result = await files.readFile(path, { ...(options ?? {}), directory: root || undefined }); + return result.content ?? ''; + } const params = new URLSearchParams({ path }); - if (options?.allowOutsideWorkspace) { - params.set('allowOutsideWorkspace', 'true'); - } - if (options?.outsideFileGrant) { - params.set('outsideFileGrant', options.outsideFileGrant); - } - if (options?.optional) { - params.set('optional', 'true'); - } - if (root) { - params.set('directory', root); - } + if (options?.allowOutsideWorkspace) { + params.set('allowOutsideWorkspace', 'true'); + } + if (options?.outsideFileGrant) { + params.set('outsideFileGrant', options.outsideFileGrant); + } + if (options?.optional) { + params.set('optional', 'true'); + } + if (root) { + params.set('directory', root); + } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: options?.optional ? 'no-store' : 'default', }); @@ -1507,19 +1560,19 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed')); } return response.text(); - }, [files, root, t]); + }, [files, root, t]); - const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise => { - if (files.statFile) { - const result = await files.statFile(path, { ...(options ?? {}), directory: root || undefined }); - return { + const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise => { + if (files.statFile) { + const result = await files.statFile(path, { ...(options ?? {}), directory: root || undefined }); + return { path: result.path, size: result.size, mtimeMs: result.mtimeMs, }; } return null; - }, [files, root]); + }, [files, root]); React.useEffect(() => { if (!root || !files.statFile || openPaths.length === 0) { @@ -1531,7 +1584,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { void Promise.all(paths.map(async (path) => { try { - const stat = await files.statFile?.(path, { directory: root || undefined }); + const stat = await files.statFile?.(path, { directory: root || undefined }); if (!cancelled && stat && !stat.isFile) { removeOpenPathsByPrefix(root, path); } @@ -1566,14 +1619,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return true; } - if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) { - console.warn( - `[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` + - 'The file may have been read during a concurrent write (O_TRUNC race). ' + - 'Try again after content finishes loading if the save was intentional.', - ); - return false; - } + if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) { + console.warn( + `[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` + + 'The file may have been read during a concurrent write (O_TRUNC race). ' + + 'Try again after content finishes loading if the save was intentional.', + ); + return false; + } setIsSaving(true); @@ -1583,13 +1636,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (!result?.success) { toast.error(t('filesView.toast.writeFileFailed')); return false; - } - setFileContent(draftContent); - if (selectedFile?.path && isDrawioFile(selectedFile.path)) { - diagramXmlRef.current = draftContent; - diagramSavedXmlRef.current = draftContent; - } - // Refresh stat after write so polling doesn't see a stale metadata change. + } + setFileContent(draftContent); + if (selectedFile?.path && isDrawioFile(selectedFile.path)) { + diagramXmlRef.current = draftContent; + diagramSavedXmlRef.current = draftContent; + } + // Refresh stat after write so polling doesn't see a stale metadata change. void readFileStat(selectedFile.path) .then((stat) => { if (stat) { @@ -1604,7 +1657,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } finally { setIsSaving(false); } - }, [draftContent, fileContent, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]); + }, [draftContent, fileContent, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]); React.useEffect(() => { if (!isDirty) { @@ -1726,9 +1779,9 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setDesktopImageSrc(''); setLoadedFilePath(null); - const selectedIsImage = isImageFile(node.path); - const isSvg = node.path.toLowerCase().endsWith('.svg'); - const selectedIsPdf = isPdfFile(node.path); + const selectedIsImage = isImageFile(node.path); + const isSvg = node.path.toLowerCase().endsWith('.svg'); + const selectedIsPdf = isPdfFile(node.path); if (isMobile) { setShowMobilePageContent(true); @@ -1743,41 +1796,41 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } // Web: binary images should not be read as utf8. - if (!runtime.isDesktop && selectedIsImage && !isSvg) { - setFileContent(''); - setDraftContent(''); - setLoadedFilePath(node.path); - setFileLoading(false); - return; - } - - if (selectedIsPdf) { - setFileContent(''); - setDraftContent(''); - setLoadedFilePath(node.path); - setFileLoading(false); - return; - } + if (!runtime.isDesktop && selectedIsImage && !isSvg) { + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(node.path); + setFileLoading(false); + return; + } + + if (selectedIsPdf) { + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(node.path); + setFileLoading(false); + return; + } setFileLoading(true); - const outsideFileGrant = getOutsideFileGrant(node.path); - const readOptions = { - allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root), - outsideFileGrant, - }; + const outsideFileGrant = getOutsideFileGrant(node.path); + const readOptions = { + allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root), + outsideFileGrant, + }; await readFile(node.path, readOptions) .then((content) => { if (!isCurrentLoad()) { return; } - const editorContent = normalizeEditorLineEndings(content); - setLoadedFileLineEnding(detectFileLineEnding(content)); - setFileContent(editorContent); - diagramXmlRef.current = editorContent; - diagramSavedXmlRef.current = editorContent; - setDraftContent(editorContent.length > MAX_VIEW_CHARS + const editorContent = normalizeEditorLineEndings(content); + setLoadedFileLineEnding(detectFileLineEnding(content)); + setFileContent(editorContent); + diagramXmlRef.current = editorContent; + diagramSavedXmlRef.current = editorContent; + setDraftContent(editorContent.length > MAX_VIEW_CHARS ? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` : editorContent); setLoadedFilePath(node.path); @@ -1892,12 +1945,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { void ensurePathVisible(node.path, false); } - setFileError(null); - setDesktopImageSrc(''); - setFileContent(''); - diagramXmlRef.current = ''; - diagramSavedXmlRef.current = ''; - setDraftContent(''); + setFileError(null); + setDesktopImageSrc(''); + setFileContent(''); + diagramXmlRef.current = ''; + diagramSavedXmlRef.current = ''; + setDraftContent(''); setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(true); @@ -2203,10 +2256,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { badge={isDir ? getFolderBadge(node.path) : undefined} permissions={fileRowPermissions} downloadFile={files.downloadFile} - contextMenuPath={contextMenuPath} - setContextMenuPath={setContextMenuPath} - rightClickMenuPath={rightClickMenuPath} - setRightClickMenuPath={setRightClickMenuPath} + contextMenuPath={contextMenuPath} + setContextMenuPath={setContextMenuPath} + rightClickMenuPath={rightClickMenuPath} + setRightClickMenuPath={setRightClickMenuPath} onSelect={handleSelectFile} onToggle={toggleDirectory} onRevealPath={handleRevealPath} @@ -2231,9 +2284,9 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }); } - const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); - const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); - const isSelectedPdf = Boolean(selectedFile?.path && isPdfFile(selectedFile.path)); + const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); + const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); + const isSelectedPdf = Boolean(selectedFile?.path && isPdfFile(selectedFile.path)); const pendingNavigationTargetPath = React.useMemo( () => normalizePath(pendingFileNavigation?.path ?? ''), [pendingFileNavigation?.path], @@ -2243,24 +2296,24 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { && pendingNavigationTargetPath && selectedFilePath && selectedFilePath === pendingNavigationTargetPath - && !fileLoading - && !fileError - && !isSelectedImage - && !isSelectedPdf, - ); + && !fileLoading + && !fileError + && !isSelectedImage + && !isSelectedPdf, + ); const displaySelectedPath = React.useMemo(() => { return getDisplayPath(root, selectedFilePath); }, [selectedFilePath, root]); - const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && fileContent.length > 0); - const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); - const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && !isSelectedPdf && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); + const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && fileContent.length > 0); + const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); + const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && !isSelectedPdf && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path)); - const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf); + const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf); const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview'); const staticLanguageExtension = React.useMemo( () => (selectedFilePath ? languageByExtension(selectedFilePath) : null), @@ -2330,10 +2383,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } catch { // Ignore localStorage errors } - setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault); - setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? 'preview'); - - let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text'; + setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault); + setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? 'preview'); + + let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text'; try { const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY); if (stored === 'tree' || stored === 'text') { @@ -2379,7 +2432,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } }, []); - const saveHtmlViewMode = React.useCallback((mode: PreviewViewMode) => { + const saveHtmlViewMode = React.useCallback((mode: PreviewViewMode) => { const selectedPath = selectedFile?.path; if (selectedPath) { htmlViewModeByPathRef.current[selectedPath] = mode; @@ -2390,105 +2443,105 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } 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]); + }, [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; @@ -2598,7 +2651,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return; } - if (fileError || isSelectedImage || isSelectedPdf) { + if (fileError || isSelectedImage || isSelectedPdf) { setPendingFileNavigation(null); pendingNavigationCycleRef.current = { key: '', attempts: 0 }; return; @@ -2667,10 +2720,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { draftContent, editorViewReadyNonce, fileError, - fileLoading, - isSelectedImage, - isSelectedPdf, - loadedFilePath, + fileLoading, + isSelectedImage, + isSelectedPdf, + loadedFilePath, handleSelectFile, pendingFileNavigation, root, @@ -2699,7 +2752,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return; } - if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage || isSelectedPdf) { + if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage || isSelectedPdf) { return; } @@ -2717,10 +2770,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { confirmDiscardOpen, fileError, fileLoading, - handleSelectFile, - isSelectedImage, - isSelectedPdf, - loadedFilePath, + handleSelectFile, + isSelectedImage, + isSelectedPdf, + loadedFilePath, pendingFileFocusPath, root, selectedFile?.path, @@ -2826,23 +2879,23 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return [createFlexokiCodeMirrorTheme(currentTheme)]; } - // 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 } : undefined)]; + // 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 } : undefined)]; const language = staticLanguageExtension ?? dynamicLanguageExtension; if (language) { extensions.push(language); } - if (shikiLanguage) { - extensions.push(shikiHighlightExtension({ - language: shikiLanguage, - themeName: currentTheme.metadata.id, - theme: getResolvedShikiTheme(currentTheme), - })); - } + if (shikiLanguage) { + extensions.push(shikiHighlightExtension({ + language: shikiLanguage, + themeName: currentTheme.metadata.id, + theme: getResolvedShikiTheme(currentTheme), + })); + } if (wrapLines) { extensions.push(EditorView.lineWrapping); } @@ -2868,169 +2921,108 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { [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 imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg + ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}` + : ''; - React.useEffect(() => { - if (!imageAssetAuthKey) { - setImageAssetAuthReadyKey(''); - return; - } + const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf + ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}` + : ''; - let cancelled = false; - setImageAssetAuthReadyKey(''); - void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) - .then((token) => { - if (!cancelled && token) setImageAssetAuthReadyKey(imageAssetAuthKey); - }) - .catch(() => {}); + const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode + ? selectedFile.path + : ''; - return () => { - cancelled = true; - }; - }, [imageAssetAuthKey]); + 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); - - React.useEffect(() => { - if (!htmlAssetAuthKey) { - setHtmlAssetAuthReadyKey(''); - return; - } - - let cancelled = false; - setHtmlAssetAuthReadyKey(''); - void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) - .then((token) => { - if (!cancelled && token) { - setHtmlAssetAuthReadyKey(htmlAssetAuthKey); - } - }) - .catch((error) => { - if (!cancelled) { - setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); - } - }); - - return () => { - cancelled = true; - }; - }, [htmlAssetAuthKey, t]); - - const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey); - - React.useEffect(() => { - if (!pdfAssetAuthKey) { - setPdfAssetAuthReadyKey(''); - return; - } - - let cancelled = false; - setPdfAssetAuthReadyKey(''); - void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) - .then((token) => { - if (!cancelled && token) setPdfAssetAuthReadyKey(pdfAssetAuthKey); - }) - .catch((error) => { - if (!cancelled) { - setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); - setPdfAssetAuthReadyKey(pdfAssetAuthKey); - } - }); - - return () => { - cancelled = true; - }; - }, [pdfAssetAuthKey, t]); - - const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey); - - const imageSrc = selectedFile?.path && isSelectedImage + 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) => ( -
-