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:
Menghuan1918
2026-08-09 23:49:58 +08:00
parent 06440d0365
commit 9b00135de4
19 changed files with 879 additions and 66 deletions
+7 -1
View File
@@ -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)
## ⌨️ 快捷键速查
| 操作 | 按键 |
+4
View File
@@ -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",
+6
View File
@@ -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
+90
View File
@@ -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;
}
}
+181
View File
@@ -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>
)
}
+9
View File
@@ -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). */
+53 -19
View File
@@ -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(() => {
try {
const host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
return () => {
root.unmount()
host.remove()
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 {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
} catch (error) {
fail('mount', error)
}
} 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)
}
+16
View File
@@ -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). */
+58
View File
@@ -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 }
}
}
+31 -26
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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),
})
+36
View File
@@ -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
View File
@@ -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',
+34
View File
@@ -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)))
}
+2
View File
@@ -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. */
+16
View File
@@ -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 })
})
})
+128
View File
@@ -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
View File
@@ -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)
})
})