diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4fd75591..a6364308 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -17,7 +17,8 @@ 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 { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; +import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; @@ -1429,10 +1430,10 @@ function App() { } const requestId = (fileLoadRequestIdRef.current += 1) const pathToLoad = selectedPath - // 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 (isMediaPath(pathToLoad)) { + // Only the markdown editor still consumes fileContent. Every other viewer + // (media + UnsupportedFileViewer) self-loads, so skip the generic UTF-8 + // loader to avoid double-fetching and to avoid slurping binary bytes. + if (!pathToLoad.endsWith('.md')) { setFileContent('') return } @@ -4853,10 +4854,8 @@ function App() { ) : ( -
-
-                      {fileContent || 'Loading...'}
-                    
+
+
) )} diff --git a/apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx b/apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx new file mode 100644 index 00000000..474d7dba --- /dev/null +++ b/apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react' +import { ExternalLinkIcon, FileIcon, FileTextIcon, Loader2Icon } from 'lucide-react' + +const TEXT_FALLBACK_MAX_BYTES = 1 * 1024 * 1024 // 1 MB + +interface UnsupportedFileViewerProps { + path: string +} + +type State = + | { kind: 'loading' } + | { kind: 'ready'; sizeBytes: number; canShowAsText: boolean } + | { kind: 'error'; message: string } + +function basename(path: string): string { + const idx = path.lastIndexOf('/') + return idx >= 0 ? path.slice(idx + 1) : path +} + +function extensionLabel(path: string): string { + const name = basename(path) + const dot = name.lastIndexOf('.') + if (dot < 0) return 'No extension' + return name.slice(dot + 1).toUpperCase() +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export function UnsupportedFileViewer({ path }: UnsupportedFileViewerProps) { + const [state, setState] = useState({ kind: 'loading' }) + const [textContent, setTextContent] = useState(null) + + useEffect(() => { + let cancelled = false + setState({ kind: 'loading' }) + setTextContent(null) + + ;(async () => { + try { + const stat = await window.ipc.invoke('workspace:stat', { path }) + if (cancelled) return + if (stat.kind !== 'file') { + setState({ kind: 'error', message: 'Selected path is not a file.' }) + return + } + setState({ + kind: 'ready', + sizeBytes: stat.size, + canShowAsText: stat.size <= TEXT_FALLBACK_MAX_BYTES, + }) + } catch (err) { + if (cancelled) return + const message = err instanceof Error ? err.message : String(err) + setState({ kind: 'error', message }) + } + })() + + return () => { + cancelled = true + } + }, [path]) + + async function loadAsText() { + try { + const result = await window.ipc.invoke('workspace:readFile', { path }) + setTextContent(result.data) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setTextContent(`Failed to read as text: ${message}`) + } + } + + if (state.kind === 'loading') { + return ( +
+ +
+ ) + } + + if (state.kind === 'error') { + return ( +
+ +

Could not open

+

{state.message}

+
+ ) + } + + if (textContent !== null) { + return ( +
+
+ {basename(path)} · plain text view + +
+
+
{textContent}
+
+
+ ) + } + + return ( +
+ +

+ {basename(path)} +

+

+ {extensionLabel(path)} · {formatSize(state.sizeBytes)} +

+

No in-app preview for this file type.

+
+ + {state.canShowAsText && ( + + )} +
+
+ ) +}