fix previews in Commander
This commit is contained in:
Generated
+1662
-27
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,19 @@
|
||||
"pub": "npm publish ./cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@embedpdf/core": "^2.15.0",
|
||||
"@embedpdf/engines": "^2.15.0",
|
||||
"@embedpdf/models": "^2.15.0",
|
||||
"@embedpdf/pdfium": "^2.15.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.15.0",
|
||||
"@embedpdf/plugin-render": "^2.15.0",
|
||||
"@embedpdf/plugin-scroll": "^2.15.0",
|
||||
"@embedpdf/plugin-tiling": "^2.15.0",
|
||||
"@embedpdf/plugin-viewport": "^2.15.0",
|
||||
"@embedpdf/plugin-zoom": "^2.15.0",
|
||||
"@extend-ai/react-docx": "^0.8.3",
|
||||
"@extend-ai/react-pptx": "^0.1.2",
|
||||
"@extend-ai/react-xlsx": "^0.16.1",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@heroui/react": "^2.7.11",
|
||||
"@mux/mux-player-react": "^3.13.2",
|
||||
@@ -56,6 +69,8 @@
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"cronstrue": "^3.24.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"framer-motion": "^12.42.2",
|
||||
|
||||
@@ -117,6 +117,23 @@ const CODE_EXTENSIONS = new Set([
|
||||
'vb',
|
||||
'asm',
|
||||
's',
|
||||
// Markup / web / query / misc — all highlighted by the CodeMirror langs pack.
|
||||
'html',
|
||||
'htm',
|
||||
'xml',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
'less',
|
||||
'sql',
|
||||
'vue',
|
||||
'svelte',
|
||||
'dart',
|
||||
'gradle',
|
||||
'groovy',
|
||||
'proto',
|
||||
'diff',
|
||||
'patch',
|
||||
])
|
||||
|
||||
const CONFIG_EXTENSIONS = new Set([
|
||||
@@ -190,7 +207,11 @@ export function isPreviewable(entry: Entry): boolean {
|
||||
type === 'video' ||
|
||||
type === 'audio' ||
|
||||
type === 'text' ||
|
||||
type === 'document'
|
||||
type === 'code' ||
|
||||
type === 'config' ||
|
||||
type === 'document' ||
|
||||
type === 'spreadsheet' ||
|
||||
type === 'presentation'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Checkbox, Listbox, ListboxItem, cn } from '@heroui/react'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { DownloadIcon, EyeIcon, PencilIcon, Share2Icon, StarIcon, Trash2Icon } from 'lucide-react'
|
||||
import { DownloadIcon, PencilIcon, Share2Icon, StarIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { formatBytes } from '../../../lib/format.ts'
|
||||
import FileIcon from './FileIcon'
|
||||
@@ -243,7 +243,13 @@ export default function FileList({
|
||||
|
||||
<div
|
||||
className="flex items-center h-full gap-2 pl-2 overflow-hidden cursor-pointer"
|
||||
onClick={() => onNavigate(entry)}
|
||||
// Folders navigate in; files open the preview (falls back
|
||||
// to onNavigate — a no-op for files — where preview is off).
|
||||
onClick={() =>
|
||||
entry.isDir || !onPreviewClick
|
||||
? onNavigate(entry)
|
||||
: onPreviewClick(entry)
|
||||
}
|
||||
>
|
||||
<FileIcon entry={entry} size="md" />
|
||||
<span className="truncate !cursor-pointer" title={entry.fullPath}>
|
||||
@@ -280,17 +286,6 @@ export default function FileList({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{!entry.isDir && onPreviewClick && (
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="transition-opacity duration-300 opacity-0 group-hover:opacity-100"
|
||||
onPress={() => onPreviewClick(entry)}
|
||||
>
|
||||
<EyeIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { useHostStore } from '../../../store/host.ts'
|
||||
import FileList from './FileList'
|
||||
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
|
||||
import PathBreadcrumb from './PathBreadcrumb'
|
||||
import PreviewDrawer from './PreviewDrawer'
|
||||
import RemoteSidebar from './RemoteSidebar'
|
||||
import PreviewDrawer from './preview/PreviewDrawer'
|
||||
import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types'
|
||||
import useCreateFolder from './useCreateFolder'
|
||||
import useFileNavigation from './useFileNavigation'
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
Spinner,
|
||||
} from '@heroui/react'
|
||||
import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { formatBytes } from '../../../lib/format.ts'
|
||||
import { useCurrentHost } from '../../../store/persisted.ts'
|
||||
import FileIcon, { getFileType, isPreviewable } from './FileIcon'
|
||||
import type { Entry } from './types'
|
||||
|
||||
const TRAILING_SLASH_RE = /\/$/
|
||||
const MAX_PREVIEW_SIZE = 500_000_000
|
||||
|
||||
function buildPreviewUrl(entry: Entry, hostUrl: string, auth?: string): string {
|
||||
const normalizedHost = hostUrl.replace(TRAILING_SLASH_RE, '')
|
||||
|
||||
if (entry.remote === 'UI_LOCAL_FS') {
|
||||
return `${normalizedHost}/[local]/${entry.fullPath}`
|
||||
}
|
||||
|
||||
const pathPart = entry.fullPath.includes(':/')
|
||||
? entry.fullPath.split(':/').slice(1).join('/')
|
||||
: entry.fullPath
|
||||
|
||||
const url = `${normalizedHost}/[${entry.remote}:]/${pathPart}`
|
||||
|
||||
if (auth) {
|
||||
return `${url}?auth=${encodeURIComponent(auth)}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export default function PreviewDrawer({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: Entry | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const currentHost = useCurrentHost()
|
||||
const hostUrl = currentHost?.url
|
||||
const authUser = currentHost?.authUser
|
||||
const authPassword = currentHost?.authPassword
|
||||
|
||||
const [textContent, setTextContent] = useState<string | null>(null)
|
||||
const [isLoadingText, setIsLoadingText] = useState(false)
|
||||
const [textError, setTextError] = useState<string | null>(null)
|
||||
|
||||
const auth = useMemo(() => {
|
||||
if (authUser) {
|
||||
return btoa(`${authUser}:${authPassword ?? ''}`)
|
||||
}
|
||||
return undefined
|
||||
}, [authUser, authPassword])
|
||||
|
||||
const fileType = item ? getFileType(item) : 'unknown'
|
||||
const canPreview = item ? isPreviewable(item) : false
|
||||
|
||||
const isTooLarge =
|
||||
item?.size !== undefined && MAX_PREVIEW_SIZE > 0 && item.size > MAX_PREVIEW_SIZE
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!item || !hostUrl) return null
|
||||
return buildPreviewUrl(item, hostUrl, auth)
|
||||
}, [item, hostUrl, auth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!item || fileType !== 'text' || !previewUrl || isTooLarge) {
|
||||
setTextContent(null)
|
||||
setTextError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
setIsLoadingText(true)
|
||||
setTextError(null)
|
||||
|
||||
fetch(previewUrl, {
|
||||
headers: auth ? { Authorization: `Basic ${auth}` } : undefined,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.text()
|
||||
})
|
||||
.then((text) => {
|
||||
setTextContent(text)
|
||||
setIsLoadingText(false)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (abortController.signal.aborted) return
|
||||
setTextError(err.message)
|
||||
setIsLoadingText(false)
|
||||
})
|
||||
|
||||
return () => abortController.abort()
|
||||
}, [item, fileType, previewUrl, auth, isTooLarge])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!previewUrl) return
|
||||
window.open(previewUrl, '_blank')
|
||||
}, [previewUrl])
|
||||
|
||||
const renderPreview = () => {
|
||||
if (!item || !previewUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIconLucide className="size-16" />
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTooLarge) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>File is too large for preview</p>
|
||||
<p className="text-sm">
|
||||
{formatBytes(item.size ?? 0)} (max: {formatBytes(MAX_PREVIEW_SIZE)})
|
||||
</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canPreview) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>Preview not available for this file type</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={item.name}
|
||||
className="object-contain max-w-full max-h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-4">
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: <explanation> */}
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls={true}
|
||||
className="max-w-full max-h-full rounded-lg"
|
||||
autoPlay={false}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4 p-4">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p className="text-lg font-medium">{item.name}</p>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: <explanation> */}
|
||||
<audio src={previewUrl} controls={true} className="w-full max-w-md">
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text': {
|
||||
if (isLoadingText) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (textError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-danger">
|
||||
<p>Error loading file: {textError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full p-4 overflow-auto">
|
||||
<pre className="p-4 font-mono text-sm break-words whitespace-pre-wrap rounded-lg bg-default-100">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'document':
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full h-full p-4 overflow-auto">
|
||||
{/* PDF preview disabled - uncomment when react-pdf is configured */}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>Preview not available</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={!!item}
|
||||
placement="right"
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex items-center justify-between mt-4">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{item && <FileIcon entry={item} size="md" />}
|
||||
<span className="font-medium truncate">{item?.name ?? 'Preview'}</span>
|
||||
</div>
|
||||
<Button isIconOnly={true} size="sm" variant="light" onPress={onClose}>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
</DrawerHeader>
|
||||
<Divider />
|
||||
<DrawerBody className="p-0">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="p-4 space-y-2 text-sm shrink-0 bg-default-50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Type:</span>
|
||||
<span className="capitalize">{fileType}</span>
|
||||
</div>
|
||||
{item.size !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Size:</span>
|
||||
<span>{formatBytes(item.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.modTime && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Modified:</span>
|
||||
<span>{item.modTime}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Location:</span>
|
||||
<span
|
||||
className="text-right truncate max-w-[200px]"
|
||||
title={item.fullPath}
|
||||
>
|
||||
{item.remote === 'UI_LOCAL_FS' ? 'Local' : item.remote}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export { default as FileIcon, getFileType, isPreviewable } from './FileIcon'
|
||||
export { default as FileList } from './FileList'
|
||||
export { default as PanelToolbar, type ToolbarButtons } from './PanelToolbar'
|
||||
export { default as PathBreadcrumb } from './PathBreadcrumb'
|
||||
export { default as PreviewDrawer } from './PreviewDrawer'
|
||||
export { default as PreviewDrawer } from './preview/PreviewDrawer'
|
||||
export { default as RemoteSidebar } from './RemoteSidebar'
|
||||
export { default as useCreateFolder } from './useCreateFolder'
|
||||
export { default as useFileNavigation } from './useFileNavigation'
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs'
|
||||
import CodeMirror, { EditorView } from '@uiw/react-codemirror'
|
||||
import { useMemo } from 'react'
|
||||
import { getFileExtension } from '../utils'
|
||||
import { PreviewError, PreviewLoading, type PreviewViewerProps } from './previewStates'
|
||||
import usePreviewText from './usePreviewText'
|
||||
|
||||
// A handful of extensions the langs pack doesn't key directly — alias each to the
|
||||
// closest available mode. Anything still unmatched renders as plain text (line
|
||||
// numbers, no highlighting), which is the right fallback for .txt/.log/.csv/etc.
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
zsh: 'sh',
|
||||
hrl: 'erl',
|
||||
conf: 'ini',
|
||||
config: 'ini',
|
||||
env: 'properties',
|
||||
sol: 'solidity',
|
||||
}
|
||||
|
||||
// Hardcoded dark-on-white content color. The built-in `light` theme leaves the content
|
||||
// color unset, so it inherits the app's dark-mode (near-white) foreground and the text
|
||||
// is invisible on the white editor. Applied via `extensions` (which win over the `theme`
|
||||
// prop) so it overrides that. Syntax-highlighted tokens keep their own darker colors.
|
||||
const LIGHT_CONTENT_THEME = EditorView.theme(
|
||||
{
|
||||
'&': { backgroundColor: '#ffffff', color: '#111827' },
|
||||
'.cm-content': { color: '#111827', caretColor: '#111827' },
|
||||
'.cm-gutters': { backgroundColor: '#f9fafb', color: '#9ca3af', border: 'none' },
|
||||
},
|
||||
{ dark: false }
|
||||
)
|
||||
|
||||
// Read-only viewer, hardcoded light (like the other preview viewers). basicSetup is
|
||||
// trimmed for a static view — syntax highlighting + line numbers, no active-line chrome.
|
||||
export default function CodeMirrorPreview({
|
||||
url,
|
||||
name,
|
||||
authHeader,
|
||||
onDownload,
|
||||
}: PreviewViewerProps) {
|
||||
const { text, error, progress } = usePreviewText(url, authHeader)
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const ext = getFileExtension(name)
|
||||
const lang = loadLanguage((LANGUAGE_ALIASES[ext] ?? ext) as LanguageName)
|
||||
// Wrap long lines (no horizontal scroll) — nicer for logs/csv/prose.
|
||||
const base = [EditorView.lineWrapping, LIGHT_CONTENT_THEME]
|
||||
return lang ? [...base, lang] : base
|
||||
}, [name])
|
||||
|
||||
if (error) {
|
||||
return <PreviewError message={error} onDownload={onDownload} />
|
||||
}
|
||||
|
||||
if (text === null) {
|
||||
return <PreviewLoading progress={progress} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
value={text}
|
||||
readOnly={true}
|
||||
theme="light"
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
className="w-full h-full text-sm"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
// In-file search: Cmd/Ctrl+F opens the search panel, highlight matches.
|
||||
searchKeymap: true,
|
||||
highlightSelectionMatches: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ReactDocxViewer, setWasmSource, useDocxModel } from '@extend-ai/react-docx'
|
||||
// Bundle the docx layout wasm locally (Vite ?url) so it loads same-origin offline.
|
||||
import docxWasmUrl from '@extend-ai/react-docx/docx_wasm_bg.wasm?url'
|
||||
import { PreviewError, PreviewLoading, type PreviewViewerProps } from './previewStates'
|
||||
import usePreviewSource from './usePreviewSource'
|
||||
|
||||
export default function DocxPreview({ url, authHeader, onDownload }: PreviewViewerProps) {
|
||||
// `buffer` is null until the wasm is configured, so parsing only starts once the
|
||||
// import worker can instantiate from the ArrayBuffer source.
|
||||
const {
|
||||
buffer,
|
||||
error: sourceError,
|
||||
progress,
|
||||
} = usePreviewSource(url, authHeader, docxWasmUrl, setWasmSource)
|
||||
const { model, isLoading: modelLoading, error: modelError } = useDocxModel(buffer ?? undefined)
|
||||
|
||||
const errorMessage = sourceError ?? modelError?.message
|
||||
|
||||
if (errorMessage) {
|
||||
return <PreviewError message={errorMessage} onDownload={onDownload} />
|
||||
}
|
||||
|
||||
if (!buffer || modelLoading || !model) {
|
||||
return <PreviewLoading progress={progress} />
|
||||
}
|
||||
|
||||
// text-black sets the inherited color so runs with no explicit color (docx "auto")
|
||||
// render black, instead of inheriting the app's light dark-mode text color.
|
||||
return (
|
||||
<div className="w-full h-full overflow-auto bg-neutral-100 text-black">
|
||||
<ReactDocxViewer model={model} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { createPluginRegistration } from '@embedpdf/core'
|
||||
import { EmbedPDF } from '@embedpdf/core/react'
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react'
|
||||
// Bundle PDFium's wasm locally (via Vite ?url) so the preview works offline inside
|
||||
// the Tauri webview instead of reaching for the default CDN url.
|
||||
import pdfiumWasmUrl from '@embedpdf/pdfium/pdfium.wasm?url'
|
||||
import {
|
||||
DocumentContent,
|
||||
DocumentManagerPluginPackage,
|
||||
} from '@embedpdf/plugin-document-manager/react'
|
||||
import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'
|
||||
import { ScrollPluginPackage, Scroller, useScroll } from '@embedpdf/plugin-scroll/react'
|
||||
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'
|
||||
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react'
|
||||
import { ZoomMode, ZoomPluginPackage, useZoom } from '@embedpdf/plugin-zoom/react'
|
||||
import { Button } from '@heroui/react'
|
||||
import { DownloadIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { PreviewError, PreviewLoading, type PreviewViewerProps } from './previewStates'
|
||||
import usePreviewSource from './usePreviewSource'
|
||||
|
||||
export default function PdfPreview({ url, name, authHeader, onDownload }: PreviewViewerProps) {
|
||||
const {
|
||||
engine,
|
||||
isLoading: engineLoading,
|
||||
error: engineError,
|
||||
} = usePdfiumEngine({
|
||||
wasmUrl: pdfiumWasmUrl,
|
||||
// Run PDFium on the main thread. The worker engine's async create/destroy
|
||||
// lifecycle races with React StrictMode's double-mount, orphaning the
|
||||
// openDocumentBuffer request so the document hangs in "loading" forever.
|
||||
worker: false,
|
||||
// Desktop app: never fetch fallback fonts from a remote CDN.
|
||||
fontFallback: null,
|
||||
})
|
||||
|
||||
// Fetch the bytes ourselves and hand PDFium a buffer. This avoids EmbedPDF's
|
||||
// default range-request loader (which the rclone serve endpoint doesn't reliably
|
||||
// answer) and the webview CORS block. No wasm arg: the pdfium wasm is loaded by
|
||||
// the engine (usePdfiumEngine) above, not via setWasmSource.
|
||||
const { buffer, error: fetchError, progress } = usePreviewSource(url, authHeader)
|
||||
|
||||
const plugins = useMemo(
|
||||
() =>
|
||||
buffer
|
||||
? [
|
||||
createPluginRegistration(DocumentManagerPluginPackage, {
|
||||
initialDocuments: [{ buffer, name }],
|
||||
}),
|
||||
createPluginRegistration(ViewportPluginPackage),
|
||||
createPluginRegistration(ScrollPluginPackage),
|
||||
createPluginRegistration(RenderPluginPackage),
|
||||
createPluginRegistration(TilingPluginPackage),
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
defaultZoomLevel: ZoomMode.FitWidth,
|
||||
}),
|
||||
]
|
||||
: null,
|
||||
[buffer, name]
|
||||
)
|
||||
|
||||
const errorMessage = engineError?.message ?? fetchError
|
||||
|
||||
if (errorMessage) {
|
||||
return <PreviewError message={errorMessage} onDownload={onDownload} />
|
||||
}
|
||||
|
||||
if (engineLoading || !engine || !buffer || !plugins) {
|
||||
return <PreviewLoading progress={progress} />
|
||||
}
|
||||
|
||||
return (
|
||||
// Re-key on the url so switching to another PDF reloads the document cleanly.
|
||||
<EmbedPDF key={url} engine={engine} plugins={plugins}>
|
||||
{({ activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
return <PreviewLoading />
|
||||
}
|
||||
return (
|
||||
<DocumentContent documentId={activeDocumentId}>
|
||||
{({ isLoaded, isError, documentState }) => {
|
||||
if (isError) {
|
||||
return (
|
||||
<PreviewError
|
||||
message={documentState.error ?? 'Failed to open document'}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoaded) {
|
||||
return <PreviewLoading />
|
||||
}
|
||||
|
||||
return (
|
||||
<PdfViewport
|
||||
documentId={activeDocumentId}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</DocumentContent>
|
||||
)
|
||||
}}
|
||||
</EmbedPDF>
|
||||
)
|
||||
}
|
||||
|
||||
function PdfViewport({
|
||||
documentId,
|
||||
onDownload,
|
||||
}: {
|
||||
documentId: string
|
||||
onDownload: () => void
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const lastWidthRef = useRef(0)
|
||||
const { provides: zoom } = useZoom(documentId)
|
||||
|
||||
// Fit the page to the viewport width on load AND whenever the container's WIDTH
|
||||
// changes (e.g. expanding the drawer to 97%). The plugin's own `defaultZoomLevel`
|
||||
// is unreliable because the viewport only mounts after the document loads, so its
|
||||
// initial recalc runs while clientWidth is still 0. A ResizeObserver fires once the
|
||||
// element has a width; we defer a frame so EmbedPDF's viewport metrics are populated.
|
||||
//
|
||||
// Two guards keep this from fighting the user: only react to width changes (ignore
|
||||
// height/scrollbar churn), and only auto-fit while still in fit-width mode — once the
|
||||
// user zooms manually, we leave their zoom alone instead of snapping back to fit
|
||||
// (which otherwise produced a resize/zoom flicker loop).
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el || !zoom) return
|
||||
const observer = new ResizeObserver(() => {
|
||||
const width = el.clientWidth
|
||||
if (width === 0 || width === lastWidthRef.current) return
|
||||
lastWidthRef.current = width
|
||||
requestAnimationFrame(() => {
|
||||
if (zoom.getState().zoomLevel === ZoomMode.FitWidth) {
|
||||
zoom.requestZoom(ZoomMode.FitWidth)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [zoom])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col w-full h-full">
|
||||
<PdfToolbar documentId={documentId} onDownload={onDownload} />
|
||||
<Viewport documentId={documentId} className="flex-1 min-h-0 bg-neutral-100">
|
||||
<Scroller
|
||||
documentId={documentId}
|
||||
renderPage={({ width, height, pageIndex }) => (
|
||||
<div style={{ width, height }}>
|
||||
<RenderLayer documentId={documentId} pageIndex={pageIndex} />
|
||||
<TilingLayer documentId={documentId} pageIndex={pageIndex} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Viewport>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PdfToolbar({
|
||||
documentId,
|
||||
onDownload,
|
||||
}: {
|
||||
documentId: string
|
||||
onDownload: () => void
|
||||
}) {
|
||||
const { state: zoomState, provides: zoom } = useZoom(documentId)
|
||||
const { state: scrollState } = useScroll(documentId)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b shrink-0 border-divider bg-default-50">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => zoom?.zoomOut()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-xs text-center tabular-nums text-default-600">
|
||||
{Math.round(zoomState.currentZoomLevel * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => zoom?.zoomIn()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-default-500">
|
||||
{scrollState.currentPage} / {scrollState.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={onDownload}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactPptxViewer, setWasmSource } from '@extend-ai/react-pptx'
|
||||
// Bundle the pptx wasm locally (Vite ?url) so it loads same-origin offline.
|
||||
import pptxWasmUrl from '@extend-ai/react-pptx/pptx_wasm_bg.wasm?url'
|
||||
import '@extend-ai/react-pptx/styles.css'
|
||||
import { PreviewError, PreviewLoading, type PreviewViewerProps } from './previewStates'
|
||||
import usePreviewSource from './usePreviewSource'
|
||||
|
||||
export default function PptxPreview({ url, authHeader, onDownload }: PreviewViewerProps) {
|
||||
const { buffer, error, progress } = usePreviewSource(
|
||||
url,
|
||||
authHeader,
|
||||
pptxWasmUrl,
|
||||
setWasmSource
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return <PreviewError message={error} onDownload={onDownload} />
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return <PreviewLoading progress={progress} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactPptxViewer
|
||||
source={buffer}
|
||||
showToolbar={false}
|
||||
showThumbnails={true}
|
||||
// Preview is always light-themed regardless of the app theme.
|
||||
className="w-full h-full bg-neutral-100"
|
||||
viewportClassName="bg-neutral-100"
|
||||
renderLoading={() => <PreviewLoading />}
|
||||
renderError={(err) => <PreviewError message={err.message} onDownload={onDownload} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { Button, Divider, Drawer, DrawerBody, DrawerContent, DrawerHeader, cn } from '@heroui/react'
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileIcon as FileIconLucide,
|
||||
Maximize2Icon,
|
||||
Minimize2Icon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type ComponentType,
|
||||
type LazyExoticComponent,
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { formatBytes } from '../../../../lib/format.ts'
|
||||
import { openWindow } from '../../../../lib/window.ts'
|
||||
import { useCurrentHost, usePersistedStore } from '../../../../store/persisted.ts'
|
||||
import FileIcon, { getFileType, isPreviewable } from '../FileIcon'
|
||||
import type { Entry } from '../types'
|
||||
import { getFileExtension } from '../utils'
|
||||
import PreviewProLock from './PreviewProLock'
|
||||
import { PreviewLoading, type PreviewViewerProps } from './previewStates'
|
||||
|
||||
// Lazy-loaded so each viewer's heavy WASM/WebGL bundle only loads when a matching
|
||||
// file is actually opened, keeping them out of the main + Commander bundle.
|
||||
const PdfPreview = lazy(() => import('./PdfPreview'))
|
||||
const DocxPreview = lazy(() => import('./DocxPreview'))
|
||||
const XlsxPreview = lazy(() => import('./XlsxPreview'))
|
||||
const PptxPreview = lazy(() => import('./PptxPreview'))
|
||||
// CodeMirror + its ~140 bundled languages are heavy; keep them out of the main chunk too.
|
||||
const CodeMirrorPreview = lazy(() => import('./CodeMirrorPreview'))
|
||||
|
||||
// Text-like file types (plus .csv) render in the CodeMirror viewer.
|
||||
const CODE_VIEWER_TYPES = new Set(['text', 'code', 'config'])
|
||||
|
||||
// Documents route to a dedicated inline viewer by extension; any other extension
|
||||
// falls through to the media/text switch or the download fallback below.
|
||||
const DOCUMENT_VIEWERS: Record<string, LazyExoticComponent<ComponentType<PreviewViewerProps>>> = {
|
||||
pdf: PdfPreview,
|
||||
docx: DocxPreview,
|
||||
xlsx: XlsxPreview,
|
||||
ppt: PptxPreview,
|
||||
pptx: PptxPreview,
|
||||
}
|
||||
|
||||
const TRAILING_SLASH_RE = /\/$/
|
||||
const LEADING_SLASHES_RE = /^\/+/
|
||||
const WINDOWS_DRIVE_RE = /^([a-zA-Z]:\/)/
|
||||
const MAX_PREVIEW_SIZE = 500_000_000
|
||||
|
||||
// The daemon runs with `--rc-serve`, exposing objects at `/[fs]/remote/path` where
|
||||
// `[fs]` is an rclone connection string. The local filesystem must be addressed via
|
||||
// the `:local:` backend rooted at the filesystem root — the same fs the app uses for
|
||||
// local `/operations/list` calls (see searchPath()/getFsInfo()). A bare `[local]`
|
||||
// names a non-existent remote, so local previews 404'd while remotes worked.
|
||||
function buildLocalFs(fullPath: string): { fs: string; path: string } {
|
||||
const normalized = fullPath.replace(/\\/g, '/')
|
||||
const drive = normalized.match(WINDOWS_DRIVE_RE)
|
||||
if (drive) {
|
||||
// e.g. C:/Users/x → fs ":local:C:/", path "Users/x"
|
||||
return {
|
||||
fs: `:local:${drive[1]}`,
|
||||
path: normalized.slice(drive[0].length).replace(LEADING_SLASHES_RE, ''),
|
||||
}
|
||||
}
|
||||
// POSIX absolute path → local backend rooted at "/", path relative to it.
|
||||
return { fs: ':local:/', path: normalized.replace(LEADING_SLASHES_RE, '') }
|
||||
}
|
||||
|
||||
function buildPreviewUrl(entry: Entry, hostUrl: string, auth?: string): string {
|
||||
const normalizedHost = hostUrl.replace(TRAILING_SLASH_RE, '')
|
||||
|
||||
let url: string
|
||||
if (entry.remote === 'UI_LOCAL_FS') {
|
||||
const { fs, path } = buildLocalFs(entry.fullPath)
|
||||
url = `${normalizedHost}/[${fs}]/${path}`
|
||||
} else {
|
||||
const pathPart = entry.fullPath.includes(':/')
|
||||
? entry.fullPath.split(':/').slice(1).join('/')
|
||||
: entry.fullPath
|
||||
url = `${normalizedHost}/[${entry.remote}:]/${pathPart}`
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
url = `${url}?auth=${encodeURIComponent(auth)}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export default function PreviewDrawer({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: Entry | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const currentHost = useCurrentHost()
|
||||
const hostUrl = currentHost?.url
|
||||
const authUser = currentHost?.authUser
|
||||
const authPassword = currentHost?.authPassword
|
||||
|
||||
// File previews are a PRO feature — without a valid license we tease the preview
|
||||
// behind an upsell overlay instead of unlocking it.
|
||||
const licenseValid = usePersistedStore((state) => state.licenseValid)
|
||||
|
||||
// When expanded, the drawer widens to ~90% of the window (the Commander).
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const auth = useMemo(() => {
|
||||
if (authUser) {
|
||||
return btoa(`${authUser}:${authPassword ?? ''}`)
|
||||
}
|
||||
return undefined
|
||||
}, [authUser, authPassword])
|
||||
|
||||
const authHeader = auth ? `Basic ${auth}` : undefined
|
||||
|
||||
const fileType = item ? getFileType(item) : 'unknown'
|
||||
const canPreview = item ? isPreviewable(item) : false
|
||||
|
||||
const isTooLarge =
|
||||
item?.size !== undefined && MAX_PREVIEW_SIZE > 0 && item.size > MAX_PREVIEW_SIZE
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!item || !hostUrl) return null
|
||||
return buildPreviewUrl(item, hostUrl, auth)
|
||||
}, [item, hostUrl, auth])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!previewUrl) return
|
||||
window.open(previewUrl, '_blank')
|
||||
}, [previewUrl])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setExpanded(false)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleUnlock = useCallback(() => {
|
||||
openWindow({ name: 'Settings', url: '/settings?tab=license' })
|
||||
}, [])
|
||||
|
||||
// Only tease the paywall over an actual rendered preview — not the "select a file",
|
||||
// "too large", or unsupported/download-fallback states.
|
||||
const showProLock = !licenseValid && !!previewUrl && !isTooLarge && canPreview
|
||||
|
||||
const renderPreview = () => {
|
||||
if (!item || !previewUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIconLucide className="size-16" />
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTooLarge) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>File is too large for preview</p>
|
||||
<p className="text-sm">
|
||||
{formatBytes(item.size ?? 0)} (max: {formatBytes(MAX_PREVIEW_SIZE)})
|
||||
</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Shown for types we can't render inline (and for the sub-formats of an
|
||||
// otherwise-previewable type that this viewer doesn't handle, e.g. .odt / .csv).
|
||||
const unsupportedFallback = (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>Preview not available for this file type</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!canPreview) {
|
||||
return unsupportedFallback
|
||||
}
|
||||
|
||||
// Documents (pdf/docx/xlsx/pptx) route to their dedicated inline viewer;
|
||||
// unsupported document sub-formats (e.g. .odt) fall through to download.
|
||||
const ext = getFileExtension(item.name)
|
||||
const DocumentViewer = DOCUMENT_VIEWERS[ext]
|
||||
if (DocumentViewer) {
|
||||
return (
|
||||
<DocumentViewer
|
||||
url={previewUrl}
|
||||
name={item.name}
|
||||
authHeader={authHeader}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Text, code and config files — plus .csv (classified as a spreadsheet, but we
|
||||
// show it as delimited text) — render in the CodeMirror viewer.
|
||||
if (CODE_VIEWER_TYPES.has(fileType) || ext === 'csv') {
|
||||
return (
|
||||
<CodeMirrorPreview
|
||||
url={previewUrl}
|
||||
name={item.name}
|
||||
authHeader={authHeader}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={item.name}
|
||||
className="object-contain max-w-full max-h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-4">
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: <explanation> */}
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls={true}
|
||||
className="max-w-full max-h-full rounded-lg"
|
||||
autoPlay={false}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4 p-4">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p className="text-lg font-medium">{item.name}</p>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: <explanation> */}
|
||||
<audio src={previewUrl} controls={true} className="w-full max-w-md">
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>Preview not available</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={!!item}
|
||||
placement="right"
|
||||
size="md"
|
||||
onClose={handleClose}
|
||||
hideCloseButton={true}
|
||||
classNames={{
|
||||
base: cn(
|
||||
'transition-[max-width] duration-300 ease-in-out',
|
||||
expanded && '!max-w-[97vw]'
|
||||
),
|
||||
}}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex items-center justify-between mt-4">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{item && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-label={expanded ? 'Collapse preview' : 'Expand preview'}
|
||||
className="relative grid shrink-0 cursor-pointer size-5 group place-items-center"
|
||||
>
|
||||
{/* File icon by default; hard-swaps to a maximize/
|
||||
minimize affordance on hover (only one is ever shown). */}
|
||||
<span className="col-start-1 row-start-1 group-hover:hidden">
|
||||
<FileIcon entry={item} size="md" />
|
||||
</span>
|
||||
<span className="hidden col-start-1 row-start-1 group-hover:block">
|
||||
{expanded ? (
|
||||
<Minimize2Icon className="size-4" />
|
||||
) : (
|
||||
<Maximize2Icon className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="font-medium truncate">{item?.name ?? 'Preview'}</span>
|
||||
</div>
|
||||
<Button isIconOnly={true} size="sm" variant="light" onPress={handleClose}>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
</DrawerHeader>
|
||||
<Divider />
|
||||
<DrawerBody className="p-0">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<Suspense fallback={<PreviewLoading />}>{renderPreview()}</Suspense>
|
||||
{showProLock && <PreviewProLock onUnlock={handleUnlock} />}
|
||||
</div>
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="p-4 space-y-2 text-sm shrink-0 bg-default-50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Type:</span>
|
||||
<span className="capitalize">{fileType}</span>
|
||||
</div>
|
||||
{item.size !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Size:</span>
|
||||
<span>{formatBytes(item.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.modTime && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Modified:</span>
|
||||
<span>{item.modTime}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Location:</span>
|
||||
<span
|
||||
className="text-right truncate max-w-[200px]"
|
||||
title={item.fullPath}
|
||||
>
|
||||
{item.remote === 'UI_LOCAL_FS' ? 'Local' : item.remote}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@heroui/react'
|
||||
|
||||
/**
|
||||
* Paywall teaser shown over the preview for users without a valid license. The preview
|
||||
* renders underneath, blurred and darkened by a full-height transparent-to-black
|
||||
* gradient (smooth over heavy coverage) with the upsell copy and Unlock CTA at the
|
||||
* bottom. It captures pointer events, so the locked preview can be glimpsed but not
|
||||
* interacted with.
|
||||
*/
|
||||
export default function PreviewProLock({ onUnlock }: { onUnlock: () => void }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col justify-end bg-gradient-to-b from-transparent to-black backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3 px-6 pt-10 pb-9 text-center">
|
||||
<p className="text-2xl font-semibold tracking-tight text-white text-balance">
|
||||
Unlock File Previews
|
||||
</p>
|
||||
|
||||
<p className="max-w-xs text-sm leading-relaxed text-white/60 text-balance">
|
||||
Plus Schedules, Notifications and everything else in Rclone UI Pro.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
radius="full"
|
||||
onPress={onUnlock}
|
||||
className="mt-3 bg-white px-10 font-semibold text-black shadow-lg shadow-black/40 transition-colors hover:bg-white/90"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Unlock Pro
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { XlsxViewer, setWasmSource } from '@extend-ai/react-xlsx'
|
||||
// Bundle the sheets wasm locally (Vite ?url) so it loads same-origin offline.
|
||||
import xlsxWasmUrl from '@extend-ai/react-xlsx/duke_sheets_wasm_bg.wasm?url'
|
||||
import { PreviewError, PreviewLoading, type PreviewViewerProps } from './previewStates'
|
||||
import usePreviewSource from './usePreviewSource'
|
||||
|
||||
export default function XlsxPreview({ url, name, authHeader, onDownload }: PreviewViewerProps) {
|
||||
const { buffer, error, progress } = usePreviewSource(
|
||||
url,
|
||||
authHeader,
|
||||
xlsxWasmUrl,
|
||||
setWasmSource
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return <PreviewError message={error} onDownload={onDownload} />
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return <PreviewLoading progress={progress} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<XlsxViewer
|
||||
file={buffer}
|
||||
fileName={name}
|
||||
readOnly={true}
|
||||
// Preview is always light-themed regardless of the app theme.
|
||||
isDark={false}
|
||||
showDefaultToolbar={false}
|
||||
height="100%"
|
||||
loadingState={<PreviewLoading />}
|
||||
errorState={(err) => <PreviewError message={err.message} onDownload={onDownload} />}
|
||||
fileTooLargeState={
|
||||
<PreviewError message="File is too large to preview" onDownload={onDownload} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button, Progress } from '@heroui/react'
|
||||
import { DownloadIcon } from 'lucide-react'
|
||||
|
||||
/** Common props shared by every inline file-preview viewer. */
|
||||
export interface PreviewViewerProps {
|
||||
url: string
|
||||
name: string
|
||||
authHeader?: string
|
||||
onDownload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared centered loading indicator for the preview area. Shows a determinate download
|
||||
* progress bar when `progress` (0..1) is known, and an indeterminate bar otherwise
|
||||
* (e.g. the server sent no Content-Length, or a viewer is still parsing/initializing).
|
||||
*/
|
||||
export function PreviewLoading({ progress }: { progress?: number | null }) {
|
||||
const determinate = typeof progress === 'number'
|
||||
const percent = determinate ? Math.round(progress * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-3">
|
||||
<Progress
|
||||
aria-label="Loading preview"
|
||||
size="sm"
|
||||
value={determinate ? percent : undefined}
|
||||
isIndeterminate={!determinate}
|
||||
className="max-w-[16rem]"
|
||||
/>
|
||||
{determinate && (
|
||||
<span className="text-sm tabular-nums text-default-500">{percent}%</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared "couldn't render, download instead" fallback for the preview area. */
|
||||
export function PreviewError({
|
||||
message,
|
||||
onDownload,
|
||||
}: {
|
||||
message?: string
|
||||
onDownload: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-4 text-center text-default-500">
|
||||
<p>Could not load preview</p>
|
||||
{message && <p className="text-sm">{message}</p>}
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={onDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Native HTTP client: runs the request from Rust so it isn't blocked by the
|
||||
// webview's cross-origin (CORS) policy, the same way lib/rclone/client.ts fetches.
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface PreviewBytes {
|
||||
buffer: ArrayBuffer | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
// Download progress in 0..1, or null when the total size is unknown (no Content-Length).
|
||||
progress: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a preview target's bytes via the native (Tauri) HTTP client and hands
|
||||
* back an ArrayBuffer. Fetching the bytes ourselves — instead of letting a viewer
|
||||
* load by URL — avoids the webview CORS block against the rclone serve origin, so
|
||||
* any viewer that accepts a buffer can render local and remote files alike.
|
||||
*
|
||||
* The body is read as a stream so `progress` can track bytes-received / Content-Length
|
||||
* for a download progress bar; it stays null when the server sends no Content-Length.
|
||||
*/
|
||||
export default function usePreviewBytes(url: string, authHeader?: string): PreviewBytes {
|
||||
const [state, setState] = useState<PreviewBytes>({
|
||||
buffer: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
progress: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setState({ buffer: null, isLoading: true, error: null, progress: null })
|
||||
|
||||
const abortController = new AbortController()
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await tauriFetch(url, {
|
||||
headers: authHeader ? { Authorization: authHeader } : undefined,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const totalHeader = res.headers.get('content-length')
|
||||
const total = totalHeader ? Number.parseInt(totalHeader, 10) : 0
|
||||
|
||||
// No streamable body: fall back to reading the buffer directly.
|
||||
if (!res.body) {
|
||||
const buffer = await res.arrayBuffer()
|
||||
if (!cancelled) {
|
||||
setState({ buffer, isLoading: false, error: null, progress: 1 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let received = 0
|
||||
let lastPercent = -1
|
||||
|
||||
let result = await reader.read()
|
||||
while (!result.done) {
|
||||
const chunk = result.value
|
||||
chunks.push(chunk)
|
||||
received += chunk.byteLength
|
||||
// Throttle to whole-percent changes so we don't re-render per chunk.
|
||||
if (total > 0 && !cancelled) {
|
||||
const percent = Math.floor(Math.min(received / total, 1) * 100)
|
||||
if (percent !== lastPercent) {
|
||||
lastPercent = percent
|
||||
setState((s) => ({ ...s, progress: percent / 100 }))
|
||||
}
|
||||
}
|
||||
result = await reader.read()
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(received)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
if (!cancelled) {
|
||||
setState({ buffer: merged.buffer, isLoading: false, error: null, progress: 1 })
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || abortController.signal.aborted) return
|
||||
setState({
|
||||
buffer: null,
|
||||
isLoading: false,
|
||||
error: (err as Error).message,
|
||||
progress: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
abortController.abort()
|
||||
}
|
||||
}, [url, authHeader])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import usePreviewBytes from './usePreviewBytes'
|
||||
import usePreviewWasm from './usePreviewWasm'
|
||||
|
||||
/**
|
||||
* Prepares an inline viewer's input: fetches the document bytes (CORS-safe) and, when
|
||||
* the viewer needs it, configures the library's wasm — exposing the buffer only once
|
||||
* BOTH are ready.
|
||||
*
|
||||
* The @extend-ai libraries parse in a Web Worker that instantiates wasm from the
|
||||
* configured source, so the viewer must not mount before `setWasmSource` runs; gating
|
||||
* on this hook's `buffer` (null until wasm is configured) enforces that in one place.
|
||||
* Pass no `wasmUrl`/`setWasmSource` for viewers that manage their own wasm (PdfPreview).
|
||||
*/
|
||||
export default function usePreviewSource(
|
||||
url: string,
|
||||
authHeader: string | undefined,
|
||||
wasmUrl?: string,
|
||||
setWasmSource?: (source: ArrayBuffer) => void
|
||||
): { buffer: ArrayBuffer | null; error: string | null; progress: number | null } {
|
||||
const wasmReady = usePreviewWasm(wasmUrl, setWasmSource)
|
||||
const { buffer, error, progress } = usePreviewBytes(url, authHeader)
|
||||
|
||||
return { buffer: wasmReady ? buffer : null, error, progress }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import usePreviewBytes from './usePreviewBytes'
|
||||
|
||||
// Reused across renders; UTF-8, non-fatal (invalid bytes become U+FFFD rather than throwing).
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
|
||||
/**
|
||||
* Fetches a preview target's bytes via the native (Tauri) HTTP client — same path as
|
||||
* the binary viewers, so it works for remote hosts without hitting the webview CORS
|
||||
* block — and decodes them to a UTF-8 string for the code/text viewer.
|
||||
*/
|
||||
export default function usePreviewText(url: string, authHeader?: string) {
|
||||
const { buffer, isLoading, error, progress } = usePreviewBytes(url, authHeader)
|
||||
const text = useMemo(() => (buffer ? decoder.decode(buffer) : null), [buffer])
|
||||
return { text, isLoading, error, progress }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// One shared fetch per wasm asset, reused across mounts (incl. StrictMode remounts).
|
||||
const wasmCache = new Map<string, Promise<void>>()
|
||||
|
||||
/**
|
||||
* Fetches a viewer library's wasm (a same-origin, bundled asset — no CORS) and hands
|
||||
* it to that library's `setWasmSource` as an **ArrayBuffer**, then reports readiness.
|
||||
*
|
||||
* These libraries parse inside a Web Worker and forward the configured wasm source to
|
||||
* it. A string URL makes the worker fetch the wasm itself, which it can't resolve in
|
||||
* the Tauri webview — so we pass raw bytes instead: the buffer is structured-cloned to
|
||||
* the worker and instantiated directly. Gate the viewer on the returned flag so the
|
||||
* source is configured before the library spins up its worker.
|
||||
*/
|
||||
export default function usePreviewWasm(
|
||||
wasmUrl?: string,
|
||||
setWasmSource?: (source: ArrayBuffer) => void
|
||||
): boolean {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Nothing to configure — e.g. PdfPreview loads its own wasm via the engine.
|
||||
if (!wasmUrl || !setWasmSource) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
let promise = wasmCache.get(wasmUrl)
|
||||
if (!promise) {
|
||||
promise = fetch(wasmUrl)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buffer) => setWasmSource(buffer))
|
||||
wasmCache.set(wasmUrl, promise)
|
||||
}
|
||||
|
||||
promise
|
||||
.then(() => {
|
||||
if (!cancelled) setReady(true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Drop the failed attempt so a later mount can retry the fetch.
|
||||
wasmCache.delete(wasmUrl)
|
||||
// Leave `ready` false; the viewer stays on its loading state.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [wasmUrl, setWasmSource])
|
||||
|
||||
return ready
|
||||
}
|
||||
@@ -27,6 +27,41 @@ export default defineConfig(async () => ({
|
||||
// ],
|
||||
plugins: [react()],
|
||||
|
||||
// Some viewer libs (e.g. @extend-ai/react-xlsx) ship code-splitting Web Workers,
|
||||
// which require ES-module worker output rather than the default iife.
|
||||
worker: { format: 'es' },
|
||||
|
||||
optimizeDeps: {
|
||||
// These viewer libs create Web Workers via `new URL('./worker.js', import.meta.url)`.
|
||||
// Vite's dep pre-bundler doesn't emit those worker files, so in dev the worker URL
|
||||
// 404s ("file does not exist in the optimize deps directory"). Excluding them routes
|
||||
// the workers through Vite's normal worker pipeline. (Prod/Rollup handles them fine.)
|
||||
exclude: [
|
||||
'@extend-ai/react-docx',
|
||||
'@extend-ai/react-xlsx',
|
||||
'@extend-ai/react-pptx',
|
||||
],
|
||||
// Because the packages above are excluded, Vite never scans them to discover their
|
||||
// own dependencies — so their (often CJS) imports like `react-dom/server` aren't
|
||||
// pre-bundled and named imports fail ("Importing binding name ... is not found").
|
||||
// Force-optimize the deps they import by name so interop is applied.
|
||||
include: [
|
||||
'react-dom/server',
|
||||
'regl',
|
||||
'topojson-client',
|
||||
'utif',
|
||||
'fast-png',
|
||||
'@chenglou/pretext',
|
||||
'fflate',
|
||||
'd3-geo',
|
||||
'd3-hierarchy',
|
||||
'd3-scale',
|
||||
'd3-shape',
|
||||
'@tanstack/react-virtual',
|
||||
'@tanstack/virtual-core',
|
||||
],
|
||||
},
|
||||
|
||||
build: { chunkSizeWarningLimit: 5120 },
|
||||
|
||||
esbuild: {
|
||||
|
||||
Reference in New Issue
Block a user