(null)
@@ -180,7 +180,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
- if (!sidebarStore.tabOpen(scope.sessionId, tabId)
+ if (!store.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
}
@@ -188,7 +188,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
term.dispose()
connectRef.current = null
}
- }, [scope.sessionId, scope.cwd, tabId])
+ }, [scope.sessionId, scope.cwd, tabId, store])
return (
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 2726a44..ca4c37c 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -8,6 +8,7 @@
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import type { Context } from '../context-types.ts'
+import { createSidebarStore } from './state.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { t } from './locales.ts'
@@ -57,6 +58,11 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
+ // One store instance per activation: production code creates it only here,
+ // then hands it to the mounted panel and closes over it in the slot
+ // registration (the official createXXXStore() factory rule — no
+ // module-level singleton).
+ const sidebarStore = createSidebarStore()
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
@@ -80,7 +86,7 @@ export function apply(ctx: Context): void {
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
- root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
+ root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
return () => {
root.unmount()
host.remove()
@@ -94,7 +100,7 @@ export function apply(ctx: Context): void {
ctx.effect(
() => {
try {
- return registerTurnTailInterception(ctx)
+ return registerTurnTailInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx
index 0610a8e..1a62fd3 100644
--- a/src/client/intercept.tsx
+++ b/src/client/intercept.tsx
@@ -8,19 +8,18 @@
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
-import { openTab } from './state.ts'
-import { sidebarStore } from './state.ts'
+import { openTab, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
-export function openSidebarFile(ctx: Context, sessionId: string, path: string): void {
+export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
- sidebarStore.reduce(state => openTab(state, {
+ store.reduce(state => openTab(state, {
id: `editor:${absolute}`,
type: 'editor',
title,
@@ -61,14 +60,14 @@ export function SidebarProducedFiles(props: {
}
/** Register the turn-tail interception (returns the disposer). */
-export function registerTurnTailInterception(ctx: Context): () => void {
+export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.register({
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
- openInSidebar: (path: string) => { openSidebarFile(ctx, sessionId, path) },
+ openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}),
}, SidebarProducedFiles)
}
diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx
index d4647d0..28f6812 100644
--- a/src/client/split-pane.tsx
+++ b/src/client/split-pane.tsx
@@ -31,10 +31,17 @@ export interface WorkbenchActions {
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
}
-/** One divider: pointer-capture drag translating px deltas into fractions. */
+/** One divider: pointer-capture drag translating px deltas into fractions.
+ * Deltas are incremental — each move reports the displacement since the
+ * previous move — because the store adds every reported delta to the pane
+ * sizes; a cumulative (since-pointer-down) delta would be re-added on each
+ * move and the divider would run away from the cursor. */
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
const { dir, onResize } = props
- const start = useRef({ x: 0, y: 0, size: 0 })
+ // `last` is the previous pointer position while dragging; `size` is the
+ // container size at pointer-down (constant during the drag), used to
+ // normalize the px delta into a fraction.
+ const last = useRef({ x: 0, y: 0, size: 0 })
const [dragging, setDragging] = useState(false)
return (
@@ -44,7 +51,7 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
const box = event.currentTarget.parentElement?.getBoundingClientRect()
- start.current = {
+ last.current = {
x: event.clientX,
y: event.clientY,
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
@@ -53,9 +60,10 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
- const delta = dir === 'row' ? event.clientX - start.current.x : event.clientY - start.current.y
- const size = Math.max(1, start.current.size)
- onResize(delta / size)
+ const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
+ onResize(delta / Math.max(1, last.current.size))
+ last.current.x = event.clientX
+ last.current.y = event.clientY
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
diff --git a/src/client/state.ts b/src/client/state.ts
index c778a47..022492d 100644
--- a/src/client/state.ts
+++ b/src/client/state.ts
@@ -624,5 +624,13 @@ export class SidebarStore {
}
}
-/** Module-level store instance (one per page; the plugin mounts once). */
-export const sidebarStore = new SidebarStore()
+/**
+ * Create one sidebar store instance. Production code calls this only from
+ * the client plugin's `apply` (the instance is handed to components as a
+ * prop); tests call it directly. No module-level singleton: the store's
+ * lifetime belongs to the plugin activation, exactly like the official
+ * `createXXXStore()` factory rule.
+ */
+export function createSidebarStore(): SidebarStore {
+ return new SidebarStore()
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..847b0d0
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,56 @@
+/**
+ * Serializable configuration and defaults for the sidebar host half. Loader
+ * schema validation normally fills defaults; {@link resolveSidebarConfig}
+ * applies the same defaults for direct callers that bypass the Loader.
+ * @module dsh-better-sidebar/config
+ */
+
+import z from 'schemastery'
+
+/** Tunable sidebar host limits (every field optional; defaults fill in). */
+export interface SidebarConfig {
+ /** Read cap of one text file (bytes); larger files return truncated. */
+ readLimit?: number
+ /** Media route cap (bytes); larger binaries are refused. */
+ mediaLimit?: number
+ /** Explorer row bound of one level. */
+ listLimit?: number
+ /** Terminals per session. */
+ terminalsPerSession?: number
+ /** How long a disconnected terminal process survives awaiting a reconnect. */
+ reconnectGraceMs?: number
+}
+
+/** Schemastery schema for the plugin configuration. */
+export const Config: z = z.object({
+ readLimit: z.number().step(1).min(1).default(512 * 1024),
+ mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
+ listLimit: z.number().step(1).min(1).default(1000),
+ terminalsPerSession: z.number().step(1).min(1).default(3),
+ reconnectGraceMs: z.number().step(1).min(0).default(30_000),
+})
+
+/** Fully defaulted sidebar host settings. */
+export interface ResolvedSidebarConfig {
+ readLimit: number
+ mediaLimit: number
+ listLimit: number
+ terminalsPerSession: number
+ reconnectGraceMs: number
+}
+
+/**
+ * Apply direct-call defaults after Loader schema validation has normally run.
+ *
+ * @param config - Deployment-provided sidebar host settings.
+ * @returns Complete settings consumed by the host half.
+ */
+export function resolveSidebarConfig(config: SidebarConfig | undefined): ResolvedSidebarConfig {
+ return {
+ readLimit: config?.readLimit ?? 512 * 1024,
+ mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024,
+ listLimit: config?.listLimit ?? 1000,
+ terminalsPerSession: config?.terminalsPerSession ?? 3,
+ reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
+ }
+}
diff --git a/src/context-types.ts b/src/context-types.ts
index 3bfe11a..ec35c7b 100644
--- a/src/context-types.ts
+++ b/src/context-types.ts
@@ -125,12 +125,27 @@ export interface SidebarConversation {
}
}
+/**
+ * The invariant service face (mirror of @deepseek-ai/dsh-invariants'
+ * InvariantService). The upstream augmentation does not reach this Context
+ * (dual-cordis-instance resolution), so the register signature is restated
+ * structurally, exactly like the other service faces above.
+ */
+export interface SidebarInvariantsService {
+ /** Reserve one package's checks and install them in the service's child fiber. */
+ register(
+ packageName: string,
+ installer: (ctx: Context, fail: (message: string) => never) => void | Promise,
+ ): () => void
+}
+
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
sessions: SidebarSessionStore & SidebarSessionsService
loader: SidebarLoader
slots: SidebarSlotsService
+ invariants: SidebarInvariantsService
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
diff --git a/src/index.ts b/src/index.ts
index 6f63733..951546c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,28 +16,26 @@ import { dirname, extname } from 'node:path'
import type { IncomingMessage } from 'node:http'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
+import {
+ Config,
+ resolveSidebarConfig,
+ type ResolvedSidebarConfig,
+ type SidebarConfig,
+} from './config.ts'
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import * as git from './git.ts'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
+export { Config }
+export type { SidebarConfig, ResolvedSidebarConfig }
+
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
-/** Services required before mounting: the webserver routes and the session store. */
-export const inject = ['httpServer', 'sessions']
-
-/** Read cap of one text file (bytes); larger files return truncated. */
-const READ_LIMIT = 512 * 1024
-/** Media route cap (bytes); larger binaries are refused. */
-const MEDIA_LIMIT = 20 * 1024 * 1024
-/** Explorer row bound of one level. */
-const LIST_LIMIT = 1000
-/** Terminals per session. */
-const TERMINALS_PER_SESSION = 3
-/** How long a disconnected terminal process survives awaiting a reconnect. */
-const TERMINAL_RECONNECT_GRACE_MS = 30_000
+/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */
+export const inject = ['httpServer', 'sessions', 'loader']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record = {
@@ -88,7 +86,7 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
-async function readText(path: string): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
+async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
const info = await stat(path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
@@ -96,12 +94,12 @@ async function readText(path: string): Promise<{ content: string; truncated: boo
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
}
const size = info.size
- const truncated = size > READ_LIMIT
+ const truncated = size > readLimit
const handle = await open(path, 'r').catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
try {
- const buffer = Buffer.alloc(Math.min(size, READ_LIMIT))
+ const buffer = Buffer.alloc(Math.min(size, readLimit))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
const slice = buffer.subarray(0, bytesRead)
const binary = slice.includes(0)
@@ -114,8 +112,8 @@ async function readText(path: string): Promise<{ content: string; truncated: boo
/** One API method dispatch table entry. */
type ApiMethod = (payload: unknown) => Promise | unknown
-/** Build the API method table bound to the plugin context and pty manager. */
-function buildApi(ctx: Context, ptyManager: PtyManager): Record {
+/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
+function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSidebarConfig): Record {
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
const sessionId = requireString(payload, 'sessionId')
const record = payload as { cwd?: unknown } | null
@@ -131,12 +129,12 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
- const { content, truncated, binary, size } = await readText(path)
+ const { content, truncated, binary, size } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated }
return { kind: 'text', content, truncated }
},
@@ -220,17 +218,21 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record isTrustedApiRequest(req, trustedHosts)
- const ptyManager = new PtyManager(defaultShell(), TERMINALS_PER_SESSION)
+ const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// ── JSON API ────────────────────────────────────────────────────────────
- const api = buildApi(ctx, ptyManager)
+ const api = buildApi(ctx, ptyManager, resolved)
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/api',
@@ -290,7 +292,7 @@ export function apply(ctx: Context): void {
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
}
const info = await stat(path)
- if (!info.isFile() || info.size > MEDIA_LIMIT) {
+ if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
@@ -312,7 +314,7 @@ export function apply(ctx: Context): void {
socket.destroy()
return
}
- wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req) })
+ wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) })
},
}), 'dsh-better-sidebar: terminal WebSocket')
@@ -328,6 +330,7 @@ async function attachTerminal(
ptyManager: PtyManager,
ws: WebSocket,
req: IncomingMessage,
+ resolved: ResolvedSidebarConfig,
): Promise {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
@@ -386,7 +389,7 @@ async function attachTerminal(
// A bare socket drop (refresh, tab switch) leaves the process alive
// for a grace period so a quick reconnect keeps it; the reconnect's
// open() cancels the pending close.
- ptyManager.scheduleClose(handle.key, TERMINAL_RECONNECT_GRACE_MS)
+ ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
})
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
diff --git a/src/invariant.ts b/src/invariant.ts
new file mode 100644
index 0000000..0f2d3b1
--- /dev/null
+++ b/src/invariant.ts
@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `dsh-better-sidebar`.
+ * @module dsh-better-sidebar/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from './context-types.ts'
+
+const PACKAGE_NAME = 'dsh-better-sidebar'
+
+/** Cordis companion plugin name. */
+export const name = 'dsh-better-sidebar-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the sidebar owns no service state or event protocol
+ * of its own — every route is mounted under the host's httpServer fence, the
+ * pty lifecycle is exercised by the smoke spec, and the panel's store is a
+ * plain snapshot registry asserted by the behavior specs. The route fence,
+ * pty quota, and store semantics are each observed through their seams.
+ */
+const install: () => void = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts
new file mode 100644
index 0000000..4872fe1
--- /dev/null
+++ b/tests/plugin-shape.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import Loader from '@cordisjs/plugin-loader'
+import * as sidebar from '../src/index.ts'
+
+/**
+ * Run the real namespace export through `Loader.unwrapExports`; a stray
+ * default would discard `name`, `inject`, `Config`, and `apply`. Same guard
+ * the official plugin repos ship (dsh-external/turtle-ui,
+ * packages/ui/jsonrpc).
+ */
+describe('dsh-better-sidebar plugin export shape', () => {
+ it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
+ expect('default' in sidebar).toBe(false)
+ expect(typeof sidebar.apply).toBe('function')
+
+ const loader = Object.create(Loader.prototype) as Loader
+ const unwrapped = loader.unwrapExports(sidebar) as Record
+ expect(unwrapped).toBe(sidebar)
+ expect(unwrapped.name).toBe('dsh-better-sidebar')
+ expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader'])
+ expect(unwrapped.Config).toBeDefined()
+ expect(typeof unwrapped.apply).toBe('function')
+ })
+
+ it('exports the schemastery Config with the documented tunable fields', () => {
+ const schema = sidebar.Config
+ expect(schema).toBeDefined()
+ // The resolved defaults mirror the pre-config constants.
+ const resolved = (schema as unknown as {
+ (input: Record | undefined): Record
+ })(undefined)
+ expect(resolved.readLimit).toBe(512 * 1024)
+ expect(resolved.mediaLimit).toBe(20 * 1024 * 1024)
+ expect(resolved.listLimit).toBe(1000)
+ expect(resolved.terminalsPerSession).toBe(3)
+ expect(resolved.reconnectGraceMs).toBe(30_000)
+ })
+})
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 0000000..ee9b223
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src (the typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS). Consumers resolve declarations through the package exports' types subpaths.",
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "outDir": "lib/types"
+ },
+ "include": ["src"]
+}
diff --git a/tsdown.config.ts b/tsdown.config.ts
index c5bc554..4506cbf 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -1,15 +1,22 @@
/**
- * 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).
+ * tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
+ * the lib/invariant.js companion, 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),
+ * The client bundle replicates the official DSH client-bundle preset
+ * (packages/client/tsdown.client.ts):
+ * - externals resolve through the loader module table at runtime (the
+ * PLATFORM_MODULES seed list from apps/web's platform.ts, plus the
+ * runtime/client exemption),
* - everything else is inlined into the bundle (xterm, clsx, ...),
+ * - the purity gate rejects any other @deepseek-ai value import: cross-plugin
+ * collaboration goes through cordis services, never value imports,
* - CSS Modules compile to hashed class maps and inject