fix previews in Commander

This commit is contained in:
FTCHD
2026-08-20 22:50:32 +03:00
parent b22449dba9
commit 4eb349db88
20 changed files with 2815 additions and 347 deletions
+1662 -27
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -32,6 +32,19 @@
"pub": "npm publish ./cli" "pub": "npm publish ./cli"
}, },
"dependencies": { "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", "@formkit/auto-animate": "^0.9.0",
"@heroui/react": "^2.7.11", "@heroui/react": "^2.7.11",
"@mux/mux-player-react": "^3.13.2", "@mux/mux-player-react": "^3.13.2",
@@ -56,6 +69,8 @@
"@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.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", "cronstrue": "^3.24.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"framer-motion": "^12.42.2", "framer-motion": "^12.42.2",
+22 -1
View File
@@ -117,6 +117,23 @@ const CODE_EXTENSIONS = new Set([
'vb', 'vb',
'asm', 'asm',
's', '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([ const CONFIG_EXTENSIONS = new Set([
@@ -190,7 +207,11 @@ export function isPreviewable(entry: Entry): boolean {
type === 'video' || type === 'video' ||
type === 'audio' || type === 'audio' ||
type === 'text' || type === 'text' ||
type === 'document' type === 'code' ||
type === 'config' ||
type === 'document' ||
type === 'spreadsheet' ||
type === 'presentation'
) )
} }
+8 -13
View File
@@ -1,6 +1,6 @@
import { Button, Checkbox, Listbox, ListboxItem, cn } from '@heroui/react' import { Button, Checkbox, Listbox, ListboxItem, cn } from '@heroui/react'
import { platform } from '@tauri-apps/plugin-os' 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 { useCallback } from 'react'
import { formatBytes } from '../../../lib/format.ts' import { formatBytes } from '../../../lib/format.ts'
import FileIcon from './FileIcon' import FileIcon from './FileIcon'
@@ -243,7 +243,13 @@ export default function FileList({
<div <div
className="flex items-center h-full gap-2 pl-2 overflow-hidden cursor-pointer" 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" /> <FileIcon entry={entry} size="md" />
<span className="truncate !cursor-pointer" title={entry.fullPath}> <span className="truncate !cursor-pointer" title={entry.fullPath}>
@@ -280,17 +286,6 @@ export default function FileList({
/> />
</Button> </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 && ( {onDownload && (
<Button <Button
isIconOnly={true} isIconOnly={true}
+1 -1
View File
@@ -16,8 +16,8 @@ import { useHostStore } from '../../../store/host.ts'
import FileList from './FileList' import FileList from './FileList'
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar' import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
import PathBreadcrumb from './PathBreadcrumb' import PathBreadcrumb from './PathBreadcrumb'
import PreviewDrawer from './PreviewDrawer'
import RemoteSidebar from './RemoteSidebar' import RemoteSidebar from './RemoteSidebar'
import PreviewDrawer from './preview/PreviewDrawer'
import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types' import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types'
import useCreateFolder from './useCreateFolder' import useCreateFolder from './useCreateFolder'
import useFileNavigation from './useFileNavigation' import useFileNavigation from './useFileNavigation'
-304
View File
@@ -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>
)
}
+1 -1
View File
@@ -3,7 +3,7 @@ export { default as FileIcon, getFileType, isPreviewable } from './FileIcon'
export { default as FileList } from './FileList' export { default as FileList } from './FileList'
export { default as PanelToolbar, type ToolbarButtons } from './PanelToolbar' export { default as PanelToolbar, type ToolbarButtons } from './PanelToolbar'
export { default as PathBreadcrumb } from './PathBreadcrumb' 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 RemoteSidebar } from './RemoteSidebar'
export { default as useCreateFolder } from './useCreateFolder' export { default as useCreateFolder } from './useCreateFolder'
export { default as useFileNavigation } from './useFileNavigation' 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
}
+35
View File
@@ -27,6 +27,41 @@ export default defineConfig(async () => ({
// ], // ],
plugins: [react()], 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 }, build: { chunkSizeWarningLimit: 5120 },
esbuild: { esbuild: {