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:
Ramnique Singh 2026-05-09 00:30:14 +05:30 committed by GitHub
commit 0bf7a55611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 707 additions and 15 deletions

1
apps/x/.gitignore vendored
View file

@ -1 +1,2 @@
node_modules/ node_modules/
test-fixtures/

View file

@ -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 { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.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 started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process"; import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
@ -133,16 +134,29 @@ const rendererPath = app.isPackaged
: path.join(__dirname, "../../../renderer/dist"); // Development : path.join(__dirname, "../../../renderer/dist"); // Development
console.log("rendererPath", rendererPath); console.log("rendererPath", rendererPath);
// Register custom protocol for serving built renderer files in production. // Register custom protocol for serving built renderer files in production
// This keeps SPA routes working when users deep link into the packaged app. // 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() { function registerAppProtocol() {
protocol.handle("app", (request) => { protocol.handle("app", (request) => {
const url = new URL(request.url); const url = new URL(request.url);
// url.pathname starts with "/" // Workspace files: app://workspace/<rel-path>
let urlPath = url.pathname; 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)) { if (urlPath === "/" || !path.extname(urlPath)) {
urlPath = "/index.html"; urlPath = "/index.html";
} }
@ -161,8 +175,8 @@ protocol.registerSchemesAsPrivileged([
supportFetchAPI: true, supportFetchAPI: true,
corsEnabled: true, corsEnabled: true,
allowServiceWorkers: true, allowServiceWorkers: true,
// optional but often helpful: // Required for byte-range requests so <video> seeking works.
// stream: true, stream: true,
}, },
}, },
]); ]);
@ -207,6 +221,9 @@ function createWindow() {
contextIsolation: true, contextIsolation: true,
sandbox: true, sandbox: true,
preload: preloadPath, 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 () => { app.whenReady().then(async () => {
// Register custom protocol before creating window (for production builds) // Register custom protocol before creating window.
if (app.isPackaged) { // In production this serves the renderer SPA; in dev (and prod) it also
registerAppProtocol(); // serves workspace files via app://workspace/<rel-path> for media previews.
} registerAppProtocol();
// Initialize auto-updater (only in production) // Initialize auto-updater (only in production)
if (app.isPackaged) { if (app.isPackaged) {

View file

@ -13,6 +13,12 @@ import { ChatInputWithMentions, type StagedAttachment } from './components/chat-
import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-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 { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { SuggestedTopicsView } from '@/components/suggested-topics-view';
@ -1425,6 +1431,13 @@ function App() {
} }
const requestId = (fileLoadRequestIdRef.current += 1) const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath 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 let cancelled = false
;(async () => { ;(async () => {
try { try {
@ -4715,6 +4728,15 @@ function App() {
/> />
</div> </div>
) : selectedPath ? ( ) : 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') ? ( selectedPath.endsWith('.md') ? (
<div className="flex-1 min-h-0 flex flex-row overflow-hidden"> <div className="flex-1 min-h-0 flex flex-row overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
@ -4824,13 +4846,25 @@ function App() {
/> />
)} )}
</div> </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"> <div className="flex-1 min-h-0 overflow-hidden">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap"> <UnsupportedFileViewer path={selectedPath} />
{fileContent || 'Loading...'}
</pre>
</div> </div>
) )
)}
</>
) : selectedTask ? ( ) : selectedTask ? (
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<BackgroundTaskDetail <BackgroundTaskDetail

View 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&apos;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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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'
}