diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 96ae3742..057aacb8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1425,10 +1425,11 @@ function App() { } const requestId = (fileLoadRequestIdRef.current += 1) const pathToLoad = selectedPath - // For HTML files, clear stale content immediately so the viewer shows - // its loading state instead of rendering the previous file's bytes. + // HtmlFileViewer self-loads (with size check, error states, etc.) + // Skip the generic loader so we don't double-fetch large files. if (pathToLoad.toLowerCase().endsWith('.html') || pathToLoad.toLowerCase().endsWith('.htm')) { setFileContent('') + return } let cancelled = false ;(async () => { @@ -4827,7 +4828,7 @@ function App() { ) : selectedPath?.toLowerCase().endsWith('.html') || selectedPath?.toLowerCase().endsWith('.htm') ? (
- +
) : (
diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 3aceb117..41e7326c 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -1,33 +1,114 @@ import { useEffect, useState } from 'react' -import { Loader2Icon } from 'lucide-react' +import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' + +const MAX_SIZE_BYTES = 5 * 1024 * 1024 + +type ViewerState = + | { kind: 'loading' } + | { kind: 'loaded'; html: string } + | { kind: 'empty' } + | { kind: 'tooLarge'; sizeMB: number } + | { kind: 'error'; message: string } interface HtmlFileViewerProps { - html: string path: string } -export function HtmlFileViewer({ html, path }: HtmlFileViewerProps) { +export function HtmlFileViewer({ path }: HtmlFileViewerProps) { + const [state, setState] = useState({ kind: 'loading' }) const [iframeLoaded, setIframeLoaded] = useState(false) useEffect(() => { + let cancelled = false + setState({ kind: 'loading' }) setIframeLoaded(false) - }, [path, html]) - const showSpinner = !html || !iframeLoaded + ;(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 + } + if (stat.size > MAX_SIZE_BYTES) { + setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) }) + return + } + const result = await window.ipc.invoke('workspace:readFile', { path }) + if (cancelled) return + if (!result.data || result.data.trim() === '') { + setState({ kind: 'empty' }) + return + } + setState({ kind: 'loaded', html: result.data }) + } catch (err) { + if (cancelled) return + const message = err instanceof Error ? err.message : String(err) + setState({ kind: 'error', message }) + } + })() + + return () => { + cancelled = true + } + }, [path]) + + if (state.kind === 'error') { + return ( +
+ +

Could not load preview

+

{state.message}

+

{path}

+
+ ) + } + + if (state.kind === 'empty') { + return ( +
+ +

This file is empty

+
+ ) + } + + if (state.kind === 'tooLarge') { + return ( +
+ +

File too large to preview

+

+ {state.sizeMB.toFixed(1)} MB — preview limit is {(MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB. +

+ +
+ ) + } return (
- {html && ( + {state.kind === 'loaded' && (