mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02: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/
|
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 { 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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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