# 开发一个 `dsh-file-explorer` 预览插件 [English](developing-preview-plugins.md) | 中文 本指南说明如何为 [dsh-file-explorer](https://github.com/wolfsonliu/dsh-file-explorer) 开发一个贡献预览器(或编辑器)的插件,并以**本仓库**([dsh-file-explorer-preview-code](https://github.com/wolfsonliu/dsh-file-explorer-preview-code))作为参考实现。 ## 概览 `dsh-file-explorer` 按扩展名渲染文件预览。其内置预览器都以优先级 `0` 注册: - `text`(纯文本 `
`,覆盖一批代码扩展名)、
- `markdown`(`.md`/`.mdx`)、`image`(`.png`/`.jpg`/…)、`binary`(文件信息)。
外部插件可通过注入 `fileExplorer` cordis 服务并调用 `registerViewer(...)`(推荐——一个命名 viewer 覆盖多个扩展名)或 `registerPreview(ext, component, priority)`(匿名、单个扩展名覆盖)来**覆盖**任意内置预览,或为全新扩展名新增预览器。优先级数值越大越优先;本插件用 `10` 覆盖内置纯文本预览。
```
@dsh-external/dsh-file-explorer(核心)
└─ 客户端 apply:ctx.reflect.provide('fileExplorer', { registerPreview, registerViewer, registerFileAction, writeFile, readRawFile })
@dsh-external/dsh-file-explorer-preview-(你的插件)
└─ inject: ['fileExplorer', 'locale']
└─ apply:ctx.fileExplorer.registerViewer({ id, label, exts, component, priority: 10 })
```
## 契约
类型来自核心包的 `./client` 导出:
```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 // 判别式联合:text / image / empty / binary / text-large / too-large
filePath: string // 工作区相对路径
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 }
```
关于路由的两点须知:
1. **`registerPreview` 以小写扩展名为键**(不含前导点)。`resolvePreview` 对未注册扩展名回退到 `binary` 预览器。
2. **`resolvePreviewFor(preview, ext, readRawFile?)` 会先按 kind 路由,但并非总是路由到别处。** `image` 与 `empty` 始终解析到核心内置预览器。`text-large`、`binary` 与 `too-large` 在存在扩展名注册组件时解析到该组件(对 `text-large`,未注册扩展名会回退到核心内置的分页文本读取器)。因此,为文本扩展名注册的面向文本预览器会收到 `text`、`text-large`,有时还会收到 `binary`;请添加 `text-large` 分支(或回退到你的未处理路径)。可选第三参数 `readRawFile` 用于分页读取大文本;若你调用此助手,请传入你的读取器。
## 最小骨架
只读预览器非常小:
```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', // 唯一;'auto' | 'text' | 'binary' 为保留 id
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 即文件文本——解析并渲染。
return renderStructure(props.preview.content)
}
```
要点:
- **服务名**为 `fileExplorer`,用 `inject: ['fileExplorer']` 注入。
- **优先级**:数值越大越优先;内置为 `0`,用 `10` 覆盖。同优先级后注册者胜。
- **`registerViewer`** 一次调用即为所有 `exts` 注册一个命名 viewer 并返回单个 disposer。其 `label` 会显示在文件行「···」菜单的 **Open with…** 与预览面板切换器中。请使用稳定且唯一的 `id`;`auto`/`text`/`binary` 为保留 id。
- **`registerViewer` 于核心 v0.9.0 引入**——对旧核心需探测并降级,回退到 `registerPreview` 循环(每个扩展名各成一个无名的「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() }
}
```
## 带自动保存的编辑
如果预览器需要编辑,通过 `writeFile` 保存(完整模式见本仓库 `src/client/CodePreview.tsx`):
```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)` 是对工作区文件的整文件 UTF-8 写入(相对当前会话工作区解析)。`ComponentType` 会收到 `filePath`,因此用工厂闭包把服务方法桥接进组件(模块级变量亦可,但工厂更显式、更易测试)。
## 打包
纯客户端插件只产出一个浏览器 bundle。本仓库的 `tsdown.config.mjs` 即模板:
- **外部依赖(`neverBundle`)**:`@deepseek-ai/dsh-client-runtime/client`、`react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`。这些由平台模块表提供。
- **其余全部打进 bundle**(`alwaysBundle: true`)。CodeMirror 及所有 `@codemirror/*` 语言包必须内联。
- **单文件输出**:客户端 bundle 使用 `window.__ModuleLoader__.load({ id, factory })` 的 banner/footer/intro,并设置 `codeSplitting: false`,让动态 `import()`(如 `@codemirror/language-data` 的懒加载)内联进单个 `lib/client.js`。
- **宿主半部**:`src/index.ts` 里放一个最小 no-op `apply()`,以便宿主 Loader 能导入该 roster 行。
`package.json` manifest:
```json
{
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-runtime"] }
}
}
```
`cordis.patch.yml` 插入 roster 行:
```yaml
- insert:
- id: my-preview
name: '@dsh-external/dsh-file-explorer-preview-'
```
## 国际化
要本地化你自己的文案,请在 `fileExplorer` 之外再注入 `locale` 服务,用自己的命名空间注册 `zh`/`en` 字典,并绑定翻译器:
```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')
// ... 把 t 传入组件,并在清理时 dispose d1/d2
})
}
```
你收到的 `PreviewProps.t` 绑定在 *file-explorer* 命名空间(`emptyFile`/`binary`/`tooLarge`/…),而非你自己的——请为自己的文案绑定自己的命名空间。见本仓库 `src/client/locale.ts`。
## 添加文件行操作(可选)
同一服务还暴露 `registerFileAction`,可在文件行「···」菜单中加一项:
```typescript
ctx.fileExplorer.registerFileAction({
id: 'my-action',
label: '我的操作',
run: ({ filePath, openFile }) => { /* ... */ },
})
```
精确结构见 `@dsh-external/dsh-file-explorer/client` 的 `FileAction`/`FileActionHelpers` 类型。
## 验证
```sh
npm install
npm run check # tsc 类型检查
npm test # vitest 单元测试
npm run build # tsc + tsdown
dsh plugin --profile web add .
dsh web
```
然后在浏览器的预览框里打开对应文件,确认你的组件正常渲染(对编辑器而言,确认编辑内容能通过 `writeFile` 保存)。
## 本仓库的参考文件
- `src/client/index.ts` — 命名 viewer 注册(`registerViewer`,带回退 `registerPreview`)+ 样式/locale 装配。
- `src/client/CodePreview.tsx` — 带自动保存、语言加载、主题与状态栏的 CodeMirror 编辑器。
- `src/client/languages.ts` — `@codemirror/language-data` 匹配。
- `src/client/locale.ts` — 中英文典。
- `tsdown.config.mjs` — 客户端 bundle 预设。
- `tests/` — 注册、匹配与组件的测试示例。