feat: 客户端懒加载分块 — 重依赖(Univer/xterm/CodeMirror/Office)拆独立 bundle 按需下发(/sidebar/bundle 路由 + __dshChunks__ 注册表),核心启动仅 ~325KB
This commit is contained in:
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user