mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-10 15:52:38 +02:00
Merge pull request #539 from rowboatlabs/feat/knowledge-file-viewer
feat: render html, image, video, audio, and pdf in knowledge view
This commit is contained in:
commit
0bf7a55611
11 changed files with 707 additions and 15 deletions
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
|
|||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -133,16 +134,29 @@ const rendererPath = app.isPackaged
|
|||
: path.join(__dirname, "../../../renderer/dist"); // Development
|
||||
console.log("rendererPath", rendererPath);
|
||||
|
||||
// Register custom protocol for serving built renderer files in production.
|
||||
// This keeps SPA routes working when users deep link into the packaged app.
|
||||
// Register custom protocol for serving built renderer files in production
|
||||
// AND for serving local workspace files to the renderer (images, PDFs, video).
|
||||
//
|
||||
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
|
||||
// app://<anything-else>/... → renderer SPA (existing behavior)
|
||||
function registerAppProtocol() {
|
||||
protocol.handle("app", (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// url.pathname starts with "/"
|
||||
let urlPath = url.pathname;
|
||||
// Workspace files: app://workspace/<rel-path>
|
||||
if (url.host === "workspace") {
|
||||
try {
|
||||
const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||
if (!relPath) return new Response("Not Found", { status: 404 });
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
return net.fetch(pathToFileURL(absPath).toString());
|
||||
} catch {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// If it's "/" or a SPA route (no extension), serve index.html
|
||||
// Renderer SPA — existing logic
|
||||
let urlPath = url.pathname;
|
||||
if (urlPath === "/" || !path.extname(urlPath)) {
|
||||
urlPath = "/index.html";
|
||||
}
|
||||
|
|
@ -161,8 +175,8 @@ protocol.registerSchemesAsPrivileged([
|
|||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
allowServiceWorkers: true,
|
||||
// optional but often helpful:
|
||||
// stream: true,
|
||||
// Required for byte-range requests so <video> seeking works.
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -207,6 +221,9 @@ function createWindow() {
|
|||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: preloadPath,
|
||||
// Enable Chromium's built-in PDFium plugin so <iframe src="*.pdf">
|
||||
// renders PDFs natively (zoom/scroll/print toolbar included).
|
||||
plugins: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -251,10 +268,10 @@ function createWindow() {
|
|||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register custom protocol before creating window (for production builds)
|
||||
if (app.isPackaged) {
|
||||
registerAppProtocol();
|
||||
}
|
||||
// Register custom protocol before creating window.
|
||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||
// serves workspace files via app://workspace/<rel-path> for media previews.
|
||||
registerAppProtocol();
|
||||
|
||||
// Initialize auto-updater (only in production)
|
||||
if (app.isPackaged) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import { ChatInputWithMentions, type StagedAttachment } from './components/chat-
|
|||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||
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 { 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';
|
||||
|
|
@ -1425,6 +1431,13 @@ function App() {
|
|||
}
|
||||
const requestId = (fileLoadRequestIdRef.current += 1)
|
||||
const pathToLoad = selectedPath
|
||||
// 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
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
|
|
@ -4715,6 +4728,15 @@ function App() {
|
|||
/>
|
||||
</div>
|
||||
) : selectedPath ? (
|
||||
<>
|
||||
{/* Always-mounted persistent cache for HTML/PDF — hidden when active file is something else, so iframes preserve scroll/page/zoom across switches. */}
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isCacheableViewerPath(selectedPath) ? 'block' : 'none' }}
|
||||
>
|
||||
<PersistentViewerCache activePath={selectedPath} />
|
||||
</div>
|
||||
{!isCacheableViewerPath(selectedPath) && (
|
||||
selectedPath.endsWith('.md') ? (
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
|
|
@ -4824,13 +4846,25 @@ function App() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
) : selectedPath && getViewerType(selectedPath) === 'image' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ImageFileViewer path={selectedPath} />
|
||||
</div>
|
||||
) : selectedPath && getViewerType(selectedPath) === 'video' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<VideoFileViewer path={selectedPath} />
|
||||
</div>
|
||||
) : selectedPath && getViewerType(selectedPath) === 'audio' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AudioFileViewer path={selectedPath} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
|
||||
{fileContent || 'Loading...'}
|
||||
</pre>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<UnsupportedFileViewer path={selectedPath} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : selectedTask ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<BackgroundTaskDetail
|
||||
|
|
|
|||
60
apps/x/apps/renderer/src/components/audio-file-viewer.tsx
Normal file
60
apps/x/apps/renderer/src/components/audio-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileAudioIcon } from 'lucide-react'
|
||||
|
||||
interface AudioFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'ready' | 'error'
|
||||
|
||||
function basename(path: string): string {
|
||||
const idx = path.lastIndexOf('/')
|
||||
return idx >= 0 ? path.slice(idx + 1) : path
|
||||
}
|
||||
|
||||
export function AudioFileViewer({ path }: AudioFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileAudioIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot play this audio file</p>
|
||||
<p className="max-w-md text-xs">The codec or container format isn't supported.</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="flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/30 px-6">
|
||||
<FileAudioIcon className="size-10 text-muted-foreground" />
|
||||
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
|
||||
{basename(path)}
|
||||
</p>
|
||||
<audio
|
||||
key={path}
|
||||
src={src}
|
||||
controls
|
||||
className="w-full max-w-lg"
|
||||
onLoadedMetadata={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
apps/x/apps/renderer/src/components/html-file-viewer.tsx
Normal file
155
apps/x/apps/renderer/src/components/html-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
const MAX_SIZE_BYTES = 5 * 1024 * 1024
|
||||
const CACHE_MAX_ENTRIES = 20
|
||||
|
||||
type CacheEntry = { html: string; mtimeMs: number; size: number }
|
||||
const htmlCache = new Map<string, CacheEntry>()
|
||||
|
||||
function getCached(path: string, mtimeMs: number, size: number): string | null {
|
||||
const entry = htmlCache.get(path)
|
||||
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
|
||||
// Refresh LRU position
|
||||
htmlCache.delete(path)
|
||||
htmlCache.set(path, entry)
|
||||
return entry.html
|
||||
}
|
||||
|
||||
function setCached(path: string, html: string, mtimeMs: number, size: number) {
|
||||
htmlCache.set(path, { html, mtimeMs, size })
|
||||
while (htmlCache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldest = htmlCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
htmlCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
type ViewerState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded'; html: string }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'tooLarge'; sizeMB: number }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
interface HtmlFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
;(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 cachedHtml = getCached(path, stat.mtimeMs, stat.size)
|
||||
if (cachedHtml !== null) {
|
||||
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
|
||||
return
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
if (cancelled) return
|
||||
setCached(path, result.data, stat.mtimeMs, stat.size)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
|
||||
// gets a null origin with no base URL. Trade-off: relative assets inside
|
||||
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
|
||||
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
|
||||
// works fine; HTML that ships next to sibling assets will look broken.
|
||||
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
|
||||
// support; that path also resolves through the existing path-traversal
|
||||
// guard in resolveWorkspacePath.
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{state.kind === 'loaded' && (
|
||||
<iframe
|
||||
key={path}
|
||||
srcDoc={state.html}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{(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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
apps/x/apps/renderer/src/components/image-file-viewer.tsx
Normal file
58
apps/x/apps/renderer/src/components/image-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileImageIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
interface ImageFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'loaded' | 'error'
|
||||
|
||||
export function ImageFileViewer({ path }: ImageFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileImageIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot preview this image</p>
|
||||
<p className="max-w-md text-xs">The format may be unsupported (e.g. HEIC on Windows).</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 flex h-full w-full items-center justify-center bg-muted/30">
|
||||
<img
|
||||
key={path}
|
||||
src={src}
|
||||
alt={path}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
onLoad={() => setState('loaded')}
|
||||
onError={() => setState('error')}
|
||||
style={state === 'loading' ? { opacity: 0 } : undefined}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading image…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/x/apps/renderer/src/components/pdf-file-viewer.tsx
Normal file
56
apps/x/apps/renderer/src/components/pdf-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
interface PdfFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'ready' | 'error'
|
||||
|
||||
export function PdfFileViewer({ path }: PdfFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
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">Cannot preview this PDF</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">
|
||||
<iframe
|
||||
key={path}
|
||||
src={src}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="PDF preview"
|
||||
onLoad={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<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">Loading PDF…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
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 renderViewer(path: string): JSX.Element | null {
|
||||
const type = getViewerType(path)
|
||||
if (type === 'html') return <HtmlFileViewer path={path} />
|
||||
if (type === 'pdf') return <PdfFileViewer path={path} />
|
||||
return null
|
||||
}
|
||||
|
||||
interface PersistentViewerCacheProps {
|
||||
activePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps recently-opened HTML and PDF viewers mounted in the DOM,
|
||||
* toggling visibility instead of unmounting. This preserves iframe
|
||||
* state (PDF page/zoom, HTML scroll/JS state) across file switches.
|
||||
*/
|
||||
export function PersistentViewerCache({ activePath }: PersistentViewerCacheProps) {
|
||||
const [mountedPaths, setMountedPaths] = useState<string[]>(() =>
|
||||
isCacheableViewerPath(activePath) ? [activePath] : []
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
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).
|
||||
if (prev.includes(activePath)) return prev
|
||||
const next = [...prev, activePath]
|
||||
return next.length > CACHE_LIMIT ? next.slice(-CACHE_LIMIT) : next
|
||||
})
|
||||
}, [activePath])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{mountedPaths.map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="absolute inset-0"
|
||||
style={{ display: p === activePath ? 'block' : 'none' }}
|
||||
>
|
||||
{renderViewer(p)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
149
apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx
Normal file
149
apps/x/apps/renderer/src/components/unsupported-file-viewer.tsx
Normal file
|
|
@ -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<State>({ kind: 'loading' })
|
||||
const [textContent, setTextContent] = useState<string | null>(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 (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2Icon className="size-6 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<FileIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Could not open</p>
|
||||
<p className="max-w-md text-xs">{state.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (textContent !== null) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{basename(path)} · plain text view</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTextContent(null)}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">{textContent}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileIcon className="size-10 text-muted-foreground" />
|
||||
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
|
||||
{basename(path)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{extensionLabel(path)} · {formatSize(state.sizeBytes)}
|
||||
</p>
|
||||
<p className="max-w-md text-xs">No in-app preview for this file type.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{state.canShowAsText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadAsText()}
|
||||
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"
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
Show as plain text
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
apps/x/apps/renderer/src/components/video-file-viewer.tsx
Normal file
53
apps/x/apps/renderer/src/components/video-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLinkIcon, FileVideoIcon } from 'lucide-react'
|
||||
|
||||
interface VideoFileViewerProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
type State = 'loading' | 'ready' | 'error'
|
||||
|
||||
export function VideoFileViewer({ path }: VideoFileViewerProps) {
|
||||
const [state, setState] = useState<State>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [path])
|
||||
|
||||
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
||||
<FileVideoIcon className="size-6" />
|
||||
<p className="text-sm font-medium text-foreground">Cannot play this video</p>
|
||||
<p className="max-w-md text-xs">
|
||||
The codec or container format isn't supported by Chromium (e.g. WMV, AVI, or some MKV files).
|
||||
</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="flex h-full w-full items-center justify-center bg-black">
|
||||
<video
|
||||
key={path}
|
||||
src={src}
|
||||
controls
|
||||
className="max-h-full max-w-full"
|
||||
onLoadedMetadata={() => setState('ready')}
|
||||
onError={() => setState('error')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/x/apps/renderer/src/lib/file-types.ts
Normal file
56
apps/x/apps/renderer/src/lib/file-types.ts
Normal file
|
|
@ -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<string, ViewerType> = {
|
||||
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'
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue