src/renderer/components/FilePreview/README.md
FilePreview is the canonical read-only preview host for local files. Callers provide a file path and decide where the preview appears. The host validates the path target and selects the preview strategy; the matching plugin owns file I/O, format rendering, toolbar controls, and format-specific state.
The built-in plugins currently support HTML, images (.jpg, .jpeg, .png, .gif, .bmp, .webp, .avif, .ico, .svg — SVG renders via ``, which never executes embedded scripts), PDF, Word (.docx), PowerPoint (.pptx), Markdown (.md, .markdown, .mdx), and text/source files. Files outside the text extension whitelist still use the text plugin when content sniffing identifies them as text.
AbsoluteFilePath values only. POSIX and Windows paths are supported.file:// URLs, HTTP URLs, Base64 values, or in-memory data.FilePreview lexically normalizes the path before resolving a plugin. It does not resolve symlinks or call realpath.FilePreview calls getMetadata before selecting a plugin. Directories and inaccessible paths never reach a file plugin.normalizeFilePreviewPath. Do not bypass runtime validation with a type assertion.import { normalizeFilePreviewPath } from '@renderer/utils/filePreview'
const filePath = normalizeFilePreviewPath(physicalPath)
Import FilePreview from the module root and place it in a parent with a defined available height. The component fills its parent, and the plugin content area handles scrolling.
import { Button } from '@cherrystudio/ui'
import { FilePreview } from '@renderer/components/FilePreview'
import type { AbsoluteFilePath } from '@shared/types/file'
import { useTranslation } from 'react-i18next'
interface FileDetailsProps {
fileName: string
filePath: AbsoluteFilePath
onBack: () => void
refreshKey?: number
}
export function FileDetails({ fileName, filePath, onBack, refreshKey }: FileDetailsProps) {
const { t } = useTranslation()
return (
<section className="flex min-h-0 flex-1">
<FilePreview
filePath={filePath}
refreshKey={refreshKey}
header={
<>
<Button onClick={onBack}>{t('common.back')}</Button>
<span className="truncate">{fileName}</span>
</>
}
/>
</section>
)
}
The embedded host owns page-level interactions such as back, close, and file selection. Pass those controls as
header content when they should share the fixed top row with the plugin toolbar. FilePreview keeps caller content
on the left and portals the active plugin toolbar to the right. Do not pass format controls through header or add
embedded, showBackButton, or page-specific callbacks to FilePreview.
Use type="artifact" for an explicit development-artifact surface whose host owns editing. Markdown and HTML then
stay in rendered preview mode and omit their preview/source switch, while HTML uses the interactive artifact sandbox
so generated applications can run scripts. This does not hide format-specific controls such as PDF zoom or image
transforms.
All other callers default to type="file". That type treats local HTML as untrusted, renders it with the
script-less sandbox and strict CSP, and keeps the plugin-owned preview/source switch. Do not mark an arbitrary local
file as an artifact merely to enable scripts.
Use useOpenFilePreviewTab below TabsProvider. The hook normalizes the path, creates a URL-encoded /app/file-preview?path=... target, and uses the cross-platform basename as the tab title.
import { Button } from '@cherrystudio/ui'
import { useOpenFilePreviewTab } from '@renderer/components/FilePreview'
import type { AbsoluteFilePath } from '@shared/types/file'
import { useTranslation } from 'react-i18next'
export function OpenPreviewButton({ filePath }: { filePath: AbsoluteFilePath }) {
const { t } = useTranslation()
const openFilePreviewTab = useOpenFilePreviewTab()
return <Button onClick={() => openFilePreviewTab(filePath)}>{t('common.open_in_new_tab')}</Button>
}
The hook does not set forceNew. Equivalent normalized paths produce the same URL and reuse an existing tab. Reopening an existing tab increments its internal refresh key so the mounted plugin reloads the file. Pass the file's display name as the optional second argument when it differs from the physical path basename. The returned string is the tab ID when the caller needs it.
Embedded and tab previews are host composition choices, not FilePreview display variants. If users can switch between them, keep that choice in the calling page: set the current filePath for embedded mode or call openFilePreviewTab(filePath) for tab mode. Do not move this mode state into FilePreview.
Each format is an independent plugin under plugins/<format>/:
plugins/example/
├── ExampleFilePreview.tsx
├── ExampleFilePreviewToolbar.tsx # Create only when the plugin has controls
├── __tests__/
│ └── ExampleFilePreview.test.tsx
└── exampleFilePreviewPlugin.ts
The plugin descriptor declares only its identity, extensions, and lazy entry point:
import type { FilePreviewPlugin } from '../../types'
export const exampleFilePreviewPlugin = {
id: 'example',
extensions: ['example', 'example2'],
load: () => import('./ExampleFilePreview')
} satisfies FilePreviewPlugin
Descriptor rules:
id must be stable and unique within the registry.extensions must be lowercase and omit the leading dot. Use pdf, not .pdf or PDF.load must resolve to a module with a default React component export. Keep large rendering libraries inside the lazy module rather than the descriptor.The plugin component receives the normalized path, extracted filename, preflighted file metadata, and a required refresh key:
interface FilePreviewPluginProps {
filePath: AbsoluteFilePath
fileName: string
metadata: FilePreviewFileMetadata
refreshKey: number
type?: 'artifact' | 'file'
}
The preview component must use a default export, read the file, and compose the module's internal layout:
import { FilePreviewLayout } from '../../FilePreviewLayout'
import type { FilePreviewPluginProps } from '../../types'
import { ExampleFilePreviewToolbar } from './ExampleFilePreviewToolbar'
export default function ExampleFilePreview({ filePath, fileName, metadata, refreshKey }: FilePreviewPluginProps) {
// Load in an effect that depends on filePath and refreshKey. The plugin owns
// file loading, view state, and toolbar actions here.
return (
<FilePreviewLayout.Frame>
<ExampleFilePreviewToolbar disabled={false} />
<FilePreviewLayout.Content>
<div>{fileName} ({metadata.size} bytes)</div>
</FilePreviewLayout.Content>
</FilePreviewLayout.Frame>
)
}
After implementing the plugin, explicitly import it in filePreviewRegistry.ts and add it to extensionPlugins:
export const filePreviewRegistry = createFilePreviewRegistry({
extensionPlugins: [imageFilePreviewPlugin, exampleFilePreviewPlugin]
})
Keep the public FilePreview props minimal: filePath, optional header, optional refreshKey, and optional type.
Follow these boundaries when adding formats or capabilities:
isPdf or isImage to FilePreview.<Format>FilePreviewToolbar.tsx component. When a plugin has no controls, omit the toolbar completely instead of rendering an empty row.FilePreviewToolbar. Use FilePreviewToolbarButton for icon commands and an appropriate UI primitive such as SegmentedControl for mode selection.FilePreview later instead of coupling the new plugin back to it.'preview' | 'source', not several interacting booleans.type="file" is the default for arbitrary paths and must keep untrusted HTML script-less.type="artifact" only for a development-artifact surface that intentionally runs generated HTML and owns the
source/edit experience. Plugins without an artifact-specific policy ignore it; their format controls remain visible.header as host-owned navigation and identity content only. When it is absent, the plugin toolbar remains
centered in its own row for Tab and standalone previews.This composition lets the same plugin work in embedded and tab hosts without format-specific branches.
FilePreview; let missing or inaccessible file selections reach FilePreview so it can show the unavailable state.FilePreview uses this routing model:| Target | Preview decision | Result |
|---|---|---|
| Directory | No file plugin | File-browser surface; defensive folder state if passed directly |
| Existing file with a registered binary plugin | Registered plugin | Inline preview |
| Artifact HTML | HTML plugin with artifact policy | Interactive inline preview; host owns source/edit |
| Existing text file with a registered text plugin | Registered plugin after content sniff | Inline preview |
| Existing text file with no registered extension | Text fallback plugin | Source preview |
| Existing binary file with no registered plugin | Unsupported | Explanation plus safe default-app action |
| Missing or inaccessible path | Unavailable | Explanation without an open action |
| Invalid or non-absolute path | Invalid | Explanation without an open action |
window.api.fs.readText for text. Use window.api.fs.read only for full binary reads that the plugin bounds
using the preflighted file size. Large or on-demand binary formats must use typed ipcApi.request('file.read', ...)
range reads instead of loading the entire file.version changes between reads or whose version.size differs from the preflighted metadata.size.metadata prop for size guards. Do not issue a second metadata request from a plugin.filePath and refreshKey in loading effects. A new refresh key means the current file must be read again even when its path is unchanged.FilePreview owns directory, invalid-path, unavailable-path, unsupported-format, plugin-load, and synchronous render error states.loggerService, and expose enough diagnostic detail in the error state to make failures actionable.filePath changes, or refreshKey changes.@cherrystudio/ui and Tailwind CSS, following the repository DESIGN.md.file_preview.* i18n keys, reuse existing common.* or preview.* keys for shared controls, and update en-us and zh-cn.FilePreviewLayout.Content should own content scrolling.A new plugin should have focused coverage for at least these cases:
filePath, correct fileName, preflighted metadata, and current refreshKey.Run the focused plugin and registry Vitest suites first, followed by the repository-required formatting and static checks.