Files
Menghuan1918 223b2545d9 refactor(shell): retire the plugin's own right panel and free windows
DSH 0.1.5 owns the right column (its native Sidebar hosts every tab type
this plugin registers), so the plugin's own right panel, its width drag /
new-session seed prefs, and the free-window feature are gone. The bottom
workbench is the only plugin-owned surface left, and its expand/collapse
control is registered into DSH's session-header utilities list slot (the
header's corner seat belongs to the native sidebar's own control).

- state: one `bottomSplits` tree; `makeDefaultState()` takes no arguments;
  `toggleBottomPanel` / `openTabInBottomPane` are the only landing
  reducers; `setBottomHeight` and `sanitizeState` share the
  `CONVERSATION_MIN` cap (a persisted tall height no longer snaps on the
  first drag).
- service: every landing in the workbench (creation or focus) expands it;
  `SIDEBAR_FEATURES` drops `'floatWindows'`; the open/close/activate tab
  pools cover the single tree.
- shell: `Sidebar.tsx` renders only the bottom workbench; the toggle lives
  in `sidebar/bottom-toggle.tsx`; layout-push pushes height only (no width,
  no `--dsh-sidebar-width`).
- prefs: `openByDefault` / `defaultWidthPercent` / `changesDiffFloat`
  removed from the schema, defaults, client parser, settings page, and all
  20 dictionaries; the retired keys in stored documents are ignored.
- tests: free-window specs deleted, panel/float assertions dropped,
  remaining suites adapted (124 files / 1287 tests); the mount lane asserts
  the header toggle and sweeps the native sidebar's guide page.
- docs: README / README_EN / guide §0,§11 / AGENTS test map.
2026-09-09 15:18:06 +08:00

262 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pinned-terminal resolver (v0.17.0) — pure functions over cached session
* states. Covers the visibility matrix (global / workspace × cwd match /
* mismatch / both undefined / viewer-only undefined) and the cross-session
* collection (multi-session, exclusion of the viewer's own session, stable
* tree order).
*/
import { describe, expect, it } from 'vitest'
import {
makeDefaultState, openTabInBottomPane, setTabPin, splitPane,
type SidebarState,
} from '../src/client/state.ts'
import { collectPinnedTabs, pinnedVisibleTo, type PinnedViewer,
isPinnedVirtualId, parsePinnedVirtualId, isPinnedVirtualTab, getPinnedHomeScope,
createPinnedVirtualTab, injectPinnedIntoTree,
} from '../src/client/pinned.ts'
/** A pinned terminal tab in a fresh state, opened and pinned in one go. */
function stateWithPinnedTerminal(
id: string,
pin: { scope: 'workspace' | 'global'; homeCwd?: string },
): SidebarState {
let s = makeDefaultState()
s = openTabInBottomPane(s, { id, type: 'terminal', title: id })
return setTabPin(s, id, pin)
}
const viewer = (sessionId: string, cwd?: string): PinnedViewer => ({ sessionId, cwd })
describe('pinnedVisibleTo', () => {
it('returns false for an unpinned tab', () => {
const tab = { id: 't', type: 'terminal', title: 'T' }
expect(pinnedVisibleTo(tab, viewer('s', '/p'))).toBe(false)
})
it('global pin is visible to any session regardless of cwd', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'global' as const } }
expect(pinnedVisibleTo(tab, viewer('s', '/other'))).toBe(true)
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
it('workspace pin matches when cwds are equal', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const, homeCwd: '/p' } }
expect(pinnedVisibleTo(tab, viewer('s', '/p'))).toBe(true)
expect(pinnedVisibleTo(tab, viewer('s', '/q'))).toBe(false)
})
it('workspace pin without homeCwd is visible everywhere (pin set before cwd resolved)', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const } }
expect(pinnedVisibleTo(tab, viewer('s', '/anywhere'))).toBe(true)
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
it('workspace pin is conservatively visible when viewer.cwd is unknown (no hydration flash)', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const, homeCwd: '/p' } }
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
it('both undefined cwds match (legacy pin + unhydrated viewer)', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const, homeCwd: undefined } }
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
})
describe('collectPinnedTabs', () => {
it('returns an empty array when no other session has pinned terminals', () => {
const bySession = new Map<string, SidebarState>([
['s1', stateWithPinnedTerminal('terminal:1', { scope: 'global' })],
])
expect(collectPinnedTabs(bySession, viewer('s1', '/p'))).toEqual([])
})
it('returns an empty array for an empty session cache', () => {
expect(collectPinnedTabs(new Map(), viewer('s1', '/p'))).toEqual([])
})
it('collects a global pin from another session', () => {
const bySession = new Map<string, SidebarState>([
['home', stateWithPinnedTerminal('terminal:1', { scope: 'global' })],
])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/anywhere'))
expect(entries).toHaveLength(1)
expect(entries[0]!.tab.id).toBe('terminal:1')
expect(entries[0]!.homeSessionId).toBe('home')
})
it('collects a workspace pin only when the viewer cwd matches homeCwd', () => {
const home = stateWithPinnedTerminal('terminal:1', { scope: 'workspace', homeCwd: '/proj' })
const bySession = new Map<string, SidebarState>([['home', home]])
expect(collectPinnedTabs(bySession, viewer('viewer', '/proj'))).toHaveLength(1)
expect(collectPinnedTabs(bySession, viewer('viewer', '/elsewhere'))).toHaveLength(0)
})
it('excludes the viewer\'s own session (its pinned tabs are on its own strip)', () => {
const bySession = new Map<string, SidebarState>([
['viewer', stateWithPinnedTerminal('terminal:1', { scope: 'global' })],
['home', stateWithPinnedTerminal('terminal:2', { scope: 'global' })],
])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:2'])
})
it('collects in stable tree order (the workbench split tree, depth-first)', () => {
let home = makeDefaultState()
home = openTabInBottomPane(home, { id: 'terminal:first', type: 'terminal', title: 'A' })
home = setTabPin(home, 'terminal:first', { scope: 'global' })
// A second leaf to the right of the first one: its tabs collect after.
const firstPane = (home.bottomSplits as { id: string }).id
home = splitPane(home, 'row')
home = openTabInBottomPane(home, { id: 'terminal:second', type: 'terminal', title: 'B' })
home = setTabPin(home, 'terminal:second', { scope: 'global' })
expect(firstPane).not.toBe('')
const bySession = new Map<string, SidebarState>([['home', home]])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:first', 'terminal:second'])
})
it('keeps stable insertion order across multiple home sessions', () => {
const bySession = new Map<string, SidebarState>([
['homeA', stateWithPinnedTerminal('terminal:a', { scope: 'global' })],
['homeB', stateWithPinnedTerminal('terminal:b', { scope: 'global' })],
])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:a', 'terminal:b'])
expect(entries.map(e => e.homeSessionId)).toEqual(['homeA', 'homeB'])
})
it('ignores unpinned terminals and non-terminal tabs in other sessions', () => {
let home = makeDefaultState()
home = openTabInBottomPane(home, { id: 'terminal:unpinned', type: 'terminal', title: 'U' })
home = openTabInBottomPane(home, { id: 'editor:1', type: 'editor', title: 'E', path: '/e' })
home = setTabPin(home, 'editor:1', { scope: 'global' }) // defensive: pin only targets terminals
home = openTabInBottomPane(home, { id: 'terminal:pinned', type: 'terminal', title: 'P' })
home = setTabPin(home, 'terminal:pinned', { scope: 'global' })
const bySession = new Map<string, SidebarState>([['home', home]])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:pinned'])
})
})
describe('isPinnedVirtualId / parsePinnedVirtualId', () => {
it('detects a pinned virtual id by prefix', () => {
expect(isPinnedVirtualId('pinned:home:terminal:3')).toBe(true)
expect(isPinnedVirtualId('pinned:abc-123:agent:def-456')).toBe(true)
expect(isPinnedVirtualId('terminal:3')).toBe(false)
expect(isPinnedVirtualId('agent:uuid')).toBe(false)
})
it('parses home session id and original tab id (terminal)', () => {
const { homeSessionId, tabId } = parsePinnedVirtualId('pinned:home-sess:terminal:3')
expect(homeSessionId).toBe('home-sess')
expect(tabId).toBe('terminal:3')
})
it('parses home session id and original tab id (agent)', () => {
const { homeSessionId, tabId } = parsePinnedVirtualId('pinned:abc-123:agent:def-456')
expect(homeSessionId).toBe('abc-123')
expect(tabId).toBe('agent:def-456')
})
})
describe('createPinnedVirtualTab', () => {
it('creates a virtual tab with a prefixed id and home scope in meta', () => {
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'global' as const, homeCwd: '/p' } }
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home' })
expect(vtab.id).toBe('pinned:home:terminal:3')
expect(vtab.type).toBe('terminal')
expect(vtab.title).toBe('T3')
expect(vtab.pin).toEqual({ scope: 'global', homeCwd: '/p' })
})
it('stores the home scope (sessionId, cwd, original tabId) in meta', () => {
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'workspace' as const, homeCwd: '/proj' } }
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home-sess' })
const home = getPinnedHomeScope(vtab)
expect(home).toBeDefined()
expect(home!.sessionId).toBe('home-sess')
expect(home!.cwd).toBe('/proj')
expect(home!.tabId).toBe('terminal:3')
})
it('preserves existing meta fields alongside the pinned home scope', () => {
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'global' as const }, meta: { custom: 42 } }
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home' })
expect((vtab.meta as Record<string, unknown>).custom).toBe(42)
expect(isPinnedVirtualTab(vtab)).toBe(true)
})
it('cwd is undefined for a global pin without homeCwd', () => {
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'global' as const } }
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home' })
expect(getPinnedHomeScope(vtab)!.cwd).toBeUndefined()
})
it('isPinnedVirtualTab returns false for a regular tab', () => {
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3' }
expect(isPinnedVirtualTab(tab)).toBe(false)
})
})
describe('injectPinnedIntoTree', () => {
it('returns the original tree when no pinned tabs and no active override', () => {
const tree = makeDefaultState().bottomSplits
expect(injectPinnedIntoTree(tree, [], null)).toBe(tree)
})
it('appends pinned virtual tabs to the first leaf', () => {
const s = makeDefaultState()
const vtab = createPinnedVirtualTab({
tab: { id: 'terminal:3', type: 'terminal', title: 'T3', pin: { scope: 'global' } },
homeSessionId: 'home',
})
const result = injectPinnedIntoTree(s.bottomSplits, [vtab], null)
expect(result.kind).toBe('leaf')
if (result.kind === 'leaf') {
expect(result.tabs[result.tabs.length - 1]!.id).toBe('pinned:home:terminal:3')
}
})
it('overrides the first leaf active when activePinnedId is set', () => {
const s = openTabInBottomPane(makeDefaultState(), { id: 'terminal:1', type: 'terminal', title: 'T1' })
const vtab = createPinnedVirtualTab({
tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'global' } },
homeSessionId: 'home',
})
const result = injectPinnedIntoTree(s.bottomSplits, [vtab], vtab.id)
if (result.kind === 'leaf') {
expect(result.active).toBe(vtab.id)
expect(result.tabs.map(t => t.id)).toContain(vtab.id)
expect(result.tabs.map(t => t.id)).toContain('terminal:1')
}
})
it('injects into the first child of a split tree', () => {
let s = makeDefaultState()
s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'T1' })
// Create a split (splits the active pane into two)
const leafId = s.bottomSplits.kind === 'leaf' ? s.bottomSplits.id : ''
s = { ...s, bottomSplits: { kind: 'split', id: 'split:1', dir: 'row', sizes: [0.5, 0.5], children: [
{ kind: 'leaf', id: leafId, tabs: s.bottomSplits.kind === 'leaf' ? s.bottomSplits.tabs : [], active: 'terminal:1' },
{ kind: 'leaf', id: 'pane:2', tabs: [], active: null },
] } }
const vtab = createPinnedVirtualTab({
tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'global' } },
homeSessionId: 'home',
})
const result = injectPinnedIntoTree(s.bottomSplits, [vtab], null)
expect(result.kind).toBe('split')
if (result.kind === 'split') {
const first = result.children[0]!
if (first.kind === 'leaf') {
expect(first.tabs.map(t => t.id)).toContain(vtab.id)
}
const second = result.children[1]!
if (second.kind === 'leaf') {
expect(second.tabs.map(t => t.id)).not.toContain(vtab.id)
}
}
})
})