diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 39cf44e2..4fd75591 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -17,6 +17,7 @@ import { ImageFileViewer } from '@/components/image-file-viewer';
import { VideoFileViewer } from '@/components/video-file-viewer';
import { AudioFileViewer } from '@/components/audio-file-viewer';
import { PersistentViewerCache } from '@/components/persistent-viewer-cache';
+import { getViewerType, isMediaPath, isCacheableViewerPath } from '@/lib/file-types';
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
@@ -1428,10 +1429,10 @@ function App() {
}
const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath
- // Media viewers (HTML, image, video, PDF) self-load via app:// protocol.
+ // Media viewers (HTML, image, video, audio, PDF) self-load via app:// protocol.
// Skip the generic UTF-8 loader so we don't trash fileContent with binary
// bytes or double-fetch large files.
- if (/\.(html?|png|jpe?g|webp|gif|svg|avif|bmp|ico|mp4|mov|webm|m4v|pdf|mp3|wav|m4a|ogg|flac|aac)$/i.test(pathToLoad)) {
+ if (isMediaPath(pathToLoad)) {
setFileContent('')
return
}
@@ -4725,11 +4726,11 @@ function App() {
{/* Always-mounted persistent cache for HTML/PDF — hidden when active file is something else, so iframes preserve scroll/page/zoom across switches. */}
- {!/\.(html?|pdf)$/i.test(selectedPath) && (
+ {!isCacheableViewerPath(selectedPath) && (
selectedPath.endsWith('.md') ? (
@@ -4839,15 +4840,15 @@ function App() {
/>
)}
- ) : selectedPath && /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(selectedPath) ? (
+ ) : selectedPath && getViewerType(selectedPath) === 'image' ? (
- ) : selectedPath && /\.(mp4|mov|webm|m4v)$/i.test(selectedPath) ? (
+ ) : selectedPath && getViewerType(selectedPath) === 'video' ? (
- ) : selectedPath && /\.(mp3|wav|m4a|ogg|flac|aac)$/i.test(selectedPath) ? (
+ ) : selectedPath && getViewerType(selectedPath) === 'audio' ? (
diff --git a/apps/x/apps/renderer/src/components/persistent-viewer-cache.tsx b/apps/x/apps/renderer/src/components/persistent-viewer-cache.tsx
index d655b929..ca71eca2 100644
--- a/apps/x/apps/renderer/src/components/persistent-viewer-cache.tsx
+++ b/apps/x/apps/renderer/src/components/persistent-viewer-cache.tsx
@@ -1,22 +1,14 @@
import { useEffect, useState } from 'react'
import { HtmlFileViewer } from './html-file-viewer'
import { PdfFileViewer } from './pdf-file-viewer'
+import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'
const CACHE_LIMIT = 3
-function isCacheable(path: string): boolean {
- const lower = path.toLowerCase()
- return lower.endsWith('.html') || lower.endsWith('.htm') || lower.endsWith('.pdf')
-}
-
function renderViewer(path: string): JSX.Element | null {
- const lower = path.toLowerCase()
- if (lower.endsWith('.html') || lower.endsWith('.htm')) {
- return
- }
- if (lower.endsWith('.pdf')) {
- return
- }
+ const type = getViewerType(path)
+ if (type === 'html') return
+ if (type === 'pdf') return
return null
}
@@ -31,11 +23,11 @@ interface PersistentViewerCacheProps {
*/
export function PersistentViewerCache({ activePath }: PersistentViewerCacheProps) {
const [mountedPaths, setMountedPaths] = useState
(() =>
- isCacheable(activePath) ? [activePath] : []
+ isCacheableViewerPath(activePath) ? [activePath] : []
)
useEffect(() => {
- if (!isCacheable(activePath)) return
+ if (!isCacheableViewerPath(activePath)) return
setMountedPaths((prev) => {
// Never reorder existing entries — moving a keyed iframe in the DOM
// detaches it, which causes the browser to re-navigate (state lost).
diff --git a/apps/x/apps/renderer/src/lib/file-types.ts b/apps/x/apps/renderer/src/lib/file-types.ts
new file mode 100644
index 00000000..d4477f7a
--- /dev/null
+++ b/apps/x/apps/renderer/src/lib/file-types.ts
@@ -0,0 +1,56 @@
+/**
+ * Single source of truth for which file types the knowledge viewer renders.
+ *
+ * Both the App.tsx loader-skip check and the render-switch consume this so
+ * adding a new extension is a one-place edit. The persistent-viewer-cache
+ * also uses it to decide what to keep mounted.
+ */
+
+export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf'
+
+const VIEWER_BY_EXT: Record = {
+ html: 'html',
+ htm: 'html',
+ png: 'image',
+ jpg: 'image',
+ jpeg: 'image',
+ webp: 'image',
+ gif: 'image',
+ svg: 'image',
+ avif: 'image',
+ bmp: 'image',
+ ico: 'image',
+ mp4: 'video',
+ mov: 'video',
+ webm: 'video',
+ m4v: 'video',
+ mp3: 'audio',
+ wav: 'audio',
+ m4a: 'audio',
+ ogg: 'audio',
+ flac: 'audio',
+ aac: 'audio',
+ pdf: 'pdf',
+}
+
+function extensionOf(path: string): string {
+ const lower = path.toLowerCase()
+ const dot = lower.lastIndexOf('.')
+ return dot >= 0 ? lower.slice(dot + 1) : ''
+}
+
+/** Returns the viewer type for a path, or null if no media viewer handles it. */
+export function getViewerType(path: string): ViewerType | null {
+ return VIEWER_BY_EXT[extensionOf(path)] ?? null
+}
+
+/** True if the path is rendered by one of the dedicated media viewers. */
+export function isMediaPath(path: string): boolean {
+ return getViewerType(path) !== null
+}
+
+/** True if the viewer for this path participates in the persistent mount cache. */
+export function isCacheableViewerPath(path: string): boolean {
+ const t = getViewerType(path)
+ return t === 'html' || t === 'pdf'
+}