# Developing a `dsh-file-explorer` preview plugin [中文](developing-preview-plugins.zh.md) | English This guide shows how to build a plugin that contributes a previewer (or editor) to [dsh-file-explorer](https://github.com/wolfsonliu/dsh-file-explorer), using **this repository** ([dsh-file-explorer-preview-code](https://github.com/wolfsonliu/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 `
`) for a list of code extensions,
- `markdown` (`.md`/`.mdx`), `image` (`.png`/`.jpg`/…), and `binary` (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- (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:
```typescript
import type {
FileExplorerService,
PreviewProps,
ViewerRegistration,
Translate,
} from '@dsh-external/dsh-file-explorer/client'
```
```typescript
interface FileExplorerService {
registerPreview(ext: string, component: ComponentType, priority?: number): () => void
registerViewer(viewer: ViewerRegistration): () => void
registerFileAction(action: FileAction): () => void
writeFile(path: string, content: string): Promise
readRawFile(path: string, offset?: number, limit?: number, signal?: AbortSignal): Promise
}
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`:
```typescript
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:
1. **`registerPreview` keys by lowercase extension** (no leading dot). `resolvePreview` falls back to the `binary` previewer for unregistered extensions.
2. **`resolvePreviewFor(preview, ext, readRawFile?)` routes kinds before consulting your component, but not always away from it.** `image` and `empty` always resolve to core's built-in previewers. `text-large`, `binary`, and `too-large` resolve to the extension-registered component when one exists — for `text-large`, an unregistered extension falls back to core's built-in paged text reader. So a text-oriented previewer registered for a text extension receives `text`, `text-large`, and possibly `binary`; add a `text-large` case (or fall through to your unhandled path). The optional third `readRawFile` argument pages large-text reads; pass your reader if you call this helper.
## Minimal skeleton
A read-only previewer is tiny:
```typescript
// 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; 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 with `inject: ['fileExplorer']`.
- **Priority** — higher wins; built-ins use `0`, use `10` to override. Equal priority: later registration wins.
- **`registerViewer`** registers one named viewer across all your `exts` in one call and returns a single disposer. Its `label` is the name shown in the file row's **Open with…** menu and the preview-panel switcher. Give it a stable, unique `id`; `auto`/`text`/`binary` are reserved.
- **`registerViewer` shipped in core v0.9.0** — probe and degrade on older cores, falling back to a `registerPreview` loop (each extension becomes its own unnamed "Extension viewer"):
```typescript
const register = typeof ctx.fileExplorer.registerViewer === 'function'
? (exts: string[], comp: ComponentType) =>
ctx.fileExplorer.registerViewer!({ id: 'cif-viewer', label: 'My CIF Preview', exts, component: comp, priority: 10 })
: (exts: string[], comp: ComponentType) => {
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):
```typescript
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` 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, and `codeSplitting: false` so dynamic `import()`s (e.g. `@codemirror/language-data`'s lazy loads) inline into the one `lib/client.js`.
- **Node half**: a minimal no-op `apply()` in `src/index.ts` so the host Loader can import the roster row.
`package.json` manifest:
```json
{
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-runtime"] }
}
}
```
`cordis.patch.yml` inserts the roster row:
```yaml
- insert:
- id: my-preview
name: '@dsh-external/dsh-file-explorer-preview-'
```
## 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:
```typescript
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:
```typescript
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
```sh
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 (`registerViewer` with a `registerPreview` fallback) + style/locale setup.
- `src/client/CodePreview.tsx` — a CodeMirror editor with autosave, language loading, theme, and status bar.
- `src/client/languages.ts` — `@codemirror/language-data` matching.
- `src/client/locale.ts` — zh/en dictionaries.
- `tsdown.config.mjs` — the client-bundle preset.
- `tests/` — examples of testing registration, matching, and the component.