Developing a dsh-file-explorer extension

September 1, 2026 · View on GitHub

中文 | English

This guide covers everything you need to build a preview plugin (or "extension") for dsh-file-explorer. Three reference implementations are available:

ExtensionWhat it previewsKey patterns
dsh-file-explorer-preview-codeCode files with syntax highlighting + editingwriteFile, text-only
dsh-file-explorer-preview-molstarProtein/small-molecule structures (.cif/.pdb/…)readRawFile, large + binary files
dsh-file-explorer-preview-sequenceDNA/RNA sequences (.gb/.fasta/…)readRawFile, large + binary files

Architecture

dsh-file-explorer (core)
  └─ client apply: ctx.reflect.provide('fileExplorer', {
       registerPreview, registerViewer, registerFileAction, writeFile, readRawFile
     })

dsh-file-explorer-preview-<domain> (your extension)
  └─ inject: ['fileExplorer', 'locale']
  └─ apply: ctx.fileExplorer.registerViewer({ id, label, exts: [...], component: MyPreview, priority: 10 })

The core registers built-in previewers at priority 0 (text, markdown, image, CSV, binary). Your extension registers at priority 10 to override them. Higher priority wins; equal priority: later registration wins.

The contract

Types come from the core package's ./client export:

import type {
  FileExplorerService,
  PreviewProps,
  ViewerRegistration,
  FileAction,
  FileActionHelpers,
  Translate,
} from '@dsh-external/dsh-file-explorer/client'

FileExplorerService

interface FileExplorerService {
  /** Register a preview component for a file extension (lowercase, no dot). */
  registerPreview(ext: string, component: ComponentType<PreviewProps>, priority?: number): () => void

  /**
   * Register one viewer across multiple extensions under a single identity
   * (one "Open with…" list entry per id). Reserved ids: 'auto' | 'text' | 'binary'.
   */
  registerViewer(viewer: { id: string; label: string; exts: string[]; component: ComponentType<PreviewProps>; priority?: number }): () => void

  /** Register a file-row action (appears in the row "···" menu). */
  registerFileAction(action: FileAction): () => void

  /** Write UTF-8 text to a workspace file. */
  writeFile(path: string, content: string): Promise<void>

  /**
   * Read raw bytes from a workspace file, with optional byte range.
   * @param path   Workspace-relative file path.
   * @param offset Byte offset (default 0).
   * @param limit  Max bytes to read (capped server-side by maxRawBytes, default 100 MiB).
   * @param signal Optional AbortSignal to cancel the read.
   */
  readRawFile(path: string, offset?: number, limit?: number, signal?: AbortSignal): Promise<ArrayBuffer>
}

PreviewProps and FilePreview

interface PreviewProps {
  preview: FilePreview
  filePath: string          // workspace-relative path
  t: Translate              // (key, params?) => string (bound to the file-explorer namespace)
  activeView: 'preview' | 'source'
  onViewSource?: () => void
}

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 }

How routing works

resolvePreviewFor(preview, ext, readRawFile?, viewerId?) decides which component renders a file. When the user has forced a viewer (Open with… or the panel switcher), viewerId selects that viewer directly — it wins over the priority routing below for any non-empty preview; empty always stays on the status page, and unknown ids fall through to the default routing:

preview.kind === 'image'   → your registered component, or ImagePreview (fallback)
preview.kind === 'empty'   → BinaryPreview (status page) — never overridden
preview.kind === 'text'    → your registered component, or TextPreview (fallback)
preview.kind === 'binary'  → your registered component, or BinaryPreview (fallback)
preview.kind === 'too-large' → your registered component, or BinaryPreview (fallback)
preview.kind === 'text-large' → your registered component, or the built-in paged text preview

The key change (dsh-file-explorer v0.1.0+): too-large and binary previews are now forwarded to registered extension components instead of being hard-routed to the status page. This means your extension can handle large files and binary formats by calling readRawFile. image previews are likewise forwarded to your registered component when one is registered for the file's extension; otherwise they fall back to the built-in ImagePreview.

  • If your extension is registered for extension cif, a too-large .cif file is routed to your component — you call readRawFile to get the bytes.
  • If no extension is registered for dat, a too-large .dat file still falls back to the built-in status page ("File too large to preview").

For a new plugin, prefer registerViewer: one call registers a single identity across every extension, and its label is the name shown in the "Open with…" list and the panel switcher:

ctx.effect(() => {
  const dispose = ctx.fileExplorer.registerViewer({
    id: 'molstar',            // unique; 'auto' | 'text' | 'binary' are reserved
    label: 'Mol* Structure',  // shown in Open with… and the panel switcher
    exts: ['cif', 'pdb', 'mmcif'],
    component: MolstarPreview,
    priority: 10,
  })
  return () => dispose()
})

Users pick your viewer per file via the row "···" menu → Open with…, or the viewer switcher in the preview-panel title bar. The choice is one-shot: plain open still resolves by priority.

Anonymous viewers — registerPreview

registerPreview(ext, component, priority?) registers an anonymous viewer for a single extension: it works, but the "Open with…" list cannot name it (it shows "Extension viewer"), and each extension becomes its own list entry. Use it only for a quick single-extension override without a label, or as a fallback for older cores.

registerViewer shipped in v0.9.0 — probe and degrade on older cores:

const register = typeof ctx.fileExplorer.registerViewer === 'function'
  ? (exts: string[], comp: ComponentType<PreviewProps>) =>
      ctx.fileExplorer.registerViewer!({ id: 'molstar', label: 'Mol* Structure', 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() }
    }

Minimal skeleton (read-only, text only)

// src/client/index.ts
import type { FileExplorerService, PreviewProps } from '@dsh-external/dsh-file-explorer/client'

export const inject = ['fileExplorer']

export function apply(ctx: {
  fileExplorer: FileExplorerService
  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 — call it in ctx.effect cleanup so HMR/unload removes the registration. Give it a stable, unique id; auto/text/binary are reserved.
  • registerPreview remains available for a quick anonymous single-extension override, or as the fallback for cores older than v0.9.0 (see the probe-and-degrade snippet above).

Handling large and binary files with readRawFile

For extensions that need to preview files larger than the core's 2 MiB text cap (maxTextBytes), or binary formats that the core returns as { kind: 'binary' }, use readRawFile.

Detecting readRawFile availability

readRawFile was added in dsh-file-explorer v0.1.0. Older versions of the core don't have it, so your extension should probe and degrade gracefully:

import type { FileExplorerService } from '@dsh-external/dsh-file-explorer/client'

type MyFileExplorer = FileExplorerService & {
  readRawFile?: (path: string, offset?: number, limit?: number, signal?: AbortSignal) => Promise<ArrayBuffer>
}

export function apply(ctx: { fileExplorer: MyFileExplorer; ... }): void {
  ctx.effect(() => {
    const readRaw = typeof ctx.fileExplorer.readRawFile === 'function'
      ? ctx.fileExplorer.readRawFile
      : undefined

    const component = makeMyPreview(readRaw, t)
    const dispose = ctx.fileExplorer.registerViewer({
      id: 'molstar', label: 'Mol* Structure', exts: EXTS, component, priority: 10,
    })
    return () => dispose()
  })
}

In the preview component

type ReadRaw = (path: string, offset?: number, limit?: number, signal?: AbortSignal) => Promise<ArrayBuffer>

function MyPreview({ preview, filePath, readRaw }: PreviewProps & { readRaw?: ReadRaw }) {
  const [data, setData] = useState<ArrayBuffer | null>(null)

  useEffect(() => {
    if (preview.kind === 'empty') return

    // Small text files: use preview.content directly
    if (preview.kind === 'text') {
      parseAndRender(preview.content)
      return
    }

    // Large or binary files: fetch raw bytes
    if (preview.kind === 'too-large' || preview.kind === 'binary') {
      if (!readRaw) {
        showError('File too large — upgrade dsh-file-explorer to preview this file')
        return
      }
      readRaw(filePath).then(setData).catch(handleError)
      return
    }
  }, [preview, filePath])
}

The molstar plugin's MolstarPreview.tsx is the reference implementation of this pattern: it checks preview.kind, uses content for text, and calls readRaw(filePath) for too-large/binary.

Using byte ranges

For very large files you can read only the header/metadata first:

// Read the first 4 KiB to inspect a file header
const header = await readRaw(filePath, 0, 4096)

// Read bytes 1 MiB to 2 MiB
const chunk = await readRaw(filePath, 1048576, 1048576)

The limit parameter is capped server-side by maxRawBytes (default 100 MiB).

Editing with writeFile

Pass writeFile into your component via a factory closure:

export function apply(ctx: { fileExplorer: FileExplorerService; ... }): void {
  ctx.effect(() => {
    const component = makeMyPreview(ctx.fileExplorer.writeFile, t)
    const dispose = ctx.fileExplorer.registerViewer({
      id: 'code', label: 'Code', exts: EXTS, component, priority: 10,
    })
    return () => dispose()
  })
}

Inside the component, call writeFile(filePath, content) to save. The code plugin's CodePreview.tsx is the reference: autosave 500ms after the last keystroke, plus Ctrl/Cmd+S immediate save.

Internationalization

Inject locale alongside fileExplorer, register your own zh/en dictionaries, and bind a translator:

export const inject = ['fileExplorer', 'locale']

export function apply(ctx: {
  fileExplorer: FileExplorerService
  locale: {
    register(ns: string, locale: string, dict: Record<string, string>): () => void
    bind(ns: string): Translate
  }
  effect(cb: () => (() => void), label?: string): 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')
    const component = makeMyPreview(t)
    const dispose = ctx.fileExplorer.registerViewer({
      id: 'my-preview', label: 'My Preview', exts: EXTS, component, priority: 10,
    })
    return () => {
      dispose()
      d1(); d2()
    }
  })
}

Note: PreviewProps.t is bound to the file-explorer namespace (emptyFile/tooLarge/ hexTruncated/…). Bind your own namespace for your own copy.

CSS injection

External plugins can't import CSS modules. Inject styles via a <style> tag:

export function apply(ctx: ClientContext): void {
  const styleEl = document.createElement('style')
  styleEl.setAttribute('data-my-preview-style', '')
  styleEl.textContent = MY_CSS
  document.head.appendChild(styleEl)

  ctx.effect(() => {
    // ... registrations ...
    return () => {
      // ... dispose registrations ...
      styleEl.remove()
    }
  })
}

Adding a file-row action

import type { FileAction } from '@dsh-external/dsh-file-explorer/client'

ctx.fileExplorer.registerFileAction({
  id: 'my-action',
  label: (t) => t('myAction'),
  appliesTo: 'both',
  onSelect: (entry, helpers) => {
    // entry: { name, path, kind }
    // helpers.openFile(path): opens a file in the preview box
    // helpers.promptRename(entry) / promptDelete(entry) / promptMove(entry) /
    //   promptCopy(entry): open the built-in rename/delete/move/copy dialogs
    // helpers.promptNewFile(parentDir) / promptNewFolder(parentDir): open the
    //   built-in new-file/new-folder dialogs for a directory
  },
})

Bundling

Your extension is a client-only plugin. The host half (src/index.ts) is a minimal no-op so the host Loader can import the roster entry.

tsdown.config.mjs

const id = '@dsh-external/dsh-file-explorer-preview-<domain>'
const platformModules = [
  '@deepseek-ai/dsh-client-runtime/client',
  'react',
  'react/jsx-runtime',
  'react-dom',
  'react-dom/client',
]

export default [{
  // Host half: minimal no-op
  entry: ['lib/types/index.js'],
  outDir: 'lib',
  format: ['esm'],
  platform: 'node',
  target: 'es2024',
  fixedExtension: false,
  dts: false,
  clean: false,
}, {
  // Browser half: your client bundle
  entry: { client: 'src/client/index.ts' },
  outDir: 'lib',
  format: 'cjs',
  platform: 'browser',
  target: 'es2024',
  dts: false,
  sourcemap: true,
  clean: false,
  deps: {
    neverBundle: platformModules,
    alwaysBundle: mod => platformModules.includes(mod) ? undefined : true,
    onlyBundle: false,
  },
  outputOptions: {
    entryFileNames: 'client.js',
    codeSplitting: false,
    banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
    footer: 'return module.exports; } });',
    intro: 'var module = { exports: {} };\nvar exports = module.exports;',
  },
}]

Key points:

  • neverBundle: react, react-dom, and the DSH client runtime are provided by the platform — don't bundle them.
  • alwaysBundle: everything else (your code, your dependencies, and any third-party libraries like molstar, codemirror, seqviz) must be inlined.
  • codeSplitting: false: ensures dynamic import() calls are inlined into a single client.js file.
  • Banner/footer: wraps your bundle in window.__ModuleLoader__.load() so the DSH runtime can register it.
  • Intro: provides a minimal module.exports shim for CJS interop inside the bundle.

package.json

{
  "name": "@dsh-external/dsh-file-explorer-preview-<domain>",
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-runtime"] }
  }
}

cordis.patch.yml

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

src/index.ts (host half — no-op)

export const inject: string[] = []
export function apply(): void {}
src/
  index.ts              # host half: no-op apply()
  protocol.ts           # shared constants (extension list, plugin id)
  client/
    index.ts            # client apply: registrations, style/locale setup
    MyPreview.tsx        # your preview component
    locale.ts           # zh/en dictionaries
    styles.ts           # CSS string injected as <style> tag
tests/
  *.spec.ts             # vitest specs
lib/                    # built output (committed)
cordis.patch.yml        # roster insertion
tsdown.config.mjs       # bundle config

Development workflow

npm install
npm run check     # tsc --noEmit
npm test          # vitest
npm run build     # tsc + tsdown → lib/
dsh plugin --profile web add .
dsh web

After npm run build, hard-refresh the browser (Ctrl/Cmd+Shift+R): dsh web may serve a cached plugin bundle.

Reference files

WhatWhere
FileExplorerService contractdsh-file-explorersrc/client/contract.ts
Preview routing logicdsh-file-explorersrc/client/preview/index.ts
Code plugin (text + editing)dsh-file-explorer-preview-codesrc/client/index.ts, CodePreview.tsx
Molstar plugin (large + binary)dsh-file-explorer-preview-molstarsrc/client/index.ts, MolstarPreview.tsx
Sequence plugin (large + binary)dsh-file-explorer-preview-sequencesrc/client/index.ts, SequencePreview.tsx
Bundle config templatedsh-file-explorer-preview-molstartsdown.config.mjs