feat: per-session store + configurable host limits; fix divider drag runaway

- state: side-store per conversation, injected as a prop instead of a module singleton
- host: schemastery Config (read/media/list/terminal limits) resolved from cordis config
- shape: types/exports/peerDependencies for the web profile + plugin-shape spec
- fix: split divider emits incremental deltas so drags track the cursor 1:1
- docs: README refresh
This commit is contained in:
Menghuan1918
2026-08-09 22:07:03 +08:00
parent a10c9fc77e
commit e2ee427ea3
16 changed files with 421 additions and 97 deletions
+26 -24
View File
@@ -16,8 +16,8 @@ import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutlin
import type { Context, SidebarConversation } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
resizeSplit, sidebarStore, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarTab, type SplitNode,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
@@ -42,8 +42,9 @@ function TabContent(props: {
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
ctx: Context
store: SidebarStore
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx } = props
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
@@ -53,14 +54,14 @@ function TabContent(props: {
cwd={cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={(path) => { openSidebarFile(ctx, sessionId, path) }}
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
onReferenceFile={onReferenceFile}
/>
)
case 'git':
return <GitView scope={scope} />
case 'terminal':
return <TerminalView scope={scope} tabId={tab.id} />
return <TerminalView scope={scope} tabId={tab.id} store={store} />
case 'editor':
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
}
@@ -83,8 +84,8 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] {
]
}
export function Sidebar(props: { ctx: Context }) {
const { ctx } = props
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
@@ -95,10 +96,10 @@ export function Sidebar(props: { ctx: Context }) {
// Per-session sidebar state.
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => sidebarStore.subscribe(callback), []),
useCallback(() => sidebarStore.getSnapshot(), []),
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
useEffect(() => { sidebarStore.setSession(current) }, [current])
useEffect(() => { store.setSession(current) }, [current, store])
const state = snapshot.state
const sessionId = snapshot.sessionId
@@ -149,16 +150,16 @@ export function Sidebar(props: { ctx: Context }) {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
const current = sidebarStore.getSnapshot().state
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
sidebarStore.reduce(s => closeTab(s, paneId, tabId))
store.reduce(s => closeTab(s, paneId, tabId))
if (sessionId !== undefined && tab?.type === 'terminal') {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
},
activateTab: (paneId, tabId) => {
sidebarStore.reduce(s => ({
store.reduce(s => ({
...s,
activePane: paneId,
splits: mapLeaf(s.splits, paneId, (leaf) => {
@@ -166,12 +167,12 @@ export function Sidebar(props: { ctx: Context }) {
}),
}))
},
focusPane: (paneId) => { sidebarStore.reduce(s => ({ ...s, activePane: paneId })) },
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
sidebarStore.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
},
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
sidebarStore.reduce((s) => {
store.reduce((s) => {
let index = -1
const source = leafWithTab(s.splits, beforeTabId)
if (source !== undefined && source.id === toPane) {
@@ -181,9 +182,9 @@ export function Sidebar(props: { ctx: Context }) {
})
},
resizeSplit: (splitId, index, deltaFrac) => {
sidebarStore.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
},
}), [sessionId, cwd])
}), [store, sessionId, cwd])
/**
* The explorer's @-reference button: append `@<relative path>` to the
@@ -223,7 +224,7 @@ export function Sidebar(props: { ctx: Context }) {
}
const onNewTab = (optionId: string): void => {
sidebarStore.reduce((s) => {
store.reduce((s) => {
if (optionId === 'explorer') {
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
}
@@ -256,9 +257,10 @@ export function Sidebar(props: { ctx: Context }) {
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
onToggleDir={(path) => { sidebarStore.reduce(s => toggleExpanded(s, path)) }}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
/>
)
@@ -271,7 +273,7 @@ export function Sidebar(props: { ctx: Context }) {
type="button"
className={css.toggleButton}
aria-label={t('expand')}
onClick={() => { sidebarStore.reduce(togglePanel) }}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
</button>
@@ -298,7 +300,7 @@ export function Sidebar(props: { ctx: Context }) {
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
sidebarStore.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
@@ -314,7 +316,7 @@ export function Sidebar(props: { ctx: Context }) {
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
sidebarStore.reduce(s => setWidth(s, fullscreen
store.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
}}
@@ -330,7 +332,7 @@ export function Sidebar(props: { ctx: Context }) {
className={css.iconButton}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { sidebarStore.reduce(togglePanel) }}
onClick={() => { store.reduce(togglePanel) }}
>
<IconChevronRightOutline14 />
</button>
+5 -5
View File
@@ -14,7 +14,7 @@ import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import { sidebarStore } from './state.ts'
import type { SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import css from './sidebar.module.css'
@@ -60,8 +60,8 @@ function xtermTheme(): ITheme {
}
}
export function TerminalView(props: { scope: SessionScope; tabId: string }) {
const { scope, tabId } = props
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
const { scope, tabId, store } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(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 (
<div className={css.terminalWrap}>
+8 -2
View File
@@ -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
+5 -6
View File
@@ -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)
}
+14 -6
View File
@@ -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
+10 -2
View File
@@ -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()
}
+56
View File
@@ -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<SidebarConfig> = 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,
}
}
+15
View File
@@ -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>,
): () => 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.
+29 -26
View File
@@ -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<string, string> = {
@@ -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> | unknown
/** Build the API method table bound to the plugin context and pty manager. */
function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMethod> {
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSidebarConfig): Record<string, ApiMethod> {
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<string, ApiMetho
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
return listDirectory(target, LIST_LIMIT)
return listDirectory(target, resolved.listLimit)
},
'fs.read': async (payload) => {
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<string, ApiMetho
/**
* Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (httpServer, sessions, loader).
* @param config - deployment-provided limits; the Loader validates against
* {@link Config} and fills defaults, direct callers get them from
* {@link resolveSidebarConfig}.
*/
export function apply(ctx: Context): void {
export function apply(ctx: Context, config?: SidebarConfig): void {
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => 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<void> {
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))
+32
View File
@@ -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 */