DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features.
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
/**
|
|
* Interactive tests for the settings popup's text/number/select rows: the
|
|
* draft is local state committed on blur through the parent's onCommit; the
|
|
* parent's canonical return is adopted (clamped numbers, stored value for
|
|
* invalid input), and the row remounts when the committed pref value changes
|
|
* (a failed commit reverts prefs → the stored value reappears). Select rows
|
|
* commit the picked option's value (single) or the picked values in options
|
|
* order (multi) through onSelectValue.
|
|
*
|
|
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
|
|
* side-card-section.spec.tsx; this file exercises the event paths).
|
|
*/
|
|
// @vitest-environment jsdom
|
|
import { describe, expect, it } from 'vitest'
|
|
import { createElement } from 'react'
|
|
import { act } from 'react-dom/test-utils'
|
|
import { renderRoot, setupReactAct } from './test-utils.ts'
|
|
|
|
// The act() environment flag (React 18.2 reads it before flushing effects).
|
|
setupReactAct()
|
|
import type { SidebarSettingToggle } from '../src/client/service.ts'
|
|
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
|
|
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
|
|
|
/** Type into an input and commit it via blur (React 18: input event +
|
|
* focusout). The native setter bypasses React's value tracker so the
|
|
* change is actually seen. */
|
|
function typeAndBlur(input: HTMLInputElement, value: string): void {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
|
act(() => {
|
|
setter.call(input, value)
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
|
|
|
describe('FeatureSettingsRows typed rows (interactive)', () => {
|
|
it('commits the raw text on blur and adopts the canonical return', () => {
|
|
const commits: Array<[string, string]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'titleBarPresetId',
|
|
type: 'text',
|
|
title: () => 'Preset id',
|
|
}
|
|
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
onCommit: (t, raw) => {
|
|
commits.push([t.key, raw])
|
|
return raw
|
|
},
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, 'Monaco')
|
|
expect(commits).toEqual([['titleBarPresetId', 'Monaco']])
|
|
// The canonical return is adopted into the draft.
|
|
expect(input.value).toBe('Monaco')
|
|
unmount()
|
|
})
|
|
|
|
it('clamps numbers into the declared bounds on commit', () => {
|
|
const commits: Array<[string, number]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'titleBarStripPx',
|
|
type: 'number',
|
|
title: () => 'Shift distance',
|
|
min: 0,
|
|
max: 120,
|
|
}
|
|
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: { ...prefs, titleBarStripPx: 40 },
|
|
onToggle: () => {},
|
|
onCommit: (t, raw) => {
|
|
const parsed = Number(raw)
|
|
const clamped = Math.min(120, Math.max(0, Math.round(parsed)))
|
|
commits.push([t.key, clamped])
|
|
return String(clamped)
|
|
},
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
// Above the declared max: the commit clamps to 120.
|
|
typeAndBlur(input, '130')
|
|
expect(commits).toEqual([['titleBarStripPx', 120]])
|
|
expect(input.value).toBe('120')
|
|
unmount()
|
|
})
|
|
|
|
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
|
|
const commits: Array<[string, number]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'titleBarStripPx',
|
|
type: 'number',
|
|
title: () => 'Shift distance',
|
|
min: 0,
|
|
max: 120,
|
|
}
|
|
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: { ...prefs, titleBarStripPx: 40 },
|
|
onToggle: () => {},
|
|
// The parent mirrors the real handler: an emptied number parses to 0
|
|
// and clamps into the bounds (a browser number input never holds a
|
|
// non-numeric string — the draft can only be empty or numeric).
|
|
onCommit: (t, raw) => {
|
|
const clamped = Math.min(120, Math.max(0, Math.round(Number(raw))))
|
|
commits.push([t.key, clamped])
|
|
return String(clamped)
|
|
},
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, '')
|
|
expect(commits).toEqual([['titleBarStripPx', 0]])
|
|
expect(input.value).toBe('0')
|
|
unmount()
|
|
})
|
|
|
|
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'terminalFontFamily',
|
|
type: 'text',
|
|
title: () => 'Font family',
|
|
}
|
|
// The parent mirrors the real handler: the optimistic commit adopts the
|
|
// typed value, and a FAILED write reverts prefs to the stored one.
|
|
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
|
|
const { container, rerender, unmount } = renderRoot(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: prefsNow,
|
|
onToggle: () => {},
|
|
onCommit: (_t, raw) => raw,
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, 'New')
|
|
// Optimistic commit: the draft adopts the typed value.
|
|
expect(input.value).toBe('New')
|
|
// The optimistic pref lands (parent state): the key changes, the row
|
|
// remounts with the same value — no visible reset while editing.
|
|
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
|
|
rerender(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: prefsNow,
|
|
onToggle: () => {},
|
|
onCommit: (_t, raw) => raw,
|
|
}))
|
|
expect(container.querySelector('input')!.value).toBe('New')
|
|
// The write fails: prefs revert to the stored value and the row
|
|
// remounts with it (the stale draft is gone).
|
|
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
|
|
rerender(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: prefsNow,
|
|
onToggle: () => {},
|
|
onCommit: (_t, raw) => raw,
|
|
}))
|
|
expect(container.querySelector('input')!.value).toBe('Old')
|
|
unmount()
|
|
})
|
|
})
|
|
|
|
/** Open one select row's dropdown: click the anchor, the Menu portals its
|
|
* list into document.body (role="menuitem" rows). */
|
|
function openSelect(container: HTMLDivElement): HTMLElement[] {
|
|
const anchor = container.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!
|
|
act(() => { anchor.click() })
|
|
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
}
|
|
|
|
describe('FeatureSettingsRows select rows (interactive)', () => {
|
|
const options: NonNullable<SidebarSettingToggle['options']> = [
|
|
{ value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' },
|
|
{ value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' },
|
|
]
|
|
|
|
it('commits the picked option value and closes (iconed single select)', () => {
|
|
const commits: Array<[string, unknown]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'editorExplorer',
|
|
type: 'select',
|
|
title: () => 'Editor explorer',
|
|
options,
|
|
}
|
|
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
// Merged is the selected option for this scenario (the default is now
|
|
// separate — the anchor must render whatever value the prefs carry).
|
|
prefs: { ...prefs, editorExplorer: true },
|
|
onToggle: () => {},
|
|
onSelectValue: (t, next) => { commits.push([t.key, next]) },
|
|
}))
|
|
// The closed anchor shows the selected option's title (icon variant).
|
|
expect(container.textContent).toContain('Merged')
|
|
const items = openSelect(container)
|
|
// Big-icon cards: title + desc per option.
|
|
expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs'])
|
|
act(() => { items[1]!.click() })
|
|
expect(commits).toEqual([['editorExplorer', false]])
|
|
// Single-pick closes the dropdown.
|
|
expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0)
|
|
unmount()
|
|
})
|
|
|
|
it('toggles membership and commits the picked values in options order (multi)', () => {
|
|
const commits: Array<[string, unknown]> = []
|
|
const plain: NonNullable<SidebarSettingToggle['options']> = [
|
|
{ value: 'a', title: () => 'Alpha' },
|
|
{ value: 'b', title: () => 'Beta' },
|
|
{ value: 'c', title: () => 'Gamma' },
|
|
]
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'pluginKey',
|
|
type: 'select',
|
|
multi: true,
|
|
title: () => 'Pick several',
|
|
options: plain,
|
|
}
|
|
const rows = (value: unknown) => createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
onSelectValue: (t, next) => { commits.push([t.key, next]) },
|
|
valueSource: () => value,
|
|
})
|
|
const { container, rerender, unmount } = renderRoot(rows(['c', 'a']))
|
|
// The anchor shows the picked titles; options follow the declared order.
|
|
const items = openSelect(container)
|
|
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
|
|
// Deselect 'a' → ['c'] is normalized to options order… (['c'] here).
|
|
act(() => { items[0]!.click() })
|
|
expect(commits).toEqual([['pluginKey', ['c']]])
|
|
// The menu stays open under multi; re-render with the committed value.
|
|
rerender(rows(['c']))
|
|
const again = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
expect(again).toHaveLength(3)
|
|
// Select 'b' → committed in OPTIONS order, not pick order.
|
|
act(() => { again[1]!.click() })
|
|
expect(commits[1]).toEqual(['pluginKey', ['b', 'c']])
|
|
unmount()
|
|
})
|
|
|
|
it('shows an em dash when nothing is selected', () => {
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'editorExplorer',
|
|
type: 'select',
|
|
title: () => 'Editor explorer',
|
|
options,
|
|
}
|
|
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
valueSource: () => undefined,
|
|
}))
|
|
expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—')
|
|
unmount()
|
|
})
|
|
})
|