From 9b00135de4c9456ad8db86254519621761bd7b48 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 9 Aug 2026 23:41:20 +0800 Subject: [PATCH] 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. --- README.md | 8 +- package.json | 4 + pnpm-lock.yaml | 6 + src/client/SideCardSection.module.css | 90 +++++++++++++ src/client/SideCardSection.tsx | 181 ++++++++++++++++++++++++++ src/client/api.ts | 9 ++ src/client/index.tsx | 72 +++++++--- src/client/locales.ts | 16 +++ src/client/prefs.ts | 58 +++++++++ src/client/sidebar.module.css | 57 ++++---- src/client/state.ts | 54 +++++--- src/config.ts | 25 ++++ src/context-types.ts | 36 +++++ src/index.ts | 82 +++++++++++- src/prefs-shared.ts | 34 +++++ src/wire.ts | 2 + tests/plugin-shape.spec.ts | 16 +++ tests/smoke.spec.ts | 128 ++++++++++++++++++ tests/unit.spec.ts | 67 +++++++++- 19 files changed, 879 insertions(+), 66 deletions(-) create mode 100644 src/client/SideCardSection.module.css create mode 100644 src/client/SideCardSection.tsx create mode 100644 src/client/prefs.ts create mode 100644 src/prefs-shared.ts diff --git a/README.md b/README.md index 50fd898..1d70468 100644 --- a/README.md +++ b/README.md @@ -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) + ## ⌨️ 快捷键速查 | 操作 | 按键 | diff --git a/package.json b/package.json index c174b89..c78eb69 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5d4a81..c278960 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css new file mode 100644 index 0000000..0ded885 --- /dev/null +++ b/src/client/SideCardSection.module.css @@ -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; + } +} diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx new file mode 100644 index 0000000..a3b3c96 --- /dev/null +++ b/src/client/SideCardSection.tsx @@ -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(() => store.getPrefs()) + const [widthDraft, setWidthDraft] = useState(String(store.getPrefs().defaultWidthPercent)) + const [error, setError] = useState(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(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.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): 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 ( +
+ +
+ + {t('settingsWidthTitle')} + {t('settingsWidthDesc')} + + + { setWidthDraft(event.currentTarget.value) }} + onBlur={commitWidth} + onKeyDown={event => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + /> + {t('settingsWidthSuffix')} + +
+ {error !== null && ( +
+ {error} +
+ )} +
+ ) +} diff --git a/src/client/api.ts b/src/client/api.ts index f0c4c91..afddcb1 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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, expectedRevision?: number) => + call<{ value?: unknown; revision?: number }>('settings.update', { + patch, + ...(expectedRevision !== undefined ? { expectedRevision } : {}), + }), } /** Absolute URL of the media route for one path (images only). */ diff --git a/src/client/index.tsx b/src/client/index.tsx index ca4c37c..1f0f335 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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(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) } diff --git a/src/client/locales.ts b/src/client/locales.ts index a228d16..703da4b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -70,6 +70,14 @@ const zh = { copyRelative: '复制相对地址', copyAbsolute: '复制绝对地址', download: '下载', + settingsNav: '侧边卡片', + settingsOpenTitle: '新会话默认打开', + settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局', + settingsWidthTitle: '默认宽度占比', + settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)', + settingsWidthSuffix: '%', + settingsSaveFailed: '保存失败', + settingsConflict: '设置已被其他窗口修改,请重试', } const en: Record = { @@ -137,6 +145,14 @@ const en: Record = { 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). */ diff --git a/src/client/prefs.ts b/src/client/prefs.ts new file mode 100644 index 0000000..2c2faf6 --- /dev/null +++ b/src/client/prefs.ts @@ -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 + +/** + * 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 + 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 { + 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 } + } +} diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index f13d580..bf7ff78 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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); } diff --git a/src/client/state.ts b/src/client/state.ts index 022492d..c8f42da 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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 diff --git a/src/config.ts b/src/config.ts index 847b0d0..ebd1e4a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 = z.object({ + openByDefault: z.boolean().default(true), + defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), +}) diff --git a/src/context-types.ts b/src/context-types.ts index ec35c7b..b37f76a 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -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( + ns: string, + schema: unknown, + options?: { base?: Partial; applies?: 'live' | 'restart' }, + ): { + get(): T + watch(callback: (next: T, prev: T) => void | Promise): () => void + update(patch: object): Promise + replace(section: object): Promise + } + /** 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 +} + 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 diff --git a/src/index.ts b/src/index.ts index ef86adf..0edcdaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 +/** + * 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, 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 { +function buildApi( + ctx: Context, + ptyManager: PtyManager, + resolved: ResolvedSidebarConfig, + getSettings: () => SidebarSettingsFace | undefined, +): Record { 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, 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', diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts new file mode 100644 index 0000000..e6dd644 --- /dev/null +++ b/src/prefs-shared.ts @@ -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))) +} diff --git a/src/wire.ts b/src/wire.ts index 7d7c6e0..403277f 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -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. */ diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 4872fe1..6b165c0 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -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 | undefined): Record + })(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 | undefined): Record + })({ openByDefault: false, defaultWidthPercent: 45 }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45 }) + }) }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 3240b3a..08d2532 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -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 | undefined + revision: number + }>() + const resolve = (entry: { schema: unknown; value: Record | 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, 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/) + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 385d444..10f1d2a 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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) + }) +})