Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/ write, git status/diff/stage/commit/branch/log/show), /sidebar/file media route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api trust fence (Host-header loopback or connection.trustedHosts). Client half (src/client): portal-mounted right panel with a per-session split-pane workbench (recursive row/col splits, draggable dividers, tab drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal multi-open rules), lazy file explorer rooted at the session cwd, editable text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm terminal with reconnect, and a VSCode-style git panel. Per-session state persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat turn-tail produced-files row (priority -1 chain entry) to open files in the sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives. Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle (externals = module table words; xterm/css inlined). Tests: 19 unit + smoke specs (pure state/tree/git parsers + real git/fs integration).
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
/**
|
|
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js, ESM
|
|
* node) plus the browser client bundle (lib/client.js, CJS closure factory).
|
|
*
|
|
* The client bundle replicates the DSH client-bundle contract:
|
|
* - externals resolve through the loader module table at runtime (platform
|
|
* seed words from apps/web's seed.ts, plus the runtime/client exemption),
|
|
* - everything else is inlined into the bundle (xterm, clsx, ...),
|
|
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
|
|
* tags at factory execution,
|
|
* - the artifact registers itself via window.__ModuleLoader__.load({id,
|
|
* factory}) with the (require) => exports CJS closure shape.
|
|
*/
|
|
import { readFile } from 'node:fs/promises'
|
|
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
|
import { createRequire } from 'node:module'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { UserConfig } from 'tsdown'
|
|
import { transform } from 'lightningcss'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
|
|
const PLUGIN_ID = 'dsh-better-sidebar'
|
|
|
|
/** Module specifiers the web shell shares into the frozen module table (apps/web/src/seed.ts). */
|
|
const CLIENT_EXTERNALS = [
|
|
'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',
|
|
]
|
|
|
|
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
|
|
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
|
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
|
|
|
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
|
|
|
|
/** The style-injection prologue shared by module css and plain css loads. */
|
|
function injectTag(fileId: string, cssText: string): string {
|
|
const tagId = `${PLUGIN_ID}/${basename(fileId)}`
|
|
return [
|
|
`const css = ${JSON.stringify(cssText)};`,
|
|
`const tagId = ${JSON.stringify(tagId)};`,
|
|
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
|
|
` const tag = document.createElement('style');`,
|
|
` tag.dataset.plugin = ${JSON.stringify(PLUGIN_ID)};`,
|
|
` tag.dataset.pluginCss = tagId;`,
|
|
` tag.textContent = css;`,
|
|
` document.head.appendChild(tag);`,
|
|
`}`,
|
|
].join('\n')
|
|
}
|
|
|
|
/** Rebase a physical lib-relative source onto the repository-shaped URL tree. */
|
|
function browserSourcePath(source: string, sourcemapPath: string): string {
|
|
if (!source.startsWith('.')) return source
|
|
const physicalSource = resolvePath(dirname(sourcemapPath), source)
|
|
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
|
|
return `../../../${repositoryPath}`
|
|
}
|
|
|
|
export default [
|
|
{
|
|
entry: { index: 'src/index.ts' },
|
|
outDir: 'lib',
|
|
format: ['esm'],
|
|
platform: 'node',
|
|
target: 'es2024',
|
|
fixedExtension: false,
|
|
dts: false,
|
|
clean: true,
|
|
},
|
|
{
|
|
entry: { client: 'src/client/index.tsx' },
|
|
outDir: 'lib',
|
|
format: 'cjs',
|
|
platform: 'browser',
|
|
dts: false,
|
|
sourcemap: true,
|
|
clean: false,
|
|
external: [...CLIENT_EXTERNALS],
|
|
define: {
|
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
|
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
|
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
|
},
|
|
// External wins for module-table entries; every other dependency inlines.
|
|
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
|
plugins: [{
|
|
name: 'dsh-css-inline',
|
|
resolveId(source: string, importer: string | undefined) {
|
|
if (!source.endsWith('.css')) return null
|
|
// Relative/absolute paths resolve against the importer; bare
|
|
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
|
|
let abs: string
|
|
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
|
|
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
|
|
} else {
|
|
abs = require.resolve(source)
|
|
}
|
|
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
|
},
|
|
async load(virtualId: string) {
|
|
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
|
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
|
this.addWatchFile(fileId)
|
|
const source = await readFile(fileId)
|
|
// CSS Modules (x.module.css) become hashed class maps; plain css
|
|
// (xterm's stylesheet) is inlined verbatim.
|
|
if (fileId.endsWith('.module.css')) {
|
|
const { code, exports: cssExports } = transform({
|
|
filename: fileId,
|
|
code: source,
|
|
cssModules: { pattern: `[hash]_[local]` },
|
|
minify: true,
|
|
})
|
|
const classMap: Record<string, string> = {}
|
|
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
|
return [
|
|
injectTag(fileId, code.toString()),
|
|
`export default ${JSON.stringify(classMap)};`,
|
|
].join('\n')
|
|
}
|
|
return [
|
|
injectTag(fileId, source.toString('utf8')),
|
|
'export default "";',
|
|
].join('\n')
|
|
},
|
|
}],
|
|
outputOptions: {
|
|
entryFileNames: 'client.js',
|
|
sourcemapPathTransform: browserSourcePath,
|
|
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
|
|
footer: `return module.exports; } });`,
|
|
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
|
},
|
|
},
|
|
] satisfies UserConfig[]
|