mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
feat: add error, empty, and oversize states to html viewer
This commit is contained in:
parent
9014c79f2c
commit
754561d893
2 changed files with 93 additions and 11 deletions
|
|
@ -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() {
|
|||
</div>
|
||||
) : selectedPath?.toLowerCase().endsWith('.html') || selectedPath?.toLowerCase().endsWith('.htm') ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<HtmlFileViewer html={fileContent} path={selectedPath} />
|
||||
<HtmlFileViewer path={selectedPath} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
|
|
|
|||
|
|
@ -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<ViewerState>({ 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 (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground">
|
||||
<AlertCircleIcon className="size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-foreground">Could not load preview</p>
|
||||
<p className="max-w-md text-xs">{state.message}</p>
|
||||
<p className="text-xs opacity-60">{path}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'empty') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<FileTextIcon className="size-6" />
|
||||
<p className="text-sm">This file is empty</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'tooLarge') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileTextIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">File too large to preview</p>
|
||||
<p className="text-xs">
|
||||
{state.sizeMB.toFixed(1)} MB — preview limit is {(MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.ipc.invoke('shell:openPath', { path })
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Open in system
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{html && (
|
||||
{state.kind === 'loaded' && (
|
||||
<iframe
|
||||
key={path}
|
||||
srcDoc={html}
|
||||
srcDoc={state.html}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{showSpinner && (
|
||||
{(state.kind === 'loading' || !iframeLoaded) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Rendering preview…</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue