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"
|
"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",
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user