开发一个 dsh-file-explorer 预览插件

September 1, 2026 · View on GitHub

English | 中文

本指南说明如何为 dsh-file-explorer 开发一个贡献预览器(或编辑器)的插件,并以本仓库dsh-file-explorer-preview-code)作为参考实现。

概览

dsh-file-explorer 按扩展名渲染文件预览。其内置预览器都以优先级 0 注册:

  • text(纯文本 <pre><code>,覆盖一批代码扩展名)、
  • 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-<domain>(你的插件)
  └─ inject: ['fileExplorer', 'locale']
  └─ apply:ctx.fileExplorer.registerViewer({ id, label, exts, component, priority: 10 })

契约

类型来自核心包的 ./client 导出:

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      // 判别式联合:text / image / empty / binary / text-large / too-large
  filePath: string          // 工作区相对路径
  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 }

关于路由的两点须知:

  1. registerPreview 以小写扩展名为键(不含前导点)。resolvePreview 对未注册扩展名回退到 binary 预览器。
  2. resolvePreviewFor(preview, ext, readRawFile?) 会先按 kind 路由,但并非总是路由到别处。 imageempty 始终解析到核心内置预览器。text-largebinarytoo-large 在存在扩展名注册组件时解析到该组件(对 text-large,未注册扩展名会回退到核心内置的分页文本读取器)。因此,为文本扩展名注册的面向文本预览器会收到 texttext-large,有时还会收到 binary;请添加 text-large 分支(或回退到你的未处理路径)。可选第三参数 readRawFile 用于分页读取大文本;若你调用此助手,请传入你的读取器。

最小骨架

只读预览器非常小:

// 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',       // 唯一;'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… 与预览面板切换器中。请使用稳定且唯一的 idauto/text/binary 为保留 id。
  • registerViewer 于核心 v0.9.0 引入——对旧核心需探测并降级,回退到 registerPreview 循环(每个扩展名各成一个无名的「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() }
    }

带自动保存的编辑

如果预览器需要编辑,通过 writeFile 保存(完整模式见本仓库 src/client/CodePreview.tsx):

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<PreviewProps> 会收到 filePath,因此用工厂闭包把服务方法桥接进组件(模块级变量亦可,但工厂更显式、更易测试)。

打包

纯客户端插件只产出一个浏览器 bundle。本仓库的 tsdown.config.mjs 即模板:

  • 外部依赖(neverBundle@deepseek-ai/dsh-client-runtime/clientreactreact/jsx-runtimereact-domreact-dom/client。这些由平台模块表提供。
  • 其余全部打进 bundlealwaysBundle: 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:

{
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-runtime"] }
  }
}

cordis.patch.yml 插入 roster 行:

- insert:
    - id: my-preview
      name: '@dsh-external/dsh-file-explorer-preview-<domain>'

国际化

要本地化你自己的文案,请在 fileExplorer 之外再注入 locale 服务,用自己的命名空间注册 zh/en 字典,并绑定翻译器:

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,可在文件行「···」菜单中加一项:

ctx.fileExplorer.registerFileAction({
  id: 'my-action',
  label: '我的操作',
  run: ({ filePath, openFile }) => { /* ... */ },
})

精确结构见 @dsh-external/dsh-file-explorer/clientFileAction/FileActionHelpers 类型。

验证

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/ — 注册、匹配与组件的测试示例。