feat: DSH-native flat chrome + Side card settings (plugin-owned settings wire)
- Remove every box-shadow; panel uses the native sidebar fill surface (--dsw-specific-sidebar-fill) with a hairline border, the collapse control becomes the native 36px circular rail button, the active tab uses the native interactive-bg-active fill, and rows/typography follow the native 34px/14px session-row recipe. - New 'Side card' settings section in the DSH Settings shell: open-by- default for new conversations (boolean) and default width share as a percent of the window (20-60). New sessions seed from these prefs; existing per-session layouts stay untouched. - The settings namespace is read/written through the plugin's own fenced /sidebar routes calling the settings seam in-process: the DSH settings RPC domain only serves allowlisted namespaces to configuration clients, so third-party namespaces cannot use connection.api.settings (reads are silently filtered, writes refused with 'not exposed'). The seam still persists to the user settings document with revision-guarded writes.
This commit is contained in:
@@ -101,9 +101,15 @@
|
||||
|
||||
### 🎨 界面风格
|
||||
|
||||
- 行/按钮/间距/动效对齐应用自身组件:34px 圆角树行、28px 圆形图标按钮、挂载淡入
|
||||
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
|
||||
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
|
||||
|
||||
### ⚙️ Side card 设置(DSH 设置页)
|
||||
|
||||
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
|
||||
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
|
||||
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
|
||||
|
||||
## ⌨️ 快捷键速查
|
||||
|
||||
| 操作 | 按键 |
|
||||
|
||||
@@ -54,11 +54,13 @@
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
@@ -97,11 +99,13 @@
|
||||
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
|
||||
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
|
||||
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
|
||||
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
|
||||
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
|
||||
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
|
||||
Generated
+6
@@ -105,6 +105,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives
|
||||
version: link:../../.dsh/source/current/packages/client/ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings
|
||||
version: link:../../.dsh/source/current/packages/client/ui-settings
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
|
||||
version: link:../../.dsh/source/current/packages/client/ui-slots
|
||||
@@ -120,6 +123,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session
|
||||
version: link:../../.dsh/source/current/packages/core/session
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/settings/settings
|
||||
version: link:../../.dsh/source/current/packages/settings/settings
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.13.3
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Side card settings section chrome: the native settings-row recipe (figma
|
||||
* row 16/0 padding, hairline separator, 14px/22px title, 12px/17px desc) with
|
||||
* the native checkbox (16px, brand accent) and the Input atom + "%" suffix.
|
||||
* The shell strips the last row's separator via the trailing-child rule.
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent, no custom chrome (RiskConfirmation recipe). */
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.percentInput {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
* Two rows:
|
||||
* - new conversations open the panel by default (native checkbox), and
|
||||
* - the default panel width as a percent of the window (number input + %).
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values. Any failure reverts the optimistic UI and shows the wire error
|
||||
* inline — a broken settings surface never crashes the shell.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
|
||||
|
||||
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
|
||||
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
|
||||
}
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
// Whether the user already wrote since mount: the mount read must not
|
||||
// clobber a newer optimistic edit (the window is milliseconds, but a slow
|
||||
// route must never silently revert a just-made change).
|
||||
const dirtyRef = useRef(false)
|
||||
// Serialize commits: a queued write must observe the previous write's
|
||||
// revision; a failed write must not poison the queue for later ones.
|
||||
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
|
||||
// Sync the persisted document once on mount: the revision and the current
|
||||
// values (another tab may have changed them since the store hydrated).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.settingsGet().then((view) => {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
{ ...patch },
|
||||
revisionRef.current,
|
||||
)
|
||||
const next = parsePrefs(view.value)
|
||||
revisionRef.current = view.revision
|
||||
store.setPrefs(next)
|
||||
return next
|
||||
})
|
||||
// A failed commit must not poison the queue: later writes still run.
|
||||
inFlightRef.current = run.then(() => undefined, () => undefined)
|
||||
return run.then(
|
||||
(next) => ({ ok: true, prefs: next }),
|
||||
(caught) => {
|
||||
setError(messageOf(caught))
|
||||
return { ok: false, prefs }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, openByDefault: next })
|
||||
setError(null)
|
||||
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.openByDefault}
|
||||
aria-label={t('settingsOpenTitle')}
|
||||
onChange={event => { onToggle(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -119,6 +119,15 @@ export const api = {
|
||||
* this explicit route). */
|
||||
ptyClose: (scope: SessionScope, tab: string) =>
|
||||
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
/** Merge a patch into the side card preferences (revision-guarded). */
|
||||
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.update', {
|
||||
patch,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
}),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
|
||||
+48
-14
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* Client half of dsh-better-sidebar: mounts the right sidebar portal (inside
|
||||
* an error boundary so a rendering failure shows an error strip instead of a
|
||||
* blank panel) and registers the turn-tail interception. Requires the
|
||||
* runtime's slots and sessions services; the bundle itself is a module-table
|
||||
* consumer only (react + ui-primitives + xterm, all provided or inlined).
|
||||
* Client half of dsh-better-sidebar: resolves the user's "Side card"
|
||||
* preferences through the plugin's own fenced settings route, mounts the
|
||||
* right sidebar portal (inside an error boundary so a rendering failure
|
||||
* shows an error strip instead of a blank panel), registers the turn-tail
|
||||
* interception, and contributes the Side card settings section to the DSH
|
||||
* Settings shell. Requires the runtime's slots and sessions services; the
|
||||
* bundle itself is a module-table consumer only (react + ui-primitives +
|
||||
* xterm, all provided or inlined).
|
||||
*/
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createRoot, type Root } 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 { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
@@ -60,7 +66,7 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
|
||||
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
|
||||
// registrations (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
|
||||
@@ -81,19 +87,35 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
try {
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
// Resolve the user's side card prefs BEFORE the first session seeds,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
// schema defaults; the sidebar still mounts (a stalled wire gives up
|
||||
// after the timeout and mounts on the defaults).
|
||||
const prefs = await Promise.race([
|
||||
loadPrefs(api),
|
||||
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
||||
if (disposed) return
|
||||
try {
|
||||
const host = document.createElement('div')
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
return () => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
}
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
root?.unmount()
|
||||
host?.remove()
|
||||
}
|
||||
}, 'dsh-better-sidebar: sidebar mount')
|
||||
|
||||
@@ -108,6 +130,18 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route and keeps the shared store in sync.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,14 @@ const zh = {
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
}
|
||||
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
@@ -137,6 +145,14 @@ const en: Record<keyof typeof zh, string> = {
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
settingsNav: 'Side card',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Client-side read of the user-facing "Side card" preferences. The host owns
|
||||
* the namespace through the settings seam (in-process); the DSH settings RPC
|
||||
* domain only serves allowlisted namespaces to configuration clients, so the
|
||||
* client reads and writes THIS namespace through the plugin's own fenced
|
||||
* /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure
|
||||
* (route rejected, namespace absent, a field of the wrong type, a value out
|
||||
* of the contract range) falls back to the schema defaults — the side card
|
||||
* must keep working exactly as composed when the settings surface is missing.
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/**
|
||||
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
* @returns validated prefs (always well-formed).
|
||||
*/
|
||||
export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
openByDefault: typeof record.openByDefault === 'boolean'
|
||||
? record.openByDefault
|
||||
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
|
||||
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
|
||||
? clampWidthPercent(record.defaultWidthPercent)
|
||||
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
* @returns validated prefs, or the schema defaults when the route rejects,
|
||||
* the namespace is absent, or a stored value violates the contract.
|
||||
*/
|
||||
export async function loadPrefs(settings: SidebarSettingsClient): Promise<SidebarPrefs> {
|
||||
try {
|
||||
const view = await settings.settingsGet()
|
||||
return parsePrefs(view.value)
|
||||
} catch {
|
||||
// Transport/fence rejection or a malformed response: keep the defaults.
|
||||
return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Sidebar styles. Per the DSH web-styling rules every visual value rides the
|
||||
* theme: --dsw-alias-* semantic tokens for colors, --dsw-font-* typography
|
||||
* roles (size paired with line-height), --dsw-shadow-* for elevation, and
|
||||
* --ds-* for motion. No static palette values, no color literals, no theme
|
||||
* selectors — light/dark adaptation is the theme package's job.
|
||||
* roles (size paired with line-height), and --ds-* for motion. The chrome
|
||||
* mirrors the native DSH sidebar: flat (NO box-shadows anywhere), the
|
||||
* sidebar fill surface, hairline borders, and the 28/36px circular icon
|
||||
* controls with hover fills. No static palette values, no color literals, no
|
||||
* theme selectors — light/dark adaptation is the theme package's job.
|
||||
*/
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
||||
@@ -16,28 +18,31 @@
|
||||
z-index: 55;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Native rail inset: the 36px control sits 10px off the column edge. */
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* The closed-state affordance is the native rail icon control: a plain 36px
|
||||
circle, transparent fill, secondary ink; hover raises the fill. No border,
|
||||
no shadow — the icon alone is the affordance (SidebarRoot .iconButton). */
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-right: none;
|
||||
border-radius: 10px 0 0 10px;
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--dsw-shadow-lv1);
|
||||
transition:
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.toggleButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -54,9 +59,10 @@
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark,
|
||||
exactly like the app's own sidebar column (SidebarRoot .root). */
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Expand/collapse slides the panel; fullscreen animates the width. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -67,7 +73,6 @@
|
||||
.panelHidden {
|
||||
transform: translateX(102%);
|
||||
pointer-events: none;
|
||||
box-shadow: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -107,7 +112,7 @@
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -371,8 +376,8 @@
|
||||
|
||||
.tabActive {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary);
|
||||
/* Native selected fill (Rows .sessionRow.selected) — no underline, no shadow. */
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
@@ -449,7 +454,7 @@
|
||||
}
|
||||
|
||||
.explorerRoot {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -463,9 +468,9 @@
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
/* One tree row: the app's tree-row cell (34px, radius 8, inset, hover
|
||||
fill, 22px indent step) with a mount fade so expanding a directory
|
||||
reveals its children gently (stable keys keep existing rows from
|
||||
/* One tree row: the app's session-row cell (34px, radius 8, inset, hover
|
||||
fill, 22px indent step, 14px ink) with a mount fade so expanding a
|
||||
directory reveals its children gently (stable keys keep existing rows from
|
||||
replaying it). */
|
||||
.explorerRow {
|
||||
display: flex;
|
||||
@@ -473,10 +478,10 @@
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
@@ -490,7 +495,7 @@
|
||||
}
|
||||
|
||||
.explorerDir {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
font: var(--dsw-font-s-strong-14);
|
||||
}
|
||||
|
||||
.explorerHidden {
|
||||
@@ -843,7 +848,7 @@
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
margin: 0 6px;
|
||||
padding: 0 6px 0 12px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
animation: dsh-row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -888,7 +893,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
|
||||
+38
-16
@@ -9,6 +9,8 @@
|
||||
* divides the space row- or column-wise with fractional sizes. All tree
|
||||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||||
*/
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal'
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
|
||||
@@ -54,8 +56,6 @@ export interface SidebarState {
|
||||
export const PANEL_MIN = 280
|
||||
export const PANEL_MAX = 640
|
||||
export const PANEL_DEFAULT = 400
|
||||
/** Approximate default width of the host's left sidebar (columns.ts SIDEBAR_DEFAULT). */
|
||||
export const LEFT_SIDEBAR_DEFAULT = 320
|
||||
export const TAB_MAX_WIDTH = 160
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
@@ -99,15 +99,16 @@ function maxCounterId(parsed: unknown): number {
|
||||
return max
|
||||
}
|
||||
|
||||
/** A fresh default state: open, one explorer tab in one pane. `width` is
|
||||
* the caller's preferred panel width (default PANEL_DEFAULT); the store
|
||||
* seeds new sessions with half the viewport. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT): SidebarState {
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
return {
|
||||
panelOpen: true,
|
||||
panelOpen,
|
||||
width,
|
||||
activePane: leaf.id,
|
||||
nextTerminal: 1,
|
||||
@@ -405,7 +406,14 @@ export interface SidebarSnapshot {
|
||||
state: SidebarState | undefined
|
||||
}
|
||||
|
||||
function loadState(sessionId: string): SidebarState {
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
* clamped to the panel floor (a tiny percent must stay usable) and to the
|
||||
* viewport (a large one must never cover the whole window). */
|
||||
export function defaultWidthFor(viewport: number, percent: number): number {
|
||||
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
|
||||
}
|
||||
|
||||
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
if (raw !== null) {
|
||||
@@ -419,13 +427,15 @@ function loadState(sessionId: string): SidebarState {
|
||||
} catch {
|
||||
// Corrupt or unavailable storage: fall through to the default.
|
||||
}
|
||||
// New sessions open at half the CONTENT area (the viewport minus the
|
||||
// left sidebar's default width), so the panel never swallows half the
|
||||
// whole window; the same default applies to every session.
|
||||
const contentHalf = typeof window !== 'undefined'
|
||||
? Math.max(PANEL_MIN, Math.round((window.innerWidth - LEFT_SIDEBAR_DEFAULT) / 2))
|
||||
: PANEL_DEFAULT
|
||||
return makeDefaultState(contentHalf)
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), and the
|
||||
// panel starts open only when the preference says so.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,6 +547,18 @@ export class SidebarStore {
|
||||
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/** Replace the side card prefs (the settings RPC result / settings page write). */
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
getPrefs(): SidebarPrefs {
|
||||
return { ...this.prefs }
|
||||
}
|
||||
|
||||
/** Select a session (or none); loads its persisted state. */
|
||||
setSession(sessionId: string | undefined): void {
|
||||
@@ -546,7 +568,7 @@ export class SidebarStore {
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId)
|
||||
state = loadState(sessionId, this.prefs)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Cache hit: another session's load/ops may have left the uid
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
/** Tunable sidebar host limits (every field optional; defaults fill in). */
|
||||
export interface SidebarConfig {
|
||||
@@ -54,3 +71,11 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
// ── User-facing "Side card" preferences ─────────────────────────────────────
|
||||
|
||||
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
||||
export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
})
|
||||
|
||||
@@ -80,6 +80,12 @@ export interface SidebarSlotRegisterOptions {
|
||||
/** The client slots service face (register returns the disposer). */
|
||||
export interface SidebarSlotsService {
|
||||
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
|
||||
/**
|
||||
* Run a callback for each declaration lifetime of a slot (the runtime
|
||||
* SlotsService.inject): a no-op while the slot is undeclared, so the
|
||||
* settings section registration waits for the settings shell.
|
||||
*/
|
||||
inject(key: string, callback: () => () => void): () => void
|
||||
}
|
||||
|
||||
/** The client session list row the sidebar reads (cwd for the explorer). */
|
||||
@@ -139,12 +145,42 @@ export interface SidebarInvariantsService {
|
||||
): () => void
|
||||
}
|
||||
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
|
||||
export interface SidebarSettingsService {
|
||||
/**
|
||||
* Register one namespace schema (the resolved value layers schema defaults,
|
||||
* then the composition base, then the user document).
|
||||
*/
|
||||
register<T>(
|
||||
ns: string,
|
||||
schema: unknown,
|
||||
options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
|
||||
): {
|
||||
get(): T
|
||||
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
||||
update(patch: object): Promise<void>
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
/** Redacted descriptors of every registered namespace (secrets stripped). */
|
||||
describe(options?: { redactSecrets?: boolean }): Array<{
|
||||
ns: string
|
||||
value?: unknown
|
||||
base?: unknown
|
||||
user?: unknown
|
||||
applies: 'live' | 'restart'
|
||||
revision: number
|
||||
}>
|
||||
/** Service-level merge write with the revision guard (a stale writer is refused). */
|
||||
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: SidebarHttpServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
loader: SidebarLoader
|
||||
slots: SidebarSlotsService
|
||||
settings: SidebarSettingsService
|
||||
invariants: SidebarInvariantsService
|
||||
/**
|
||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||
|
||||
+80
-2
@@ -18,13 +18,16 @@ import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
resolveSidebarConfig,
|
||||
SIDEBAR_PREFS_NS,
|
||||
type ResolvedSidebarConfig,
|
||||
type SidebarConfig,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { isTrustedApiRequest } from './trust-fence.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'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
@@ -112,8 +115,27 @@ async function readText(path: string, readLimit: number): Promise<{ content: str
|
||||
/** One API method dispatch table entry. */
|
||||
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
|
||||
/**
|
||||
* The live face of the side card settings namespace, bound to the settings
|
||||
* service when it is mounted. The DSH settings RPC domain only serves
|
||||
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
|
||||
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
|
||||
* which call the seam in-process — no configuration-client gate involved.
|
||||
*/
|
||||
export interface SidebarSettingsFace {
|
||||
/** The current resolved value + revision (undefined while the settings service is absent). */
|
||||
get(): { value?: unknown; revision?: number }
|
||||
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
||||
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const record = payload as { cwd?: unknown } | null
|
||||
@@ -212,6 +234,35 @@ function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSideba
|
||||
ptyManager.close(`${sessionId}:${tab}`)
|
||||
return { ok: true }
|
||||
},
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
// is refused with settings-conflict so a concurrent change is never
|
||||
// silently overwritten (mirror of the settings seam's own guard).
|
||||
'settings.get': () => {
|
||||
const settings = getSettings()
|
||||
return settings?.get() ?? { value: undefined, revision: undefined }
|
||||
},
|
||||
'settings.update': async (payload) => {
|
||||
const settings = getSettings()
|
||||
if (settings === undefined) {
|
||||
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
|
||||
}
|
||||
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
|
||||
const patch = record?.patch
|
||||
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new SidebarError('bad-request', 'patch must be a plain object')
|
||||
}
|
||||
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
|
||||
try {
|
||||
return await settings.update(patch as Record<string, unknown>, expectedRevision)
|
||||
} catch (error) {
|
||||
if (error instanceof SettingsConflictError) {
|
||||
throw new SidebarError('settings-conflict', error.message, 409)
|
||||
}
|
||||
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +282,35 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
||||
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
// (client half) can render and persist the new-conversation defaults. The
|
||||
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
||||
// configuration clients, so the client reaches this namespace through the
|
||||
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
||||
// which call the seam in-process. Deployments without a settings service
|
||||
// simply never fill the face and the client falls back to the defaults.
|
||||
let settingsFace: SidebarSettingsFace | undefined
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
||||
sctx.settings.register(ns, PrefsSchema)
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
? { value: undefined, revision: undefined }
|
||||
: { value: descriptor.value, revision: descriptor.revision }
|
||||
}
|
||||
settingsFace = {
|
||||
get: viewOf,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, resolved)
|
||||
const api = buildApi(ctx, ptyManager, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
||||
* BOTH halves: the host registers the schemastery schema over these values
|
||||
* (config.ts) and the client reads/writes them through the settings RPC
|
||||
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
|
||||
* the browser bundle never pulls the schema runtime in.
|
||||
*/
|
||||
|
||||
/** The user-settings namespace holding the side card preferences. */
|
||||
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
|
||||
|
||||
/** User-facing side card preferences (new-conversation defaults). */
|
||||
export interface SidebarPrefs {
|
||||
/** Whether a brand-new conversation opens the side card by default. */
|
||||
openByDefault: boolean
|
||||
/** Default panel width as a percent of the window width (20–60). */
|
||||
defaultWidthPercent: number
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
export function clampWidthPercent(value: number): number {
|
||||
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
/** One API failure with its wire code and HTTP status. */
|
||||
|
||||
@@ -35,4 +35,20 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.terminalsPerSession).toBe(3)
|
||||
expect(resolved.reconnectGraceMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('registers the side card preferences schema with the documented defaults', async () => {
|
||||
const { PrefsSchema, SIDEBAR_PREFS_NS } = await import('../src/config.ts')
|
||||
expect(SIDEBAR_PREFS_NS).toBe('dsh-better-sidebar')
|
||||
const resolved = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write).
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve as resolvePath } from 'node:path'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
@@ -20,6 +21,9 @@ interface FakeContext {
|
||||
}
|
||||
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
effect: (fn: () => void | (() => void), label?: string) => void
|
||||
/** The settings service never appears in the smoke context: the inject
|
||||
* callback must never run (mirror of cordis' service-less inject). */
|
||||
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
@@ -40,6 +44,9 @@ describe('host plugin smoke', () => {
|
||||
const cleanup = fn()
|
||||
if (typeof cleanup === 'function') effects.push(cleanup)
|
||||
},
|
||||
// No settings service in the smoke context: the registration callback
|
||||
// never runs (cordis' service-less inject behaves the same).
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
|
||||
@@ -167,6 +174,8 @@ describe('session cwd resolution over the API route', () => {
|
||||
sessions: overrides.sessions ?? { get: () => undefined },
|
||||
// The vendored cordis runs registration effects immediately.
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
// No settings service: the namespace registration never runs.
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
@@ -235,3 +244,122 @@ describe('session cwd resolution over the API route', () => {
|
||||
expect(missing.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
/** A minimal settings seam: register/describe/update with the revision guard. */
|
||||
const createFakeSettings = () => {
|
||||
const namespaces = new Map<string, {
|
||||
schema: unknown
|
||||
value: Record<string, unknown> | undefined
|
||||
revision: number
|
||||
}>()
|
||||
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
|
||||
const schema = entry.schema as (input: unknown) => unknown
|
||||
return entry.value === undefined ? schema(undefined) : schema(entry.value)
|
||||
}
|
||||
return {
|
||||
register(ns: string, schema: unknown) {
|
||||
namespaces.set(ns, { schema, value: undefined, revision: 0 })
|
||||
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
|
||||
},
|
||||
describe() {
|
||||
return [...namespaces.entries()].map(([ns, entry]) => ({
|
||||
ns,
|
||||
value: resolve(entry),
|
||||
applies: 'live' as const,
|
||||
revision: entry.revision,
|
||||
}))
|
||||
},
|
||||
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
|
||||
const entry = namespaces.get(ns)
|
||||
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
|
||||
throw new SettingsConflictError(settingsNamespace(ns), expectedRevision, entry.revision)
|
||||
}
|
||||
entry.value = { ...entry.value, ...patch }
|
||||
entry.revision += 1
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: { get: () => undefined },
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
}
|
||||
|
||||
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
|
||||
ok: boolean
|
||||
value?: unknown
|
||||
error?: { code?: string; message: string }
|
||||
}> => {
|
||||
const body = Buffer.from(JSON.stringify(payload))
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: `/sidebar/api/${method}`,
|
||||
headers: { host: '127.0.0.1:3080' },
|
||||
[Symbol.asyncIterator]: async function* () { yield body },
|
||||
} as never
|
||||
const out: { status: number; body: string } = { status: 200, body: '' }
|
||||
const res = {
|
||||
writeHead: (status: number) => { out.status = status },
|
||||
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
|
||||
} as never
|
||||
await route.handler(req, res)
|
||||
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
|
||||
}
|
||||
|
||||
it('serves the schema defaults when the settings service is absent', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
const result = await invoke(route, 'settings.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({ value: undefined, revision: undefined })
|
||||
})
|
||||
|
||||
it('reads the resolved prefs and writes a patch through the seam', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const read = await invoke(route, 'settings.get', {})
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({ value: { openByDefault: true, defaultWidthPercent: 30 }, revision: 0 })
|
||||
|
||||
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
expect(written.ok).toBe(true)
|
||||
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
|
||||
expect(view.value.openByDefault).toBe(false)
|
||||
expect(view.value.defaultWidthPercent).toBe(30)
|
||||
expect(view.revision).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a stale write with settings-conflict (409)', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
// The second write carries the pre-write revision: the seam refuses it.
|
||||
const stale = await invoke(route, 'settings.update', {
|
||||
patch: { defaultWidthPercent: 40 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(stale.ok).toBe(false)
|
||||
expect(stale.error?.code).toBe('settings-conflict')
|
||||
expect(stale.error?.message).toMatch(/changed since it was read/)
|
||||
})
|
||||
|
||||
it('rejects a non-object patch as bad-request', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const result = await invoke(route, 'settings.update', { patch: 'nope' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/plain object/)
|
||||
})
|
||||
})
|
||||
|
||||
+65
-2
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab, insertLeafAt, makeDefaultState, moveTab, moveTabToEdge,
|
||||
openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||
moveTab, moveTabToEdge, openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { relativeTo } from '../src/client/paths.ts'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
@@ -450,3 +452,64 @@ describe('editor language mapping', () => {
|
||||
expect(languageKeyForExt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('side card preferences', () => {
|
||||
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
||||
const wire = (value: unknown): SidebarSettingsClient => ({
|
||||
settingsGet: async () => ({ value, revision: 1 }),
|
||||
settingsUpdate: async () => ({ value, revision: 2 }),
|
||||
})
|
||||
|
||||
const rejecting = (): SidebarSettingsClient => ({
|
||||
settingsGet: async () => { throw new Error('route rejected') },
|
||||
settingsUpdate: async () => { throw new Error('route rejected') },
|
||||
})
|
||||
|
||||
it('falls back to the defaults when the settings route rejects', async () => {
|
||||
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('falls back to the defaults when the value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('parses a valid value and clamps the percent into the contract range', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80 })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 60 })
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33 })))
|
||||
.toEqual({ openByDefault: true, defaultWidthPercent: 33 })
|
||||
})
|
||||
|
||||
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
expect(snapshot.state?.width).toBe(400)
|
||||
// The default prefs keep the panel open.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setSession('another-fresh')
|
||||
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('derives the default width from the window percent with clamps', () => {
|
||||
expect(defaultWidthFor(1440, 30)).toBe(432)
|
||||
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
|
||||
expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
|
||||
})
|
||||
|
||||
it('makeDefaultState honors the open flag', () => {
|
||||
expect(makeDefaultState().panelOpen).toBe(true)
|
||||
expect(makeDefaultState(400, false).panelOpen).toBe(false)
|
||||
expect(makeDefaultState(400, false).width).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user