feat: 客户端懒加载分块 — 重依赖(Univer/xterm/CodeMirror/Office)拆独立 bundle 按需下发(/sidebar/bundle 路由 + __dshChunks__ 注册表),核心启动仅 ~325KB

This commit is contained in:
Menghuan1918
2026-08-12 18:19:44 +08:00
parent bf54a60e37
commit bab659f8bd
29 changed files with 1691 additions and 345 deletions
+130
View File
@@ -0,0 +1,130 @@
/**
* Lazy chunk route: serves the client bundle's chunk scripts
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
* cannot serve arbitrary file names, so the plugin serves its own split
* bundles (lib/client-<name>.js) here; the client injects the script on
* first use of the feature that needs it (see src/client/chunk-loader.ts).
*
* Caching contract: every response carries `cache-control: no-cache` plus an
* ETag (content hash, memoized per file by mtime/size) and honors
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
* re-downloading multi-MB chunks that did not change (page refresh, HMR
* re-activation). Same browser-trust fence as every other /sidebar route;
* only allowlisted chunk names are servable (no path traversal).
*/
import { createHash } from 'node:crypto'
import { stat, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Context } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
interface ChunkEtag {
mtimeMs: number
size: number
etag: string
}
/** ETag memo: recompute the content hash only when the file's stat changed. */
const etags = new Map<string, ChunkEtag>()
/**
* The chunk file's ETag (quoted hash), or undefined when the file is
* missing. Hash is recomputed only when mtime/size changed (hashing a
* multi-MB chunk per request is wasteful).
*/
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
const path = join(chunkDir, `client-${name}.js`)
const key = `${chunkDir}:${name}`
try {
const info = await stat(path)
const memo = etags.get(key)
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
return memo.etag
}
const etag = `"${shortHash(await readFile(path))}"`
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
return etag
} catch {
return undefined
}
}
/**
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
* trust check every /sidebar route applies; `chunkDir` is the directory the
* chunk scripts live in (overridable for tests).
*/
export function createBundleRouteHandler(
fence: (req: IncomingMessage) => boolean,
chunkDir: string = LIB_DIR,
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
return async (req, res): Promise<void> => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
const name = match?.[1] as ChunkName | undefined
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
res.writeHead(404)
res.end('not found')
return
}
const etag = await etagOf(name, chunkDir)
if (etag === undefined) {
// Registered name but unreadable (bundle not built yet): loud 404.
res.writeHead(404)
res.end('not found')
return
}
if (req.headers['if-none-match'] === etag) {
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
// multi-MB scripts on page refresh / HMR re-activation.
res.writeHead(304, { 'cache-control': 'no-cache', etag })
res.end()
return
}
try {
const body = await readFile(join(chunkDir, `client-${name}.js`))
res.writeHead(200, {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
etag,
})
res.end(body)
} catch {
// Read raced a delete/rebuild between the stat and the read.
res.writeHead(404)
res.end('not found')
}
}
}
/** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
return ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/bundle',
handler: createBundleRouteHandler(fence),
})
}
+29 -4
View File
@@ -13,14 +13,41 @@ import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { TerminalView } from '../TerminalView.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts'
import type { TabDescriptor } from '../service.ts'
/**
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
* only when a terminal tab is first opened (see chunk-loader.ts). The
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
* calls it as a plain function.
*
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
* descriptor maps it explicitly; a bare pass-through would leave tabId
* undefined and TerminalView's isAgentTabId(tabId) would crash on
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
*/
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
'terminal',
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
)
/** The terminal view's props (mirror of TerminalView's own signature). */
interface TerminalViewProps {
scope: SessionScope
tabId: string
store: SidebarStore
}
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
@@ -133,9 +160,7 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
},
{
id: 'browser',
+28 -16
View File
@@ -6,15 +6,21 @@
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did.
*
* The heavy viewers (docx/xlsx/pptx and the CodeMirror-backed
* markdown/html/code) render through {@link lazyChunkComponent} wrappers —
* their libraries are fetched only when such a file is first opened (see
* chunk-loader.ts). The descriptor metadata (id/exts/priority/detect) is
* identical either way, so matching semantics and external-plugin
* overrides are unaffected; the `component` wrapper keeps the descriptor
* contract `(props) => ReactNode`.
*
* Every viewer carries the declarative settings-surface fields — `title`
* and `icon` — so the Side card settings page can render the enable/disable
* inventory without hardcoding (eating our own dogfood).
*/
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocxView, XlsxView } from '../office-view.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { PdfView } from '../PdfView.tsx'
import { PptxView } from '../PptxView.tsx'
import { TextEditor } from '../TextEditor.tsx'
import { BinaryDownload } from '../binary-download.tsx'
import {
IconImageOutline16,
@@ -25,10 +31,22 @@ import {
IconPptxOutline16,
IconHtmlOutline16,
} from '../icons.tsx'
import type { FileViewerDescriptor } from '../service.ts'
import type { ComponentType } from 'react'
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/**
* Lazy wrappers over the chunk-resident viewer components. The `pick`
* functions are module-level (stable identity — the wrapper effect depends
* on them); the cast bridges the chunk exports record to the descriptor
* prop shape (the views read only their own subset of FileViewerProps).
*/
const LazyDocx = lazyChunkComponent<FileViewerProps>('docx', (mod) => mod.DocxView as ComponentType<FileViewerProps> | undefined)
const LazyXlsx = lazyChunkComponent<FileViewerProps>('xlsx', (mod) => mod.XlsxView as ComponentType<FileViewerProps> | undefined)
const LazyPptx = lazyChunkComponent<FileViewerProps>('pptx', (mod) => mod.PptxView as ComponentType<FileViewerProps> | undefined)
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
/** The 9 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
@@ -60,9 +78,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconDocxOutline16 size={size} />,
exts: ['docx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<DocxView scope={scope} path={path} title={title} />
),
component: (props) => <LazyDocx {...props} />,
},
{
id: 'xlsx',
@@ -70,9 +86,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconXlsxOutline16 size={size} />,
exts: ['xlsx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<XlsxView scope={scope} path={path} title={title} />
),
component: (props) => <LazyXlsx {...props} />,
},
{
id: 'pptx',
@@ -80,9 +94,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconPptxOutline16 size={size} />,
exts: ['pptx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PptxView scope={scope} path={path} title={title} />
),
component: (props) => <LazyPptx {...props} />,
},
{
id: 'markdown',
@@ -90,7 +102,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
exts: ['md', 'markdown'],
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'html',
@@ -112,7 +124,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
}],
},
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'code',
@@ -121,7 +133,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'binary-download',
+196
View File
@@ -0,0 +1,196 @@
/**
* Lazy chunk loader for the client bundle. The heavy preview/terminal
* libraries (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — the
* office/terminal/editor stacks, tens of MB) live in separate build-time
* bundles (`lib/client-<name>.js`) fetched only on first use of the feature
* that needs them, so startup downloads/parses only the ~1MB core bundle.
*
* How a chunk script works (see tsdown.config.ts chunkBundle):
*
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
*
* The script registers its factory on a plugin-owned global registry (NOT
* through window.__ModuleLoader__.load — the module loader's import() only
* resolves seed words, shell-own modules, registered factories, and boot
* graph rows; a chunk id is none of those, so resolution would be version-
* dependent). Materialization is plugin-owned:
*
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
* script; the official /plugins/<id>/client.js route cannot serve
* arbitrary file names, so the plugin's own host route serves the chunks),
* 2. read the factory from the global registry,
* 3. call it with a require that resolves the platform externals through
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
* the module system that is stable across versions.
*
* Caching contract (three layers, each with a failure path):
* - In-memory: one in-flight promise per chunk, memoized until
* {@link resetChunks}; a failed load removes its entry so the next call
* retries from scratch.
* - Script execution: each re-execution overwrites the global registry slot
* (assignment, never registration) — no "duplicate factory registration"
* class of errors; a failed materialization clears the cache so the retry
* re-injects and re-executes.
* - HTTP: the bundle route revalidates every request (`cache-control:
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
* re-activations never re-download a multi-MB chunk that did not change.
*
* HMR: each plugin activation calls {@link resetChunks}, which drops the
* in-memory cache (and any test registry), so a hot-reloaded core bundle
* re-fetches and re-executes the current chunk scripts on the next lazy
* open. Chunk-only source edits still need a manual page refresh (the HMR
* poll watches only client.js).
*/
export type ChunkName = 'docx' | 'xlsx' | 'pptx' | 'terminal' | 'editor'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
/**
* The platform externals a chunk bundle may require (mirror of
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
* external and the loader resolves them here). A superset is safe: the
* require only answers what the chunk actually asks for.
*/
export const CHUNK_EXTERNALS: readonly string[] = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
]
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
interface ChunkModuleSystem {
import(specifier: string): Promise<unknown>
}
/** Resolve the shell-installed module system (set before any plugin activates). */
function moduleSystem(): ChunkModuleSystem | undefined {
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
}
/** The plugin-owned chunk factory registry the chunk scripts populate. */
interface ChunkRegistry {
[name: string]: ChunkFactory | undefined
}
function chunkRegistry(): ChunkRegistry {
const g = globalThis as { __dshChunks__?: ChunkRegistry }
return g.__dshChunks__ ??= {}
}
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
export type ChunkScriptLoader = (src: string) => Promise<void>
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
const el = document.createElement('script')
el.async = true
el.src = src
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
}, { once: true })
document.head.append(el)
})
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
scriptLoader = loader ?? defaultScriptLoader
}
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
testLoaders.set(name, loader)
}
/** Memoized externals require, resolved once per page from the seed table. */
let externalsRequire: ((spec: string) => unknown) | undefined
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
if (externalsRequire !== undefined) return externalsRequire
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
// the runtime/client exemption row) stays unresolved until a chunk
// actually requires it — only then it is a loud error.
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
try {
return [spec, await modules.import(spec)] as const
} catch {
return [spec, undefined] as const
}
}))
const table = new Map<string, unknown>(entries)
externalsRequire = (spec: string): unknown => {
if (!table.has(spec)) {
// Single quotes: the bundle-consistency scan regexes for require("...")
// lexical calls — a double-quoted literal here would trip it.
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
}
return table.get(spec)
}
return externalsRequire
}
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
const cache = new Map<ChunkName, Promise<ChunkExports>>()
/**
* Load (once) and materialize a lazy chunk, returning its module exports.
* Concurrent callers share one in-flight load; a failure clears the cache
* entry so the next call retries (the script re-executes and overwrites its
* global registry slot — assignments are idempotent).
* @param name - the chunk to load.
*/
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
const cached = cache.get(name)
if (cached !== undefined) return cached
const task = (async (): Promise<ChunkExports> => {
const test = testLoaders.get(name)
if (test !== undefined) return test()
const modules = moduleSystem()
if (modules === undefined) {
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
}
await scriptLoader(CHUNK_URL(name))
const factory = chunkRegistry()[name]
if (typeof factory !== 'function') {
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
}
const require = await buildExternalsRequire(modules)
return factory(require)
})()
cache.set(name, task)
void task.catch(() => { cache.delete(name) })
return task
}
/**
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
* in-memory cache and any test-registry entries, so the next lazy open
* re-fetches and re-executes the current chunk scripts (the registry slots
* are overwritten by the re-execution — no cleanup needed).
*/
export function resetChunks(): void {
cache.clear()
testLoaders.clear()
externalsRequire = undefined
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Lazy chunk entry: the .docx preview (docx-preview + jszip). Built as
* `lib/client-docx.js` and registered under `dsh-better-sidebar/docx` —
* fetched only when a .docx is first opened (see chunk-loader.ts and
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
* from the core bundle: it pulls the office library into the startup path.
*/
export { DocxView } from '../docx-view.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
* language packages). Built as `lib/client-editor.js` and registered under
* `dsh-better-sidebar/editor` — fetched only when a text file is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls CodeMirror into
* the startup path.
*/
export { TextEditor } from '../TextEditor.tsx'
+8
View File
@@ -0,0 +1,8 @@
/**
* Lazy chunk entry: the .pptx preview (pptx-renderer). Built as
* `lib/client-pptx.js` and registered under `dsh-better-sidebar/pptx` —
* fetched only when a .pptx is first opened (see chunk-loader.ts and
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
* from the core bundle: it pulls the renderer into the startup path.
*/
export { PptxView } from '../PptxView.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
* `lib/client-terminal.js` and registered under
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls xterm (and its
* stylesheet) into the startup path.
*/
export { TerminalView } from '../TerminalView.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the .xlsx preview (Univer family + SheetJS, several MB).
* Built as `lib/client-xlsx.js` and registered under
* `dsh-better-sidebar/xlsx` — fetched only when a .xlsx is first opened
* (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls Univer into the
* startup path.
*/
export { XlsxView } from '../xlsx-view.tsx'
+113
View File
@@ -0,0 +1,113 @@
/**
* The .docx preview component: renders via docx-preview (preserved
* styles/images/tables). Lives in its own module (split out of the former
* office-view.tsx) so it builds as a standalone lazy chunk
* (`lib/client-docx.js`) — the library is loaded only when a .docx is first
* opened, and never pulls in the Univer/xlsx stack.
*
* Errors degrade to the shared download-button affordance, so a corrupted /
* encrypted / oversized file always leaves the user with a way to get it.
*/
import { useEffect, useRef, useState } from 'react'
import { mediaUrl } from './api.ts'
import { t } from './locales.ts'
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
import css from './sidebar.module.css'
/**
* Render a .docx file via docx-preview. The library renders into a container
* div (no canvas); images and styles are inlined. Unmounting clears the
* container's innerHTML — docx-preview has no dispose API, but tearing down
* the DOM is enough.
*/
export function DocxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const viewportRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
const [zoom, setZoom] = useState(100)
useEffect(() => {
let cancelled = false
const container = viewportRef.current
const wrap = wrapRef.current
if (container === null || wrap === null) return
setZoom(100)
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// docx-preview ships its own CSS through the className option; the
// wrapper div scopes its render output.
const { renderAsync } = await import('docx-preview')
await renderAsync(buf, wrap, undefined, {
className: 'docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
experimental: false,
})
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Tear down the rendered DOM so a reopen starts clean.
if (wrap !== null) wrap.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
useEffect(() => {
const viewport = viewportRef.current
if (viewport === null) return
const onWheel = (event: WheelEvent): void => {
if (!event.altKey) return
event.preventDefault()
const delta = event.deltaY < 0 ? 10 : -10
setZoom(current => Math.max(50, Math.min(200, current + delta)))
}
viewport.addEventListener('wheel', onWheel, { passive: false })
return () => { viewport.removeEventListener('wheel', onWheel) }
}, [])
return (
<div className={css.editorDocx}>
<div className={css.editorDocxViewport} ref={viewportRef}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
{load.status !== 'error' && (
<div
className={css.editorDocxWrap}
ref={wrapRef}
aria-label={title}
style={{ zoom: zoom / 100 }}
/>
)}
</div>
<div className={css.editorDocxZoom}>
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
<input
className={css.editorDocxZoomRange}
type="range"
min={50}
max={200}
step={10}
value={zoom}
aria-label={t('zoom')}
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
/>
<span className={css.editorDocxZoomValue}>{zoom}%</span>
</div>
</div>
)
}
+5
View File
@@ -13,6 +13,7 @@ import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
@@ -103,6 +104,10 @@ export function apply(ctx: Context): void {
}
}
try {
// Fresh chunk state for this activation: invalidate any chunk factories
// registered by a previous fiber (HMR) and drop the in-memory load cache
// so the next lazy open re-fetches the current chunk scripts.
resetChunks()
ctx.effect(() => {
let disposed = false
let root: Root | undefined
+89
View File
@@ -0,0 +1,89 @@
/**
* Lazy chunk view wrapper: mounts a component that lives in a lazy chunk,
* showing a loading placeholder while the chunk script loads and an error +
* retry affordance on failure. Used by the built-in tab/viewer descriptors.
*
* Contract note: {@link lazyChunkComponent} returns a plain render-prop
* function — the descriptor contract is `component: (props) => ReactNode`,
* and the repo renders descriptors BOTH ways: Sidebar calls
* `descriptor.component(props)` directly, EditorHost renders it via
* `createElement`. The wrapper function body therefore contains no hooks;
* all state lives in the inner {@link LazyChunkView} component.
*/
import { createElement, useEffect, useState, type ComponentType, type ReactNode } from 'react'
import { loadChunk, type ChunkExports, type ChunkName } from './chunk-loader.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
interface LazyChunkViewProps<P> {
chunk: ChunkName
/** Module-level-stable selector (an inline lambda would re-trigger the effect). */
pick: (mod: ChunkExports) => ComponentType<P> | undefined
props: P
}
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; Comp: ComponentType<any> }
>({ status: 'loading' })
useEffect(() => {
let cancelled = false
setState({ status: 'loading' })
loadChunk(chunk).then((mod) => {
if (cancelled) return
const Comp = pick(mod)
if (Comp === undefined) {
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
return
}
setState({ status: 'ready', Comp })
}).catch((error: unknown) => {
if (cancelled) return
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [chunk, pick, attempt])
if (state.status === 'loading') {
return <div className={css.editorPlaceholder}>{t('loading')}</div>
}
if (state.status === 'error') {
return (
<div className={css.editorError}>
<span>{state.message}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { setAttempt(current => current + 1) }}
>
{t('terminalRetry')}
</button>
</div>
)
}
return createElement(state.Comp, props)
}
/**
* Build a descriptor-compatible lazy wrapper for a chunk-resident component.
* The returned function is the descriptor `component` itself: it returns an
* element and never calls hooks, so both invocation styles (plain function
* call and createElement/JSX render) work. `pick` must be a module-level
* function (stable identity) — an inline lambda would re-trigger the load
* effect on every render.
* @param chunk - the chunk name (see chunk-loader.ts).
* @param pick - select the component from the chunk's exports.
*/
export function lazyChunkComponent<P extends object>(
chunk: ChunkName,
pick: (mod: ChunkExports) => ComponentType<P> | undefined,
): (props: P) => ReactNode {
return (props: P) => createElement(
LazyChunkView as ComponentType<LazyChunkViewProps<P>>,
{ chunk, pick, props },
)
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared pieces of the Office preview views (docx / xlsx): the load-state
* union, the shared props, and the download fallback. The two views live in
* their own modules (docx-view.tsx / xlsx-view.tsx) so the bundler can emit
* them as separate lazy chunks — docx-preview and the Univer family (several
* MB) must never share a script (see docs/plans/2026-08-12-lazy-chunks-design.md).
*/
import { downloadUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Loading / ready / error state shared by both views. */
export type LoadState =
| { status: 'loading' }
| { status: 'ready' }
| { status: 'error'; message: string }
/** Shared props. */
export interface OfficeViewProps {
scope: SessionScope
path: string
title: string
}
/**
* The shared error / fallback affordance: the failure reason plus a download
* link, so the user always has a path to the file. Used by both DocxView and
* XlsxView, and matches the binary placeholder download link.
*/
export function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{message}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
+10 -247
View File
@@ -1,249 +1,12 @@
/**
* Office preview components: {@link DocxView} renders .docx via docx-preview
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
* Univer sheets preset (data + formulas + formatting). Both load the file
* bytes through the existing /sidebar/file media route and own their library
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
* TerminalView dispose discipline).
*
* Errors degrade to the same download-button affordance the binary
* placeholder uses, so a corrupted / encrypted / oversized file always
* leaves the user with a way to get the file.
*
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
* `require` cannot load relative chunk URLs in the browser, so dynamic
* import() collapses to a synchronous inline — see tsdown.config.ts).
* Compat re-export shim for the historical `src/client/office-view.tsx`
* path. The office preview views were split into docx-view.tsx /
* xlsx-view.tsx (+ office-shared.tsx) so the bundler can emit each library
* as its own lazy chunk (see docs/plans/2026-08-12-lazy-chunks-design.md).
* This module keeps deep imports of this path resolvable through the
* `./src/*` exports subpath — it deliberately re-exports BOTH views, so it
* must never be imported from the core bundle or a chunk entry (it would
* drag both libraries into that bundle).
*/
import { useEffect, useRef, useState } from 'react'
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
import css from './sidebar.module.css'
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
// are present before the first .xlsx opens.
import '@univerjs/preset-sheets-core/lib/index.css'
/** Loading / ready / error state shared by both views. */
type LoadState =
| { status: 'loading' }
| { status: 'ready' }
| { status: 'error'; message: string }
/** Shared props. */
interface OfficeViewProps {
scope: SessionScope
path: string
title: string
}
/**
* Render a .docx file via docx-preview. The library renders into a container
* div (no canvas); images and styles are inlined. Unmounting clears the
* container's innerHTML — docx-preview has no dispose API, but tearing down
* the DOM is enough.
*/
export function DocxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const viewportRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
const [zoom, setZoom] = useState(100)
useEffect(() => {
let cancelled = false
const container = viewportRef.current
const wrap = wrapRef.current
if (container === null || wrap === null) return
setZoom(100)
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// docx-preview ships its own CSS through the className option; the
// wrapper div scopes its render output.
const { renderAsync } = await import('docx-preview')
await renderAsync(buf, wrap, undefined, {
className: 'docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
experimental: false,
})
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Tear down the rendered DOM so a reopen starts clean.
if (wrap !== null) wrap.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
useEffect(() => {
const viewport = viewportRef.current
if (viewport === null) return
const onWheel = (event: WheelEvent): void => {
if (!event.altKey) return
event.preventDefault()
const delta = event.deltaY < 0 ? 10 : -10
setZoom(current => Math.max(50, Math.min(200, current + delta)))
}
viewport.addEventListener('wheel', onWheel, { passive: false })
return () => { viewport.removeEventListener('wheel', onWheel) }
}, [])
return (
<div className={css.editorDocx}>
<div className={css.editorDocxViewport} ref={viewportRef}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
{load.status !== 'error' && (
<div
className={css.editorDocxWrap}
ref={wrapRef}
aria-label={title}
style={{ zoom: zoom / 100 }}
/>
)}
</div>
<div className={css.editorDocxZoom}>
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
<input
className={css.editorDocxZoomRange}
type="range"
min={50}
max={200}
step={10}
value={zoom}
aria-label={t('zoom')}
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
/>
<span className={css.editorDocxZoomValue}>{zoom}%</span>
</div>
</div>
)
}
/**
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
* — without it the canvas, workers, and DOM listeners leak (mirrors the
* xterm dispose discipline in TerminalView).
*/
export function XlsxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const hostRef = useRef<HTMLDivElement>(null)
const univerRef = useRef<{ dispose: () => void } | null>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (host === null) return
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
// The dynamic form keeps the source readable: each lib is only pulled
// in when an .xlsx is actually opened (semantically; the bundle still
// contains all of them).
const XLSX = await import('xlsx')
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
// Locales pick the browser language; falls back to en-US.
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
const localePack = await (isZh
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
const wb = XLSX.read(buf, { type: 'array' })
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
if (cancelled) return
const { univer, univerAPI } = createUniver({
locale,
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
presets: [UniverSheetsCorePreset({ container: host })],
})
univerRef.current = univer
univerAPI.createWorkbook(workbookData)
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
try {
univerRef.current?.dispose()
} catch {
// Partially initialized instance — ignore disposal errors.
}
univerRef.current = null
host.innerHTML = ''
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Critical: dispose the Univer instance (canvas + workers + listeners).
try {
univerRef.current?.dispose()
} catch {
// Already torn down — ignore.
}
univerRef.current = null
// Clear the host in case dispose left DOM behind.
if (host !== null) host.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
return (
<div className={css.editorXlsx} aria-label={title}>
{/* Univer exclusively owns this element's descendants. React only owns
the sibling overlay, so Univer cannot remove a React child. */}
<div className={css.editorUniverHost} ref={hostRef} />
{load.status !== 'ready' && (
<div className={css.editorOfficeOverlay}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
</div>
)}
</div>
)
}
/**
* The shared error / fallback affordance: the failure reason plus a download
* link, so the user always has a path to the file. Used by both DocxView and
* XlsxView, and matches the binary placeholder download link.
*/
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{message}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
export { DocxView } from './docx-view.tsx'
export { XlsxView } from './xlsx-view.tsx'
+116
View File
@@ -0,0 +1,116 @@
/**
* The .xlsx preview component: renders via the Univer sheets preset (data +
* formulas + formatting). Lives in its own module (split out of the former
* office-view.tsx) so it builds as a standalone lazy chunk
* (`lib/client-xlsx.js`) — the Univer family and SheetJS pull in several MB
* but are loaded only when a .xlsx is first opened. The docx stack never
* shares this script.
*
* Errors degrade to the shared download-button affordance, so a corrupted /
* encrypted / oversized file always leaves the user with a way to get it.
*/
import { useEffect, useRef, useState } from 'react'
import { mediaUrl } from './api.ts'
import { t } from './locales.ts'
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
import css from './sidebar.module.css'
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
// are present before the first .xlsx opens.
import '@univerjs/preset-sheets-core/lib/index.css'
/**
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
* — without it the canvas, workers, and DOM listeners leak (mirrors the
* xterm dispose discipline in TerminalView).
*/
export function XlsxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const hostRef = useRef<HTMLDivElement>(null)
const univerRef = useRef<{ dispose: () => void } | null>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (host === null) return
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// Dynamic imports — collapsed into the chunk by codeSplitting:false.
// The dynamic form keeps the source readable: each lib is only pulled
// in when an .xlsx is actually opened (semantically; the chunk still
// contains all of them — the chunk itself only loads on first .xlsx).
const XLSX = await import('xlsx')
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
// Locales pick the browser language; falls back to en-US.
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
const localePack = await (isZh
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
const wb = XLSX.read(buf, { type: 'array' })
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
if (cancelled) return
const { univer, univerAPI } = createUniver({
locale,
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
presets: [UniverSheetsCorePreset({ container: host })],
})
univerRef.current = univer
univerAPI.createWorkbook(workbookData)
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
try {
univerRef.current?.dispose()
} catch {
// Partially initialized instance — ignore disposal errors.
}
univerRef.current = null
host.innerHTML = ''
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Critical: dispose the Univer instance (canvas + workers + listeners).
try {
univerRef.current?.dispose()
} catch {
// Already torn down — ignore.
}
univerRef.current = null
// Clear the host in case dispose left DOM behind.
if (host !== null) host.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
return (
<div className={css.editorXlsx} aria-label={title}>
{/* Univer exclusively owns this element's descendants. React only owns
the sibling overlay, so Univer cannot remove a React child. */}
<div className={css.editorUniverHost} ref={hostRef} />
{load.status !== 'ready' && (
<div className={css.editorOfficeOverlay}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
</div>
)}
</div>
)
}
+14 -6
View File
@@ -1,11 +1,12 @@
/**
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), and the terminal
* WebSocket upgrade. Every route passes the same browser-trust fence as the
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
* trustedHosts read live from the connection loader row so the fence never
* drifts from the deployment's.
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
* and the terminal WebSocket upgrade. Every route passes the same
* browser-trust fence as the /api gateway — Host-header loopback or the
* connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
* literals per boot) — with the trustedHosts read live from the connection
* loader row so the fence never drifts from the deployment's.
*
* All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal
@@ -29,6 +30,7 @@ import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from '.
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
@@ -517,6 +519,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: /sidebar/api routes')
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
// Serves the client half's split bundles (lib/client-<name>.js) so the
// heavy preview/terminal libraries load on first use, not at page start
// (see bundle-route.ts / src/client/chunk-loader.ts).
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',