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.
262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
}
|
||
})
|
||
})
|