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:
+26
-24
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user