Developing a dsh-file-explorer preview plugin
September 1, 2026 · View on GitHub
中文 | English
This guide shows how to build a plugin that contributes a previewer (or editor) to dsh-file-explorer, using this repository (dsh-file-explorer-preview-code) as the reference implementation.
Overview
dsh-file-explorer renders a file preview by extension. Its built-in previewers are registered at priority 0:
text(plain<pre><code>) for a list of code extensions,markdown(.md/.mdx),image(.png/.jpg/…), andbinary(file info).
An external plugin can override any of these, or add a previewer for a brand-new extension, by injecting the fileExplorer cordis service and calling registerViewer(...) (recommended — one named viewer across many extensions) or registerPreview(ext, component, priority) (anonymous, single-extension override). Higher priority wins; this plugin uses 10 to override the built-in plain-text preview.
@dsh-external/dsh-file-explorer (core)
└─ client apply: ctx.reflect.provide('fileExplorer', { registerPreview, registerViewer, registerFileAction, writeFile, readRawFile })
@dsh-external/dsh-file-explorer-preview-<domain> (your plugin)
└─ inject: ['fileExplorer', 'locale']
└─ apply: ctx.fileExplorer.registerViewer({ id, label, exts, component, priority: 10 })
The contract
Types come from the core package's ./client export:
import type {
FileExplorerService,
PreviewProps,
ViewerRegistration,
Translate,
} from '@dsh-external/dsh-file-explorer/client'
interface FileExplorerService {
registerPreview(ext: string, component: ComponentType<PreviewProps>, priority?: number): () => void
registerViewer(viewer: ViewerRegistration): () => void
registerFileAction(action: FileAction): () => void
writeFile(path: string, content: string): Promise<void>
readRawFile(path: string, offset?: number, limit?: number, signal?: AbortSignal): Promise<ArrayBuffer>
}
interface PreviewProps {
preview: FilePreview // discriminated union: text / image / empty / binary / text-large / too-large
filePath: string // workspace-relative path
t: Translate // (key, params?) => string
activeView: 'preview' | 'source'
onViewSource?: () => void
}
FilePreview:
type FilePreview =
| { kind: 'text'; name: string; extension: string; content: string; size: number }
| { kind: 'image'; name: string; mime: string; dataUrl: string; size: number }
| { kind: 'empty'; name: string; size: 0 }
| { kind: 'binary'; name: string; size: number; bytes: string; truncated: boolean }
| { kind: 'text-large'; name: string; extension: string; size: number }
| { kind: 'too-large'; name: string; size: number }
Two things to know about routing:
registerPreviewkeys by lowercase extension (no leading dot).resolvePreviewfalls back to thebinarypreviewer for unregistered extensions.resolvePreviewFor(preview, ext, readRawFile?)routes kinds before consulting your component, but not always away from it.imageandemptyalways resolve to core's built-in previewers.text-large,binary, andtoo-largeresolve to the extension-registered component when one exists — fortext-large, an unregistered extension falls back to core's built-in paged text reader. So a text-oriented previewer registered for a text extension receivestext,text-large, and possiblybinary; add atext-largecase (or fall through to your unhandled path). The optional thirdreadRawFileargument pages large-text reads; pass your reader if you call this helper.
Minimal skeleton
A read-only previewer is tiny:
// src/client/index.ts
import type { ComponentType } from 'react'
import type { PreviewProps } from '@dsh-external/dsh-file-explorer/client'
export const inject = ['fileExplorer']
export function apply(ctx: {
fileExplorer: {
registerViewer(viewer: { id: string; label: string; exts: string[]; component: ComponentType<PreviewProps>; priority?: number }): () => void
}
effect(cb: () => (() => void), label?: string): void
}): void {
ctx.effect(() => {
const dispose = ctx.fileExplorer.registerViewer({
id: 'cif-viewer', // unique; 'auto' | 'text' | 'binary' are reserved
label: 'My CIF Preview',
exts: ['cif'],
component: CifPreview,
priority: 10,
})
return () => dispose()
}, 'my-preview: client')
}
function CifPreview(props: PreviewProps) {
if (props.preview.kind !== 'text') return null
// props.preview.content is the file text — parse and render it.
return renderStructure(props.preview.content)
}
Key points:
- Service name is
fileExplorer. Inject it withinject: ['fileExplorer']. - Priority — higher wins; built-ins use
0, use10to override. Equal priority: later registration wins. registerViewerregisters one named viewer across all yourextsin one call and returns a single disposer. Itslabelis the name shown in the file row's Open with… menu and the preview-panel switcher. Give it a stable, uniqueid;auto/text/binaryare reserved.registerViewershipped in core v0.9.0 — probe and degrade on older cores, falling back to aregisterPreviewloop (each extension becomes its own unnamed "Extension viewer"):
const register = typeof ctx.fileExplorer.registerViewer === 'function'
? (exts: string[], comp: ComponentType<PreviewProps>) =>
ctx.fileExplorer.registerViewer!({ id: 'cif-viewer', label: 'My CIF Preview', exts, component: comp, priority: 10 })
: (exts: string[], comp: ComponentType<PreviewProps>) => {
const disposers = exts.map((ext) => ctx.fileExplorer.registerPreview(ext, comp, 10))
return () => { for (const d of disposers) d() }
}
Editing with autosave
If your previewer edits, save through writeFile (see src/client/CodePreview.tsx here for the full pattern):
import { makeCodePreview } from './CodePreview.tsx'
export const inject = ['fileExplorer']
export function apply(ctx: { fileExplorer: FileExplorerService; effect(...): void }): void {
ctx.effect(() => {
const component = makeCodePreview(ctx.fileExplorer.writeFile)
const dispose = ctx.fileExplorer.registerViewer({
id: 'code', label: 'Code Editor', exts: CODE_EXTS, component, priority: 10,
})
return () => dispose()
})
}
writeFile(path, content) is a full-file UTF-8 write, resolved against the current session's workspace. A ComponentType<PreviewProps> receives filePath, so bridge the service method into the component via a factory closure (a module-level variable also works, but a factory is explicit and testable).
Bundling
A client-only plugin emits one browser bundle. This repo's tsdown.config.mjs is the template:
- Externals (
neverBundle):@deepseek-ai/dsh-client-runtime/client,react,react/jsx-runtime,react-dom,react-dom/client. These are provided by the platform module table. - Everything else is bundled (
alwaysBundle: true). CodeMirror and all@codemirror/*language packages must be inlined. - Single-file output: the client bundle uses the
window.__ModuleLoader__.load({ id, factory })banner/footer/intro, andcodeSplitting: falseso dynamicimport()s (e.g.@codemirror/language-data's lazy loads) inline into the onelib/client.js. - Node half: a minimal no-op
apply()insrc/index.tsso the host Loader can import the roster row.
package.json manifest:
{
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-runtime"] }
}
}
cordis.patch.yml inserts the roster row:
- insert:
- id: my-preview
name: '@dsh-external/dsh-file-explorer-preview-<domain>'
Internationalization
To localize your own UI copy, inject the locale service alongside fileExplorer, register a zh/en dictionary under your own namespace, and bind a translator:
export const inject = ['fileExplorer', 'locale']
export function apply(ctx: { fileExplorer: FileExplorerService; locale: LocaleService; effect(...): void }): void {
ctx.effect(() => {
const d1 = ctx.locale.register('my-preview', 'zh', { hello: '你好' })
const d2 = ctx.locale.register('my-preview', 'en', { hello: 'Hello' })
const t = ctx.locale.bind('my-preview')
// ... pass `t` into your component and dispose d1/d2 on cleanup
})
}
The PreviewProps.t you receive is bound to the file-explorer namespace (emptyFile/binary/tooLarge/…), not yours — bind your own for your own copy. See src/client/locale.ts here.
Adding a file-row action (optional)
The same service exposes registerFileAction, which adds an item to a file row's "···" menu:
ctx.fileExplorer.registerFileAction({
id: 'my-action',
label: 'My action',
run: ({ filePath, openFile }) => { /* ... */ },
})
Check the FileAction/FileActionHelpers types from @dsh-external/dsh-file-explorer/client for the exact shape.
Verifying
npm install
npm run check # tsc type check
npm test # vitest unit tests
npm run build # tsc + tsdown
dsh plugin --profile web add .
dsh web
Then open a matching file in the explorer's preview box and confirm your component renders (and, for editors, that edits save through writeFile).
Reference files in this repository
src/client/index.ts— named-viewer registration (registerViewerwith aregisterPreviewfallback) + style/locale setup.src/client/CodePreview.tsx— a CodeMirror editor with autosave, language loading, theme, and status bar.src/client/languages.ts—@codemirror/language-datamatching.src/client/locale.ts— zh/en dictionaries.tsdown.config.mjs— the client-bundle preset.tests/— examples of testing registration, matching, and the component.