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.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals,
|
||||
allLeaves, makeDefaultState, openTabInBottomPane, reconcileAgentTerminals,
|
||||
setTabPin, tabOpenIn, toggleBottomPanel,
|
||||
} from '../src/client/state.ts'
|
||||
|
||||
describe('agent terminal reconciliation', () => {
|
||||
it('adds tabs for new agent terminals', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'dev server' },
|
||||
{ uuid: 'bbb-222', title: 'python repl' },
|
||||
@@ -14,14 +14,14 @@ describe('agent terminal reconciliation', () => {
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
|
||||
// The titles are preserved.
|
||||
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
|
||||
const tabs = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
|
||||
expect(agentTab?.title).toBe('dev server')
|
||||
expect(agentTab?.type).toBe('terminal')
|
||||
})
|
||||
|
||||
it('removes tabs for agent terminals that vanished from the server list', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'keep' },
|
||||
{ uuid: 'bbb-222', title: 'remove' },
|
||||
@@ -34,22 +34,22 @@ describe('agent terminal reconciliation', () => {
|
||||
})
|
||||
|
||||
it('is a no-op (same reference) when the lists already match', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
|
||||
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
|
||||
expect(next).toBe(s)
|
||||
})
|
||||
|
||||
it('does not touch non-agent terminal tabs', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
|
||||
let s = makeDefaultState()
|
||||
s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles an empty server list (removes all agent tabs)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'a' },
|
||||
{ uuid: 'bbb-222', title: 'b' },
|
||||
@@ -59,20 +59,19 @@ describe('agent terminal reconciliation', () => {
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
|
||||
})
|
||||
|
||||
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
it('lands new agent terminals in the workbench pane', () => {
|
||||
let s = makeDefaultState()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane }
|
||||
const pane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: pane }
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
|
||||
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
|
||||
})
|
||||
|
||||
describe('pinned agent terminal exemption (v0.17.0)', () => {
|
||||
it('keeps a pinned agent tab when its uuid vanishes from the server list', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'keep' },
|
||||
{ uuid: 'bbb-222', title: 'pinned' },
|
||||
@@ -84,13 +83,13 @@ describe('agent terminal reconciliation', () => {
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
|
||||
// The pin marker is intact.
|
||||
const tabs = allLeaves(s.splits).flatMap(l => l.tabs)
|
||||
const tabs = allLeaves(s.bottomSplits).flatMap(l => l.tabs)
|
||||
const pinned = tabs.find(t => t.id === 'agent:bbb-222')!
|
||||
expect(pinned.pin).toEqual({ scope: 'global' })
|
||||
})
|
||||
|
||||
it('still removes an unpinned agent tab when the uuid vanishes', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [
|
||||
{ uuid: 'aaa-111', title: 'pinned' },
|
||||
{ uuid: 'bbb-222', title: 'unpinned' },
|
||||
@@ -103,7 +102,7 @@ describe('agent terminal reconciliation', () => {
|
||||
})
|
||||
|
||||
it('a pinned tab whose uuid RETURNS in a later push is a no-op (already exists)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'gone' }])
|
||||
s = setTabPin(s, 'agent:aaa-111', { scope: 'global' })
|
||||
// First push without it: exempted, stays.
|
||||
@@ -117,7 +116,7 @@ describe('agent terminal reconciliation', () => {
|
||||
})
|
||||
|
||||
it('exemption does not block the toAdd path (new uuids still land)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
let s = makeDefaultState()
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'first' }])
|
||||
s = setTabPin(s, 'agent:aaa-111', { scope: 'global' })
|
||||
// aaa-111 vanishes (exempted), bbb-222 is brand-new.
|
||||
|
||||
+5
-14
@@ -44,18 +44,9 @@ describe('built-in tab registrations', () => {
|
||||
expect(changes?.component).toBeDefined()
|
||||
})
|
||||
|
||||
it('the changes tab declares the diff-open picker (free window default)', () => {
|
||||
it('the changes tab declares no settings of its own (the diff always docks)', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('git')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['changesDiffFloat'])
|
||||
const picker = toggles[0]
|
||||
expect(picker?.type).toBe('select')
|
||||
expect(picker?.title).toBeDefined()
|
||||
expect(picker?.desc).toBeDefined()
|
||||
const options = picker?.options ?? []
|
||||
// Float first (the default the reducer and prefs ship), pane second.
|
||||
expect(options.map(o => o.value)).toEqual([true, false])
|
||||
expect(options.every(o => o.icon !== undefined && o.title !== undefined && o.desc !== undefined)).toBe(true)
|
||||
expect(service.getTab('git')?.settings).toBeUndefined()
|
||||
})
|
||||
|
||||
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
|
||||
@@ -172,7 +163,7 @@ describe('built-in tab registrations', () => {
|
||||
service.openTab({ type: 'browser' })
|
||||
service.openTab({ type: 'browser' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('browser:1')
|
||||
expect(tabs[1]!.id).toBe('browser:2')
|
||||
@@ -185,7 +176,7 @@ describe('built-in tab registrations', () => {
|
||||
service.openTab({ type: 'terminal' })
|
||||
service.openTab({ type: 'terminal' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.title).toBe('bash')
|
||||
expect(tabs[1]!.title).toBe('bash')
|
||||
@@ -199,7 +190,7 @@ describe('built-in tab registrations', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'terminal' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
|
||||
const tab = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
|
||||
expect(tab?.title).toBe(t('terminal'))
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const tab: TabDescriptor = {
|
||||
icon: (_size: number) => null,
|
||||
order: 50,
|
||||
hidden: false,
|
||||
available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null,
|
||||
available: (ctx, scope, state) => scope.sessionId !== '' && state.bottomOpen && ctx !== null,
|
||||
single: false,
|
||||
dedupeKey: (t: SidebarTab) => t.id,
|
||||
createTab: (state: SidebarState) => ({
|
||||
|
||||
+14
-7
@@ -13,8 +13,8 @@
|
||||
* plugin's `[data-dsh-better-sidebar]` host mount;
|
||||
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
|
||||
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
|
||||
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
|
||||
* built-in tab (Files / Changes / Tasks / Terminal / Browser) —
|
||||
* 4. expands DSH's native right Sidebar, sweeps every built-in tab type
|
||||
* through its guide page (Files / Changes / Tasks / Terminal / Browser) —
|
||||
* including the lazily-fetched terminal chunk — and then opens seeded
|
||||
* files through the Files window's tree (separate mode: each file opens
|
||||
* its own new tab, the seeded home "Files" tab stays the explorer),
|
||||
@@ -211,7 +211,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
}
|
||||
|
||||
// The seeded session must give the sidebar a session scope: without it the
|
||||
// shell renders a disabled toggle cluster and the tab sweep is impossible.
|
||||
// workbench has no pane to render and the tab sweep is impossible.
|
||||
const tabBar = sidebar.locator('[title]')
|
||||
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
@@ -257,6 +257,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
// starts blank, so give it one message first.
|
||||
await sendFirstMessage(page)
|
||||
|
||||
// The plugin's own surface is the bottom workbench: its expand/collapse
|
||||
// control is registered into DSH's session-header utilities (the header's
|
||||
// corner belongs to the native sidebar), and the workbench host itself is
|
||||
// mounted. Both are stable addressing surfaces for user CSS / presets.
|
||||
await expect(page.locator('[data-dsh-bottom-toggle]')).toBeAttached({ timeout: 30_000 })
|
||||
await expect(page.locator('[data-dsh-bottom-panel]')).toBeAttached()
|
||||
|
||||
// DSH 0.1.5 owns the right column: the plugin contributes tab TYPES to the
|
||||
// host's native right Sidebar instead of drawing its own panel. Open it
|
||||
// through the host's own control (the conversation header's corner) and
|
||||
@@ -485,10 +492,10 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('')
|
||||
// The stable addressing surface for presets / custom CSS is mounted: the
|
||||
// plugin's own host plus its bottom workbench (the right column belongs to
|
||||
// DSH's native Sidebar on 0.1.5).
|
||||
await expect(page.locator('[data-dsh-toggle-cluster]')).toBeAttached()
|
||||
await expect(page.locator('[data-dsh-bottom-panel]')).toBeAttached()
|
||||
// plugin's own host (its bottom workbench and the header toggle live in
|
||||
// DSH's session header, which this stamp-only page has no session for —
|
||||
// the native-surface sweep asserts those once a session exists).
|
||||
await expect(page.locator('[data-dsh-panel-host]')).toBeAttached()
|
||||
// The plugin's interactive chrome opts out of Electron drag regions
|
||||
// (issues #103/#111) — inert in plain browsers, present in the bundle
|
||||
// (the bundler minifies the property's whitespace, so match loosely).
|
||||
|
||||
+13
-6
@@ -14,7 +14,8 @@
|
||||
* for everything sidebar-shaped the page fetched;
|
||||
* 4. bottom-drag frames — rAF frame-interval p95/max while dragging the
|
||||
* bottom strip (the layout-push hot path), plus the width-leak guard
|
||||
* from the drag lane (a closed right panel must push 0).
|
||||
* from the drag lane (this shell pushes no width at all: the right
|
||||
* column belongs to DSH's native Sidebar).
|
||||
*
|
||||
* Results print as single-line `PERF_JSON` records (plus a `PERF_SUMMARY`
|
||||
* aggregate) so a shell run can `tee` the log and grep them out; the
|
||||
@@ -186,10 +187,16 @@ test('measure: bottom-strip drag frame pacing', async ({ page }) => {
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
await dismissOnboarding(page)
|
||||
|
||||
// The right panel stays CLOSED on purpose: the bottom drag must not push
|
||||
// the host layout in that pose (the width-leak regression the drag lane
|
||||
// locks; recorded here as a number-adjacent guard for the perf story).
|
||||
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
|
||||
// The workbench's expand/collapse control is registered into DSH's
|
||||
// session-header utilities, which DSH renders only for a session with
|
||||
// content — seed one message first, then address the toggle by its stable
|
||||
// data attribute (it is NOT inside the plugin's own host).
|
||||
await sendFirstMessage(page)
|
||||
|
||||
// The plugin pushes no width ever (the right column is DSH's native
|
||||
// Sidebar), so the drag must never write --dsh-sidebar-width — the guard
|
||||
// the drag lane locks, recorded here for the perf story.
|
||||
const bottomExpand = page.locator('[data-dsh-bottom-toggle]')
|
||||
await expect(bottomExpand).toHaveCount(1)
|
||||
await bottomExpand.click()
|
||||
await expect
|
||||
@@ -273,5 +280,5 @@ test('measure: bottom-strip drag frame pacing', async ({ page }) => {
|
||||
`PERF_SUMMARY drag-frames=${record.frames} p50=${record.intervalMedianMs}ms p95=${record.intervalP95Ms}ms `
|
||||
+ `max=${record.intervalMaxMs}ms widthLeak=${record.pushWidthLeakMax}px`,
|
||||
)
|
||||
expect(record.pushWidthLeakMax, 'the closed right panel must push 0 width during the bottom drag').toBe(0)
|
||||
expect(record.pushWidthLeakMax, 'the plugin must never push a width during the bottom drag').toBe(0)
|
||||
})
|
||||
|
||||
@@ -34,8 +34,13 @@ function setup(): {
|
||||
// The openTab path needs a registered editor descriptor (dedupe by path).
|
||||
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
|
||||
store.setSession('editor-home-session')
|
||||
// The workbench seeds EMPTY (the right panel that used to carry the default
|
||||
// files window is DSH's native Sidebar now), so these editor-host scenarios
|
||||
// open the path-less files window explicitly — exactly what the shell does
|
||||
// when the files page is opened.
|
||||
service.openTab({ type: 'editor', title: 'Files', meta: { treeOpen: true } })
|
||||
const homeTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.type === 'editor' && tab.path === undefined)!
|
||||
// openSidebarFile reads the session cwd from ctx.sessions.
|
||||
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
|
||||
@@ -149,14 +154,14 @@ describe('EditorHost (files window)', () => {
|
||||
typeAndCommit(container.querySelector('input')!, '/tmp/a.ts')
|
||||
// The same tab id now carries the file (homeTab's path-less finder no
|
||||
// longer matches — look the tab up by id).
|
||||
const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
const after = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.id === before.id)!
|
||||
expect(after.id).toBe(before.id)
|
||||
expect(after.path).toBe('/tmp/a.ts')
|
||||
expect(after.title).toBe('a.ts')
|
||||
expect(after.meta).toEqual({ treeOpen: true })
|
||||
// No new tab landed.
|
||||
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
|
||||
expect(allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
@@ -167,12 +172,12 @@ describe('EditorHost (files window)', () => {
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
|
||||
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/a.ts')!
|
||||
const { container, unmount } = mountHost(ctx, store, fileTab)
|
||||
try {
|
||||
typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
// home + a.ts + b.ts
|
||||
expect(tabs).toHaveLength(3)
|
||||
expect(fileTab().path).toBe('/tmp/a.ts')
|
||||
@@ -210,7 +215,7 @@ describe('EditorHost (files window)', () => {
|
||||
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
|
||||
})
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/a.ts')!
|
||||
const { container, unmount } = mountHost(ctx, store, fileTab)
|
||||
try {
|
||||
@@ -281,7 +286,7 @@ describe('EditorHost (files window)', () => {
|
||||
})
|
||||
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/x.fake')!
|
||||
const { container, unmount } = mountHost(ctx, store, fileTab)
|
||||
try {
|
||||
@@ -311,7 +316,7 @@ describe('EditorHost (files window)', () => {
|
||||
meta: { dir: true },
|
||||
}, { sessionId: 'editor-home-session' })
|
||||
const dirTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/work/src')!
|
||||
const { container, unmount } = mountHost(ctx, store, dirTab)
|
||||
try {
|
||||
|
||||
@@ -84,7 +84,7 @@ function setup(initialMode: 'preview' | 'edit' = 'preview', dirty = false): {
|
||||
} as unknown as Context
|
||||
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
|
||||
const fileTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/tmp/a.ts')!
|
||||
return { ctx, fileTab }
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createSidebarStore, floatTab } from '../src/client/state.ts'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('restored free-window ids', () => {
|
||||
it('seeds the shared uid counter past persisted float ids before minting another window', () => {
|
||||
localStorage.setItem('dsh-sidebar:v1:s1', JSON.stringify({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
activePane: 'pane:1',
|
||||
nextTerminal: 1,
|
||||
nextBrowser: 1,
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'tab:2',
|
||||
tabs: [{ id: 'tab:2', type: 'browser', title: 'Docked' }],
|
||||
},
|
||||
bottomOpen: false,
|
||||
bottomHeight: 220,
|
||||
bottomOpenedOnce: false,
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:3',
|
||||
active: null,
|
||||
tabs: [],
|
||||
},
|
||||
floats: [{
|
||||
id: 'float:5',
|
||||
tab: { id: 'tab:4', type: 'browser', title: 'Existing float' },
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 390,
|
||||
h: 500,
|
||||
}],
|
||||
}))
|
||||
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
store.reduce(state => floatTab(state, 'tab:2', 400, 300))
|
||||
|
||||
const ids = store.getSnapshot().state!.floats.map(float => float.id)
|
||||
expect(ids).toEqual(['float:5', 'float:6'])
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
})
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* Free-window interaction tests, against the REAL Sidebar shell + real
|
||||
* store/service (the sidebar-crash spec's mount pattern).
|
||||
*
|
||||
* 1. Drag-out detection: while a tab drag (the body flag the tab strips
|
||||
* maintain) hovers the conversation column, a hint overlay marks the drop
|
||||
* zone; the drop floats the tab at the release point and the pane loses
|
||||
* it. Targets inside the panel host stay the panes' own business.
|
||||
* 2. The tab context menu's "move to free window" floats the tab at the
|
||||
* conversation column's center (no drop point exists).
|
||||
* 3. The window itself: header drag moves (rAF direct writes, store commit
|
||||
* on release), the SE corner resizes, releasing the header over a pane
|
||||
* docks the tab back (the pane highlights live), and the X closes the tab
|
||||
* with its window.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
import { Sidebar } from '../src/client/Sidebar.tsx'
|
||||
import { createSidebarStore, togglePanel, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { serializeDrag } from '../src/client/TabBar.tsx'
|
||||
|
||||
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
|
||||
class FakeWebSocket {
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
close = (): void => {}
|
||||
constructor(_url: string) {}
|
||||
}
|
||||
|
||||
/** The app shell's center column: #root > col > [data-slot="conversation"],
|
||||
* with a real-looking rect so the drag-out zone can be hit-tested. */
|
||||
const CONV_RECT = { left: 100, right: 700, top: 50, bottom: 600, width: 600, height: 550 }
|
||||
|
||||
function fakeConversationColumn(): HTMLElement {
|
||||
const rootEl = document.createElement('div')
|
||||
rootEl.id = 'root'
|
||||
const col = document.createElement('div')
|
||||
const conv = document.createElement('div')
|
||||
conv.setAttribute('data-slot', 'conversation')
|
||||
col.append(conv)
|
||||
rootEl.append(col)
|
||||
document.body.append(rootEl)
|
||||
col.getBoundingClientRect = () => CONV_RECT as DOMRect
|
||||
return col
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
container: HTMLDivElement
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/** Unique per-test session ids (see the comment inside). */
|
||||
let sessionSeq = 0
|
||||
|
||||
function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted {
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
if (!('ResizeObserver' in globalThis)) {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
})
|
||||
}
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
single: true,
|
||||
// Pins the visibility contract into the DOM: plugin components honor
|
||||
// `visible` to pause work, so the float tests can assert what a
|
||||
// registering plugin would actually receive.
|
||||
component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'),
|
||||
})
|
||||
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
|
||||
// The default session id is unique per test: the store persists
|
||||
// per-session state to localStorage on a 200ms debounce, and a shared id
|
||||
// let a PREVIOUS test's late timer write leak into this store's
|
||||
// setSession restore (the portaled-menu/resize flakes — a stale float at
|
||||
// the old drop point won the querySelector race on slow runners). The
|
||||
// persistence round-trip test passes its own fixed id instead.
|
||||
store.setSession(sessionId)
|
||||
const localeSnapshot = { active: 'en' }
|
||||
const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } }
|
||||
const ctx = {
|
||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
|
||||
betterSidebar: service,
|
||||
get: (name: string) => name === 'betterSidebar' ? service : undefined,
|
||||
}
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
|
||||
return {
|
||||
container,
|
||||
store,
|
||||
service,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A document-level drag event with a payload-carrying dataTransfer (jsdom
|
||||
* has no DragEvent/DataTransfer). Dispatched AT document, where the shell's
|
||||
* capture listeners live. */
|
||||
function dispatchDrag(type: 'dragover' | 'drop', x: number, y: number, payload?: string): void {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'clientX', { value: x })
|
||||
Object.defineProperty(event, 'clientY', { value: y })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { getData: () => payload ?? '' } })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const flushFrame = async (): Promise<void> => {
|
||||
await act(async () => { await new Promise<void>(resolve => requestAnimationFrame(() => resolve())) })
|
||||
}
|
||||
|
||||
const paneTabs = (store: SidebarStore): string[] => {
|
||||
const state = store.getSnapshot().state!
|
||||
const leaves = [state.splits, state.bottomSplits]
|
||||
return leaves.flatMap(tree => (tree.kind === 'leaf' ? [tree] : allLeafList(tree)))
|
||||
.flatMap(leaf => leaf.tabs.map(tab => tab.id))
|
||||
}
|
||||
const allLeafList = (node: import('../src/client/state.ts').SplitNode): Array<{ tabs: Array<{ id: string }> }> =>
|
||||
node.kind === 'leaf' ? [node] : node.children.flatMap(allLeafList)
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.documentElement.style.cssText = ''
|
||||
vi.unstubAllGlobals()
|
||||
// Belt and braces (after unstub, so a test's storage stub never sees it):
|
||||
// drop any persisted layout a pending 200ms debounce write left behind
|
||||
// between tests — unique session ids already isolate the stores.
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('free windows: drag-out detection', () => {
|
||||
it('hovering the conversation column shows the hint; the drop floats the tab there', () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
const before = paneTabs(store)
|
||||
expect(before).toContain('notes')
|
||||
// A tab drag is active (the tab strips maintain this body flag).
|
||||
document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||
// Outside the column: no hint.
|
||||
act(() => { dispatchDrag('dragover', 50, 20) })
|
||||
expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
|
||||
// Over the column: the hint overlay appears at the column's rect.
|
||||
act(() => { dispatchDrag('dragover', 400, 300) })
|
||||
const hint = container.querySelector<HTMLElement>('[class*="floatDropHint"]')!
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint.style.left).toBe('100px')
|
||||
expect(hint.style.width).toBe('600px')
|
||||
// The drop floats the tab at the release point.
|
||||
act(() => {
|
||||
dispatchDrag('drop', 400, 300, serializeDrag({ tabId: 'notes', paneId: 'pane:1' }))
|
||||
})
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.floats).toHaveLength(1)
|
||||
expect(state.floats[0]!.tab.id).toBe('notes')
|
||||
expect(paneTabs(store)).not.toContain('notes')
|
||||
// The hint is gone and the window is rendered.
|
||||
expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
|
||||
expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull()
|
||||
// dragend clears any lingering state.
|
||||
document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||
act(() => { dispatchDrag('dragover', 400, 300) })
|
||||
expect(container.querySelector('[class*="floatDropHint"]')).not.toBeNull()
|
||||
act(() => { window.dispatchEvent(new Event('dragend')) })
|
||||
expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
|
||||
document.body.removeAttribute('data-dsh-tab-dragging')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops without the hint armed are left to the panes (no float)', () => {
|
||||
fakeConversationColumn()
|
||||
const { store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||
// A drop with NO preceding dragover over the column (e.g. directly on
|
||||
// a pane) must not float anything.
|
||||
act(() => {
|
||||
dispatchDrag('drop', 400, 300, serializeDrag({ tabId: 'notes', paneId: 'pane:1' }))
|
||||
})
|
||||
expect(store.getSnapshot().state!.floats).toHaveLength(0)
|
||||
expect(paneTabs(store)).toContain('notes')
|
||||
document.body.removeAttribute('data-dsh-tab-dragging')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('free windows: the window', () => {
|
||||
it('the tab context menu floats the tab at the conversation center', () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
// The strip holds the seeded home tab first; Notes is the LAST tab.
|
||||
const tabs = [...container.querySelectorAll<HTMLElement>('[class*="tabList"] > [class*="tab"]')]
|
||||
const tab = tabs.at(-1)!
|
||||
act(() => { tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })) })
|
||||
// The float entry is the FIRST menu row.
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
act(() => { items[0]!.click() })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.floats).toHaveLength(1)
|
||||
expect(state.floats[0]!.tab.id).toBe('notes')
|
||||
// Born at the conversation column's center with the phone-ratio
|
||||
// default (390x780), created capped to the jsdom viewport (h 744) and
|
||||
// clamped in (the centered y would be negative).
|
||||
expect(state.floats[0]).toMatchObject({ x: 400 - 195, y: 0, w: 390, h: 744 })
|
||||
expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('header drag moves the window (rAF direct writes, store commit on release)', async () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
|
||||
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
|
||||
expect(win.style.left).toBe('317px')
|
||||
// Grab at (300, 220) — the drag records the window's own origin, so
|
||||
// moving the pointer to (200, 250) lands the window at (217, 42) —
|
||||
// clamped back up to y 24 (the 744-tall window must stay in the
|
||||
// 768-tall viewport: 768 - 744 = 24).
|
||||
act(() => {
|
||||
header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
|
||||
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 }))
|
||||
})
|
||||
await flushFrame()
|
||||
expect(win.style.left).toBe('217px')
|
||||
expect(win.style.top).toBe('24px')
|
||||
// The store is untouched mid-drag (DOM is the only record).
|
||||
expect(store.getSnapshot().state!.floats[0]!.x).toBe(317)
|
||||
act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200, clientY: 250, button: 0 })) })
|
||||
expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 217, y: 24 })
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('the SE corner resizes the window and persists on release', async () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
|
||||
const handle = win.querySelector<HTMLElement>('[class*="floatResize"]')!
|
||||
act(() => {
|
||||
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 700, clientY: 750, button: 0 }))
|
||||
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 800, clientY: 800 }))
|
||||
})
|
||||
await flushFrame()
|
||||
// +100/+50 from the created 390x744: height clamps to the viewport
|
||||
// ceiling (768 - y 12 = 756).
|
||||
expect(win.style.width).toBe('490px')
|
||||
expect(win.style.height).toBe('756px')
|
||||
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 800, clientY: 800, button: 0 })) })
|
||||
expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 490, h: 756 })
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('a pointerdown on the portaled header menu must not start a header drag', async () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
|
||||
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
|
||||
expect(win.style.left).toBe('317px')
|
||||
act(() => { header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 220 })) })
|
||||
// The menu list is PORTALED to document.body, yet its events still
|
||||
// bubble through the React tree into the header's onPointerDown —
|
||||
// without the containment guard (real-browser regression, e2e-caught:
|
||||
// the hijacked drag preventDefaults the row's pointerdown and captures
|
||||
// the pointer, so the row's click never fires) this starts a move.
|
||||
const dockRow = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(row => /回到侧边栏|Dock Back to Sidebar/.test(row.textContent ?? ''))!
|
||||
act(() => {
|
||||
dockRow.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
|
||||
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 }))
|
||||
})
|
||||
await flushFrame()
|
||||
expect(win.style.left).toBe('317px')
|
||||
act(() => { dockRow.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(store.getSnapshot().state!.floats).toHaveLength(0)
|
||||
expect(paneTabs(store)).toContain('notes')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('a floated plugin tab stays visible while the panel collapses (placement contract)', () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
|
||||
expect(win.querySelector<HTMLElement>('[data-visible]')!.dataset.visible).toBe('true')
|
||||
// Collapse the sidebar: the pane's seeded home tab goes invisible
|
||||
// (panel closed), but the float is its own surface — a registering
|
||||
// plugin's component must keep receiving visible=true (AGENTS §7.5).
|
||||
act(() => { store.reduce(togglePanel) })
|
||||
expect(win.querySelector<HTMLElement>('[data-visible]')!.dataset.visible).toBe('true')
|
||||
// (No pane-side control: the pane's remaining tab is the builtin editor
|
||||
// home tab, which does not surface `visible` into the DOM.)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('releasing the header over a pane docks the tab back (live pane highlight)', async () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
|
||||
const header = win.querySelector<HTMLElement>('[class*="floatHeader"]')!
|
||||
// The workbench pane covers (600, 400) for the hit-test.
|
||||
const pane = container.querySelector<HTMLElement>('[data-dsh-pane]')!
|
||||
pane.getBoundingClientRect = () => ({ left: 550, right: 700, top: 350, bottom: 500, width: 150, height: 150 }) as DOMRect
|
||||
act(() => {
|
||||
header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
|
||||
header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 600, clientY: 400 }))
|
||||
})
|
||||
await flushFrame()
|
||||
// The dock target highlights while hovered.
|
||||
expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(true)
|
||||
act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 600, clientY: 400, button: 0 })) })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.floats).toHaveLength(0)
|
||||
expect(paneTabs(store)).toContain('notes')
|
||||
expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(false)
|
||||
expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('the X button closes the tab with its window', () => {
|
||||
fakeConversationColumn()
|
||||
const { container, store, service, unmount } = mountSidebar()
|
||||
try {
|
||||
act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
const win = container.querySelector<HTMLElement>('[data-dsh-float-window]')!
|
||||
const close = win.querySelector<HTMLElement>('[class*="floatClose"]')!
|
||||
act(() => { close.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.floats).toHaveLength(0)
|
||||
expect(paneTabs(store)).not.toContain('notes')
|
||||
expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('a reload round-trip restores the window (localStorage persistence)', () => {
|
||||
fakeConversationColumn()
|
||||
const saved = new Map<string, string>()
|
||||
const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
|
||||
const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
|
||||
vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
|
||||
const first = mountSidebar('s1')
|
||||
try {
|
||||
act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
|
||||
act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
|
||||
// The debounced persist fires on a later tick — run it synchronously.
|
||||
const state = first.store.getSnapshot().state!
|
||||
saved.set('dsh-sidebar:v1:s1', JSON.stringify(state))
|
||||
} finally {
|
||||
first.unmount()
|
||||
}
|
||||
const second = mountSidebar('s1')
|
||||
try {
|
||||
const state = second.store.getSnapshot().state!
|
||||
expect(state.floats).toHaveLength(1)
|
||||
expect(state.floats[0]!.tab.id).toBe('notes')
|
||||
expect(second.container.querySelector('[data-dsh-float-window]')).not.toBeNull()
|
||||
} finally {
|
||||
second.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
+20
-82
@@ -1,99 +1,37 @@
|
||||
/**
|
||||
* Layout-push size contract: the conversation column (output + composer)
|
||||
* must keep at least PANEL_MIN of the viewport after the bottom panel
|
||||
* claims its height. A stale persisted bottomHeight (or a cap at 100vh)
|
||||
* used to crush the composer off-screen.
|
||||
* must keep at least CONVERSATION_MIN of the viewport after the bottom
|
||||
* workbench claims its height. A stale persisted bottomHeight (or a cap at
|
||||
* 100vh) used to crush the composer off-screen. The right column belongs to
|
||||
* DSH's native Sidebar, so this shell pushes no width at all.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BOTTOM_MIN, PANEL_MIN } from '../src/client/state.ts'
|
||||
import { layoutPushSize } from '../src/client/layout-push.ts'
|
||||
import { BOTTOM_MIN, CONVERSATION_MIN } from '../src/client/state.ts'
|
||||
import { bottomPushHeight } from '../src/client/layout-push.ts'
|
||||
|
||||
describe('layoutPushSize', () => {
|
||||
it('pushes nothing on a narrow drawer viewport', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: true,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 400,
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 700,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
describe('bottomPushHeight', () => {
|
||||
it('pushes nothing while the workbench is collapsed', () => {
|
||||
expect(bottomPushHeight({ open: false, height: 400, viewportHeight: 800 })).toBe(0)
|
||||
})
|
||||
|
||||
it('pushes nothing when both panels are collapsed', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: false,
|
||||
bottomOpen: false,
|
||||
width: 400,
|
||||
bottomHeight: 400,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
|
||||
it('caps an oversize bottom panel so the conversation keeps PANEL_MIN', () => {
|
||||
it('caps an oversize workbench so the conversation keeps CONVERSATION_MIN', () => {
|
||||
const viewportHeight = 800
|
||||
const size = layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: false,
|
||||
bottomOpen: true,
|
||||
width: 0,
|
||||
bottomHeight: 10_000,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight,
|
||||
})
|
||||
expect(size.height).toBe(viewportHeight - PANEL_MIN)
|
||||
expect(size.height).toBeGreaterThanOrEqual(BOTTOM_MIN)
|
||||
expect(viewportHeight - size.height).toBeGreaterThanOrEqual(PANEL_MIN)
|
||||
const height = bottomPushHeight({ open: true, height: 10_000, viewportHeight })
|
||||
expect(height).toBe(viewportHeight - CONVERSATION_MIN)
|
||||
expect(height).toBeGreaterThanOrEqual(BOTTOM_MIN)
|
||||
expect(viewportHeight - height).toBeGreaterThanOrEqual(CONVERSATION_MIN)
|
||||
})
|
||||
|
||||
it('caps an oversize right panel at the viewport width', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: false,
|
||||
width: 10_000,
|
||||
bottomHeight: 0,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 1280, height: 0 })
|
||||
it('passes an in-range open height through', () => {
|
||||
expect(bottomPushHeight({ open: true, height: 220, viewportHeight: 800 })).toBe(220)
|
||||
})
|
||||
|
||||
it('passes through in-range open sizes', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 220,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 400, height: 220 })
|
||||
})
|
||||
|
||||
it('never pushes beyond a viewport smaller than the normal panel minima', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 220,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 200,
|
||||
})).toEqual({ width: 200, height: 0 })
|
||||
it('never pushes beyond a viewport smaller than the normal minima', () => {
|
||||
expect(bottomPushHeight({ open: true, height: 220, viewportHeight: 200 })).toBe(0)
|
||||
})
|
||||
|
||||
it('turns non-finite geometry into a safe zero push', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: Number.NaN,
|
||||
bottomHeight: Number.POSITIVE_INFINITY,
|
||||
viewportWidth: Number.NaN,
|
||||
viewportHeight: Number.POSITIVE_INFINITY,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
expect(bottomPushHeight({ open: true, height: Number.POSITIVE_INFINITY, viewportHeight: Number.NaN })).toBe(0)
|
||||
expect(bottomPushHeight({ open: true, height: Number.NaN, viewportHeight: 800 })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ function setup(): {
|
||||
component: FakeMarkdownViewer,
|
||||
})
|
||||
store.setSession('markdown-manual-refresh-session')
|
||||
const home = allLeaves(store.getSnapshot().state!.splits)
|
||||
const home = allLeaves(store.getSnapshot().state!.bottomSplits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.find(candidate => candidate.type === 'editor')!
|
||||
const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' }
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('submenuFlipTokens', () => {
|
||||
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
|
||||
})
|
||||
|
||||
it('flips left throughout a narrow panel viewport (PANEL_MIN 280)', () => {
|
||||
it('flips left throughout a narrow viewport', () => {
|
||||
// A 600px window: any cursor past x=200 flips; the left strip cannot
|
||||
// fit a right-growing submenu beside the 218px card either way.
|
||||
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
|
||||
|
||||
@@ -23,12 +23,10 @@ function typesOf(node: unknown): string[] {
|
||||
describe('sanitize keeps unregistered tab types', () => {
|
||||
it('preserves a persisted tab of an unknown type with its title/path', () => {
|
||||
const raw = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: null,
|
||||
expanded: [],
|
||||
splits: {
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
tabs: [
|
||||
@@ -40,21 +38,19 @@ describe('sanitize keeps unregistered tab types', () => {
|
||||
}
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
expect(typesOf(clean!.splits)).toContain('my-plugin:db')
|
||||
expect(typesOf(clean!.bottomSplits)).toContain('my-plugin:db')
|
||||
// The kept tab keeps its payload so a later registration can reuse it.
|
||||
const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
|
||||
const leaf = clean!.bottomSplits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
|
||||
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
|
||||
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
|
||||
})
|
||||
|
||||
it('an unknown type alone in a pane still sanitizes cleanly', () => {
|
||||
const raw = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: null,
|
||||
expanded: [],
|
||||
splits: {
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
|
||||
@@ -63,7 +59,7 @@ describe('sanitize keeps unregistered tab types', () => {
|
||||
}
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
|
||||
expect(typesOf(clean!.bottomSplits)).toEqual(['some-plugin:page'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+25
-30
@@ -3,11 +3,11 @@
|
||||
* 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, floats).
|
||||
* tree order).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
floatTab, makeDefaultState, openTabInActivePane, setTabPin, toggleBottomPanel,
|
||||
makeDefaultState, openTabInBottomPane, setTabPin, splitPane,
|
||||
type SidebarState,
|
||||
} from '../src/client/state.ts'
|
||||
import { collectPinnedTabs, pinnedVisibleTo, type PinnedViewer,
|
||||
@@ -21,7 +21,7 @@ function stateWithPinnedTerminal(
|
||||
pin: { scope: 'workspace' | 'global'; homeCwd?: string },
|
||||
): SidebarState {
|
||||
let s = makeDefaultState()
|
||||
s = openTabInActivePane(s, { id, type: 'terminal', title: id })
|
||||
s = openTabInBottomPane(s, { id, type: 'terminal', title: id })
|
||||
return setTabPin(s, id, pin)
|
||||
}
|
||||
|
||||
@@ -100,25 +100,20 @@ describe('collectPinnedTabs', () => {
|
||||
expect(entries.map(e => e.tab.id)).toEqual(['terminal:2'])
|
||||
})
|
||||
|
||||
it('collects in stable tree order: splits → bottomSplits → floats', () => {
|
||||
it('collects in stable tree order (the workbench split tree, depth-first)', () => {
|
||||
let home = makeDefaultState()
|
||||
// Right tree tab first.
|
||||
home = openTabInActivePane(home, { id: 'terminal:right', type: 'terminal', title: 'R' })
|
||||
home = setTabPin(home, 'terminal:right', { scope: 'global' })
|
||||
// Bottom tree tab second.
|
||||
home = toggleBottomPanel(home)
|
||||
const bottomPane = (home.bottomSplits as { id: string }).id
|
||||
home = { ...home, activePane: bottomPane }
|
||||
home = openTabInActivePane(home, { id: 'terminal:bottom', type: 'terminal', title: 'B' })
|
||||
home = setTabPin(home, 'terminal:bottom', { scope: 'global' })
|
||||
// Float last.
|
||||
home = openTabInActivePane(home, { id: 'terminal:float', type: 'terminal', title: 'F' })
|
||||
home = setTabPin(home, 'terminal:float', { scope: 'global' })
|
||||
home = floatTab(home, 'terminal:float', 50, 50)
|
||||
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:right', 'terminal:bottom', 'terminal:float'])
|
||||
expect(entries.map(e => e.tab.id)).toEqual(['terminal:first', 'terminal:second'])
|
||||
})
|
||||
|
||||
it('keeps stable insertion order across multiple home sessions', () => {
|
||||
@@ -133,10 +128,10 @@ describe('collectPinnedTabs', () => {
|
||||
|
||||
it('ignores unpinned terminals and non-terminal tabs in other sessions', () => {
|
||||
let home = makeDefaultState()
|
||||
home = openTabInActivePane(home, { id: 'terminal:unpinned', type: 'terminal', title: 'U' })
|
||||
home = openTabInActivePane(home, { id: 'editor:1', type: 'editor', title: 'E', path: '/e' })
|
||||
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 = openTabInActivePane(home, { id: 'terminal:pinned', type: 'terminal', title: 'P' })
|
||||
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'))
|
||||
@@ -206,7 +201,7 @@ describe('createPinnedVirtualTab', () => {
|
||||
|
||||
describe('injectPinnedIntoTree', () => {
|
||||
it('returns the original tree when no pinned tabs and no active override', () => {
|
||||
const tree = makeDefaultState().splits
|
||||
const tree = makeDefaultState().bottomSplits
|
||||
expect(injectPinnedIntoTree(tree, [], null)).toBe(tree)
|
||||
})
|
||||
|
||||
@@ -216,7 +211,7 @@ describe('injectPinnedIntoTree', () => {
|
||||
tab: { id: 'terminal:3', type: 'terminal', title: 'T3', pin: { scope: 'global' } },
|
||||
homeSessionId: 'home',
|
||||
})
|
||||
const result = injectPinnedIntoTree(s.splits, [vtab], null)
|
||||
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')
|
||||
@@ -224,12 +219,12 @@ describe('injectPinnedIntoTree', () => {
|
||||
})
|
||||
|
||||
it('overrides the first leaf active when activePinnedId is set', () => {
|
||||
const s = openTabInActivePane(makeDefaultState(), { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
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.splits, [vtab], vtab.id)
|
||||
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)
|
||||
@@ -239,18 +234,18 @@ describe('injectPinnedIntoTree', () => {
|
||||
|
||||
it('injects into the first child of a split tree', () => {
|
||||
let s = makeDefaultState()
|
||||
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
// Create a split (splits the active pane into two)
|
||||
const leafId = s.splits.kind === 'leaf' ? s.splits.id : ''
|
||||
s = { ...s, splits: { kind: 'split', id: 'split:1', dir: 'row', sizes: [0.5, 0.5], children: [
|
||||
{ kind: 'leaf', id: leafId, tabs: s.splits.kind === 'leaf' ? s.splits.tabs : [], active: 'terminal:1' },
|
||||
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.splits, [vtab], null)
|
||||
const result = injectPinnedIntoTree(s.bottomSplits, [vtab], null)
|
||||
expect(result.kind).toBe('split')
|
||||
if (result.kind === 'split') {
|
||||
const first = result.children[0]!
|
||||
|
||||
@@ -52,13 +52,14 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
})
|
||||
|
||||
it('registers the side card preferences schema with the documented defaults', async () => {
|
||||
const { PrefsSchema, SIDEBAR_PREFS_NS } = await import('../src/config.ts')
|
||||
const { PrefsSchema, SIDEBAR_PREFS_NS, SIDEBAR_PREFS_DEFAULTS } = await import('../src/config.ts')
|
||||
expect(SIDEBAR_PREFS_NS).toBe('dsh-better-sidebar')
|
||||
const resolved = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
expect(resolved.openByDefault).toBe(false)
|
||||
expect(resolved.defaultWidthPercent).toBe(35)
|
||||
expect(resolved.openByDefault).toBeUndefined()
|
||||
expect(resolved.defaultWidthPercent).toBeUndefined()
|
||||
expect(resolved.changesDiffFloat).toBeUndefined()
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// A new background job auto-opens the Jobs page too.
|
||||
expect(resolved.autoOpenJobs).toBe(true)
|
||||
@@ -96,7 +97,17 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// default when the stored document predates it.
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})({ openByDefault: false, defaultWidthPercent: 45, changesDiffFloat: true })
|
||||
// Schemastery's object schema is OPEN: a document written by an older
|
||||
// plugin version still carrying the retired keys resolves them through
|
||||
// verbatim. They are inert — the typed value the client consumes
|
||||
// (parsePrefs) drops them (tests/prefs.spec.ts) — and the defaults no
|
||||
// longer declare them.
|
||||
// titleBarScheme / titleBarPresetId / customCss are declared WITHOUT a
|
||||
// schema default (the client's parsePrefs supplies them), so they are
|
||||
// absent from a resolved document that never stored them.
|
||||
const { titleBarScheme, titleBarPresetId, customCss, ...schemaDefaults } = SIDEBAR_PREFS_DEFAULTS
|
||||
void titleBarScheme; void titleBarPresetId; void customCss
|
||||
expect(overridden).toEqual({ ...schemaDefaults, openByDefault: false, defaultWidthPercent: 45, changesDiffFloat: true })
|
||||
})
|
||||
})
|
||||
|
||||
+20
-149
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState, setWidth } from '../src/client/state.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
|
||||
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
||||
@@ -30,18 +29,16 @@ describe('side card preferences', () => {
|
||||
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, autoOpenSubagent: false, agentTerminalTools: true })))
|
||||
it('parses a valid value', async () => {
|
||||
expect(await loadPrefs(wire({ autoOpenSubagent: false, agentTerminalTools: true })))
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 60,
|
||||
autoOpenSubagent: false,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: true, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
editorExplorer: false, changesDiffFloat: true,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
@@ -64,17 +61,15 @@ describe('side card preferences', () => {
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
|
||||
expect(await loadPrefs(wire({ autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 33,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
editorExplorer: false, changesDiffFloat: true,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
@@ -97,17 +92,15 @@ describe('side card preferences', () => {
|
||||
})
|
||||
|
||||
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
|
||||
expect(await loadPrefs(wire({})))
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 40,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
editorExplorer: false, changesDiffFloat: true,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
@@ -127,26 +120,26 @@ describe('side card preferences', () => {
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
expect((await loadPrefs(wire({ autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
|
||||
expect((await loadPrefs(wire({}))).agentTerminalTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
|
||||
expect((await loadPrefs(wire({ agentTerminalTools: 1 }))).agentTerminalTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||
expect((await loadPrefs(wire({ agentTerminalTools: true }))).agentTerminalTools)
|
||||
.toBe(true)
|
||||
// The sidebar-open tool is OFF by default too; only an explicit true turns it on.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentOpenTools)
|
||||
expect((await loadPrefs(wire({}))).agentOpenTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: 1 }))).agentOpenTools)
|
||||
expect((await loadPrefs(wire({ agentOpenTools: 1 }))).agentOpenTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: true }))).agentOpenTools)
|
||||
expect((await loadPrefs(wire({ agentOpenTools: true }))).agentOpenTools)
|
||||
.toBe(true)
|
||||
// The job auto-open is ON by default; only an explicit false turns it off.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
|
||||
expect((await loadPrefs(wire({ autoOpenJobs: 1 }))).autoOpenJobs)
|
||||
.toBe(true)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
|
||||
expect((await loadPrefs(wire({ autoOpenJobs: false }))).autoOpenJobs)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
@@ -265,128 +258,6 @@ describe('side card preferences', () => {
|
||||
expect(parsed.viewersEnabled).toEqual({ image: false })
|
||||
})
|
||||
|
||||
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, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
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 closed (openByDefault defaults off).
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setSession('another-fresh')
|
||||
expect(openStore.getSnapshot().state?.panelOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
|
||||
// Stub a narrow window (the file otherwise runs without one): only
|
||||
// innerWidth is read while seeding a fresh session.
|
||||
const original = (globalThis as Record<string, unknown>).window
|
||||
;(globalThis as Record<string, unknown>).window = {
|
||||
innerWidth: 390,
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
}
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
// The narrow viewport keeps a fresh session collapsed. Persisted
|
||||
// sessions follow the same load-time visibility rule in state.spec.ts.
|
||||
store.setSession('narrow-fresh')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
// The width seeding still follows the window (clamped to the floor).
|
||||
expect(store.getSnapshot().state?.width).toBe(280)
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as Record<string, unknown>).window
|
||||
else (globalThis as Record<string, unknown>).window = original
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-editor')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the files window (editor home tab) again — in BOTH
|
||||
// editorExplorer modes.
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession(`fresh-${editorExplorer}`)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0]!.type).toBe('editor')
|
||||
expect(tabs[0]!.title).toBe('Files')
|
||||
expect(tabs[0]!.path).toBeUndefined()
|
||||
expect(tabs[0]!.meta).toEqual({ treeOpen: 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)
|
||||
// The 'none' seed starts with an empty pane (no default tab).
|
||||
expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf')
|
||||
expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shares the panel width across sessions (last drag wins)', () => {
|
||||
// A real localStorage stub so the cross-session width actually persists
|
||||
// (the default no-op mock would silently drop the write).
|
||||
const storage = new Map<string, string>()
|
||||
const savedWindow = (globalThis as Record<string, unknown>).window
|
||||
const savedStorage = (globalThis as Record<string, unknown>).localStorage
|
||||
;(globalThis as Record<string, unknown>).window = {
|
||||
innerWidth: 1280,
|
||||
clearTimeout: () => {},
|
||||
setTimeout: () => 0,
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).localStorage = {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, String(v)) },
|
||||
removeItem: (k: string) => { storage.delete(k) },
|
||||
key: () => null,
|
||||
get length() { return storage.size },
|
||||
}
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('A')
|
||||
store.reduce(s => setWidth(s, 500))
|
||||
// A fresh session adopts the width dragged in A, not its own default.
|
||||
store.setSession('B')
|
||||
expect(store.getSnapshot().state?.width).toBe(500)
|
||||
// A later drag in B carries back to the cached session A.
|
||||
store.reduce(s => setWidth(s, 600))
|
||||
store.setSession('A')
|
||||
expect(store.getSnapshot().state?.width).toBe(600)
|
||||
} finally {
|
||||
;(globalThis as Record<string, unknown>).window = savedWindow
|
||||
;(globalThis as Record<string, unknown>).localStorage = savedStorage
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('external disable (aionui-panel provider choice)', () => {
|
||||
@@ -408,13 +279,13 @@ describe('boot decision (one fetch for prefs + external disable)', () => {
|
||||
it('answers both decisions from a single settingsGet call', async () => {
|
||||
let calls = 0
|
||||
const counting = (): SidebarSettingsClient => ({
|
||||
settingsGet: async () => { calls += 1; return { value: { openByDefault: true, defaultWidthPercent: 40 }, revision: 1, externalDisable: true } },
|
||||
settingsGet: async () => { calls += 1; return { value: { autoOpenSubagent: false, editorExplorer: true }, revision: 1, externalDisable: true } },
|
||||
settingsUpdate: async () => ({ value: {}, revision: 2 }),
|
||||
})
|
||||
const decision = await loadBootDecision(counting())
|
||||
expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1)
|
||||
expect(decision.suspended).toBe(true)
|
||||
expect(decision.prefs.openByDefault).toBe(true)
|
||||
expect(decision.prefs.autoOpenSubagent).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the defaults + not suspended on any failure', async () => {
|
||||
@@ -423,8 +294,8 @@ describe('boot decision (one fetch for prefs + external disable)', () => {
|
||||
})
|
||||
|
||||
it('reads suspended false when the flag is absent', async () => {
|
||||
const decision = await loadBootDecision(wire({ defaultWidthPercent: 50 }))
|
||||
const decision = await loadBootDecision(wire({ terminalFontSize: 20 }))
|
||||
expect(decision.suspended).toBe(false)
|
||||
expect(decision.prefs.defaultWidthPercent).toBe(50)
|
||||
expect(decision.prefs.terminalFontSize).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
+56
-256
@@ -2,7 +2,7 @@
|
||||
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
|
||||
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
|
||||
const g = globalThis as Record<string, unknown>
|
||||
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
|
||||
}
|
||||
|
||||
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves, floatTab, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
|
||||
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInBottomPane, sanitizeState } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
@@ -108,8 +108,8 @@ describe('enable switches (declarative settings)', () => {
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
// The seeded files-window home tab stays; no EXPLORER tab landed.
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
// No EXPLORER tab landed.
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'explorer')).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('service.openTab dedupe', () => {
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -303,7 +303,7 @@ describe('service.openTab dedupe', () => {
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('service.openTab dedupe', () => {
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -339,7 +339,7 @@ describe('service.openTab dedupe', () => {
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('counter:1')
|
||||
expect(tabs[1]!.id).toBe('counter:2')
|
||||
@@ -353,8 +353,8 @@ describe('service.openTab dedupe', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
// Find by path: the seeded files-window home tab is an editor tab too.
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
|
||||
// The editor tab is identified by its path.
|
||||
const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
})
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('service.openTab dedupe', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'browser', url: 'https://example.com/x', title: 'example.com' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'browser')
|
||||
const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'browser')
|
||||
expect(tab?.id).toBe('browser:1')
|
||||
expect(tab?.path).toBe('https://example.com/x')
|
||||
expect(tab?.title).toBe('example.com')
|
||||
@@ -387,7 +387,7 @@ describe('service.openTab dedupe', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'plain' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
|
||||
const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'plain')
|
||||
expect(tab?.title).toBe('Plain')
|
||||
})
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('service.openTab dedupe', () => {
|
||||
service.openTab({ type: 'singleton' })
|
||||
service.openTab({ type: 'singleton', id: 'singleton:extra' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -417,11 +417,11 @@ describe('service.openTab dedupe', () => {
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs).toHaveLength(2)
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
const state2 = store.getSnapshot().state!
|
||||
const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
const tabs2 = allLeaves(state2.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs2).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -461,45 +461,20 @@ describe('service.openTab dedupe', () => {
|
||||
// (the descriptor's dedupeKey is the same per-change id rule).
|
||||
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
|
||||
const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab across the two panels', () => {
|
||||
it('openTab lands in the bottom tree when the active pane lives there', () => {
|
||||
describe('service.openTab in the bottom workbench', () => {
|
||||
it('openTab lands the tab in the bottom tree (the plugin workbench)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'git', title: 'Git', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'git', title: 'Git' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupeKey focuses an existing instance in the OTHER tree (single-instance across panels)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'singleton',
|
||||
title: 'Singleton',
|
||||
single: true,
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
// Open in the right tree first.
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
// Switch the active pane to the bottom tree and open again: the dedupe
|
||||
// scan covers both trees, so the existing instance is focused, not
|
||||
// duplicated in the bottom panel.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
const state = store.getSnapshot().state!
|
||||
const total = allLeaves(state.splits).concat(allLeaves(state.bottomSplits))
|
||||
.flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
expect(total).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closeTab by id closes a tab living in the bottom tree', () => {
|
||||
@@ -507,7 +482,6 @@ describe('service.openTab across the two panels', () => {
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'git', title: 'Git', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'git', title: 'Git' })
|
||||
const state = store.getSnapshot().state!
|
||||
const gitTab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
|
||||
@@ -518,134 +492,44 @@ describe('service.openTab across the two panels', () => {
|
||||
})
|
||||
|
||||
describe('service.openTab auto-expand for content opens', () => {
|
||||
/** The window stub is a plain object (see the file header), so the width is writable. */
|
||||
const setWidth = (width: number): void => {
|
||||
;(g.window as { innerWidth: number }).innerWidth = width
|
||||
}
|
||||
/** Collapse the right panel (the store defaults it open). */
|
||||
const collapseRightPanel = (store: ReturnType<typeof createSidebarStore>): void => {
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
/** Collapse the bottom workbench (the plugin's only panel). */
|
||||
const collapseWorkbench = (store: ReturnType<typeof createSidebarStore>): void => {
|
||||
store.reduce(s => ({ ...s, bottomOpen: false }))
|
||||
}
|
||||
|
||||
it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(true)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands the collapsed drawer for a URL (browser) open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(true)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a collapsed drawer for a type-only open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands the collapsed right panel for a path (file) open on a wide viewport', () => {
|
||||
it('expands the collapsed workbench for a path (file) open', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
collapseWorkbench(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('expands the collapsed right panel for a URL (browser) open on a wide viewport', () => {
|
||||
it('expands the collapsed workbench for a URL (browser) open', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
collapseWorkbench(store)
|
||||
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
|
||||
expect(store.getSnapshot().state!.panelOpen).toBe(true)
|
||||
expect(store.getSnapshot().state!.bottomOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('a wide-viewport path open landing in the bottom tree expands the bottom panel instead', () => {
|
||||
it('expands the collapsed workbench even when the open focuses an existing tab (id dedupe)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
// The last-touched pane lives in the bottom tree and BOTH panels are
|
||||
// collapsed: the open must surface the bottom panel, not the right one.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id, panelOpen: false, bottomOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
collapseWorkbench(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(state.panelOpen).toBe(false)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a collapsed panel for a type-only open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('expands on a narrow viewport even when the open focuses an existing tab (id dedupe)', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(true)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands on a wide viewport even when the open focuses an existing tab (id dedupe)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -673,7 +557,7 @@ describe('state subscription (v0.12.0)', () => {
|
||||
const snapshot = service.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('s1')
|
||||
expect(snapshot.state).toBeDefined()
|
||||
expect(snapshot.prefs.openByDefault).toBe(false)
|
||||
expect(snapshot.prefs.agentTerminalTools).toBe(false)
|
||||
})
|
||||
|
||||
it('subscribeState fires on state changes but NOT on registry changes', () => {
|
||||
@@ -701,7 +585,7 @@ describe('updateTab (v0.12.0)', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1' })
|
||||
service.updateTab('doc:1', { title: 'Compiling…', meta: { progress: 42 } })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
|
||||
const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
|
||||
expect(tab.title).toBe('Compiling…')
|
||||
expect(tab.meta).toEqual({ progress: 42 })
|
||||
// A missing tab id is a no-op (does not throw).
|
||||
@@ -710,7 +594,7 @@ describe('updateTab (v0.12.0)', () => {
|
||||
})
|
||||
|
||||
describe('activateTab (v0.12.0)', () => {
|
||||
it('activates a tab in either tree and fires onActivate with the session scope', () => {
|
||||
it('activates a tab and fires onActivate with the session scope', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const seen: Array<{ tab: string; sessionId: string }> = []
|
||||
@@ -722,15 +606,13 @@ describe('activateTab (v0.12.0)', () => {
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
// Land in the bottom tree by switching the active pane.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'git', title: 'Git' })
|
||||
const gitTab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
|
||||
expect(gitTab).toBeDefined()
|
||||
service.activateTab(gitTab.id)
|
||||
expect(seen).toEqual([{ tab: gitTab.id, sessionId: 's1' }])
|
||||
// The active pane followed the tab into the bottom tree.
|
||||
expect(store.getSnapshot().state!.activePane).toBe(gitTab.id === '' ? null : allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
|
||||
// The active pane is the bottom leaf hosting the tab.
|
||||
expect(store.getSnapshot().state!.activePane).toBe(allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -741,14 +623,13 @@ describe('targeted openTab (v0.12.0)', () => {
|
||||
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
|
||||
// The UI snapshot still shows s1, untouched (its default files-window tab
|
||||
// is the only one — no notes tab landed there).
|
||||
// The UI snapshot still shows s1, untouched (no notes tab landed there).
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('s1')
|
||||
expect(allLeaves(snapshot.state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
|
||||
expect(allLeaves(snapshot.state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
|
||||
// Switching to s2 reveals the tab.
|
||||
store.setSession('s2')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs)
|
||||
expect(tabs.map(t => t.id)).toContain('notes:1')
|
||||
})
|
||||
|
||||
@@ -761,7 +642,7 @@ describe('targeted openTab (v0.12.0)', () => {
|
||||
store.subscribe(() => { calls++ })
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' }, { sessionId: 's1' })
|
||||
expect(calls).toBe(1)
|
||||
expect(store.getSnapshot().state!.splits).not.toBe(undefined)
|
||||
expect(store.getSnapshot().state!.bottomSplits).not.toBe(undefined)
|
||||
})
|
||||
|
||||
it('dedupe runs against the TARGET session (opens there focus an existing tab of that session)', () => {
|
||||
@@ -772,7 +653,7 @@ describe('targeted openTab (v0.12.0)', () => {
|
||||
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
|
||||
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
|
||||
store.setSession('s2')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -785,13 +666,13 @@ describe('openFile (v0.12.0)', () => {
|
||||
store.setSession('s1')
|
||||
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
|
||||
const state = store.getSnapshot().state!
|
||||
// Find by path: the seeded files-window home tab is an editor tab too.
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
|
||||
// The editor tab is identified by its path.
|
||||
const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
expect(tab?.path).toBe('/p/src/main.ts')
|
||||
// Windows separators are handled too.
|
||||
service.openFile({ sessionId: 's1' }, 'C:\\x\\y\\spec.ts', 'custom title')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
|
||||
expect(tabs[tabs.length - 1]?.title).toBe('custom title')
|
||||
expect(tabs[tabs.length - 1]?.path).toBe('C:\\x\\y\\spec.ts')
|
||||
})
|
||||
@@ -828,7 +709,7 @@ describe('tab lifecycle callbacks (v0.12.0)', () => {
|
||||
const { store, service, events } = setup()
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
|
||||
const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'life')!
|
||||
events.length = 0
|
||||
service.closeTab(tab.id)
|
||||
expect(events).toEqual(['close'])
|
||||
@@ -865,7 +746,7 @@ describe('tab lifecycle callbacks (v0.12.0)', () => {
|
||||
})
|
||||
store.setSession('s1')
|
||||
expect(() => service.openTab({ type: 'boom', title: 'Boom' })).not.toThrow()
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
|
||||
const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
|
||||
expect(() => service.closeTab(tab.id)).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -875,7 +756,7 @@ describe('tab lifecycle callbacks (v0.12.0)', () => {
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { life: false } })
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
expect(events).toEqual([])
|
||||
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
|
||||
expect(allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -886,19 +767,19 @@ describe('tab meta (v0.12.0)', () => {
|
||||
service.registerTab({ id: 'db', title: 'DB', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'db', title: 'DB', id: 'db:1', meta: { table: 'users', page: 3 } })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
|
||||
const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
|
||||
expect(tab.meta).toEqual({ table: 'users', page: 3 })
|
||||
// Reload round-trip: the persisted shape sanitizes back with meta intact.
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
|
||||
const restored = allLeaves(sanitized!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
|
||||
const restored = allLeaves(sanitized!.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
|
||||
expect(restored.meta).toEqual({ table: 'users', page: 3 })
|
||||
})
|
||||
|
||||
it('older persisted tabs (no meta) sanitize unchanged', () => {
|
||||
const state = makeDefaultState(400, true, 'none')
|
||||
const withTab = openTabInActivePane(state, { id: 'tab:old', type: 'git', title: 'Git' })
|
||||
const state = makeDefaultState()
|
||||
const withTab = openTabInBottomPane(state, { id: 'tab:old', type: 'git', title: 'Git' })
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(withTab)))
|
||||
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
|
||||
const tabs = allLeaves(sanitized!.bottomSplits).flatMap(l => l.tabs)
|
||||
expect(tabs[0]?.meta).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -928,7 +809,7 @@ describe('lifecycle classification vs dedupe (codex review fixes)', () => {
|
||||
{ kind: 'open', tabId: 'doc:1' },
|
||||
{ kind: 'activate', tabId: 'doc:1' },
|
||||
])
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'doc')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'doc')
|
||||
expect(tabs.map(t => t.id)).toEqual(['doc:1'])
|
||||
})
|
||||
|
||||
@@ -979,7 +860,7 @@ describe('independent CR follow-up fixes', () => {
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'web', title: 'first', url: 'https://a.example' })
|
||||
service.openTab({ type: 'web', title: 'second', url: 'https://b.example' })
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'web')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'web')
|
||||
expect(tabs).toHaveLength(1)
|
||||
// The focused tab keeps its ORIGINAL url — the second open must not
|
||||
// repoint it.
|
||||
@@ -998,21 +879,6 @@ describe('independent CR follow-up fixes', () => {
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('a targeted open into an INACTIVE session never auto-expands its panels', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
// The target session starts collapsed.
|
||||
store.reduceFor('s2', s => ({ ...s, panelOpen: false, bottomOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }, { sessionId: 's2' })
|
||||
// Nothing is in sight for the user — the open must not expand s2.
|
||||
store.setSession('s2')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
expect(store.getSnapshot().state?.bottomOpen).toBe(false)
|
||||
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('closeTab/activateTab accept an optional scope that rides to the callback', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
@@ -1027,7 +893,7 @@ describe('independent CR follow-up fixes', () => {
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
|
||||
const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'life')!
|
||||
service.activateTab(tab.id, { sessionId: 's1', cwd: '/work' })
|
||||
service.closeTab(tab.id, { sessionId: 's1', cwd: '/work' })
|
||||
expect(seen).toEqual([
|
||||
@@ -1055,70 +921,4 @@ describe('independent CR follow-up fixes', () => {
|
||||
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
|
||||
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
|
||||
})
|
||||
|
||||
describe('free windows (v0.16.0)', () => {
|
||||
it('openTab dedupe focuses a FLOATING tab by raising its window (no duplicate, no panel expansion)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'singleton', title: 'S', dedupeKey: () => 'singleton', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton', title: 'S' })
|
||||
// Float the singleton out, then float a second tab above it and
|
||||
// collapse the panel — the focus must raise the window in place
|
||||
// without reopening a tab or expanding anything.
|
||||
store.reduce(s => floatTab(s, 'singleton', 100, 100))
|
||||
service.openTab({ type: 'singleton', title: 'S' })
|
||||
store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 100, 100))
|
||||
const before = store.getSnapshot().state!
|
||||
expect(before.floats).toHaveLength(2)
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'singleton', title: 'S' })
|
||||
const after = store.getSnapshot().state!
|
||||
// Raised to the top, not duplicated.
|
||||
expect(after.floats).toHaveLength(2)
|
||||
expect(after.floats.at(-1)!.tab.type).toBe('singleton')
|
||||
// The panel stays collapsed (a floating tab is already in sight).
|
||||
expect(after.panelOpen).toBe(false)
|
||||
expect(allLeaves(after.splits).some(l => l.tabs.some(t => t.type === 'singleton'))).toBe(false)
|
||||
})
|
||||
|
||||
it('closeTab on a floating tab closes it WITH the window and fires onClose', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const onClose = vi.fn()
|
||||
service.registerTab({ id: 'notes', title: 'Notes', single: true, onClose, component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'notes', title: 'Notes' })
|
||||
store.reduce(s => floatTab(s, 'notes', 50, 50))
|
||||
expect(store.getSnapshot().state!.floats).toHaveLength(1)
|
||||
service.closeTab('notes', { sessionId: 's1' })
|
||||
const after = store.getSnapshot().state!
|
||||
expect(after.floats).toHaveLength(0)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Unknown ids stay a strict no-op.
|
||||
service.closeTab('notes')
|
||||
expect(store.getSnapshot().state).toBe(after)
|
||||
})
|
||||
|
||||
it('activateTab on a floating tab raises the window and fires onActivate', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const onActivate = vi.fn()
|
||||
service.registerTab({ id: 'notes', title: 'Notes', single: true, onActivate, component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'notes', title: 'Notes' })
|
||||
service.openTab({ type: 'notes', title: 'Notes' })
|
||||
store.reduce(s => floatTab(s, 'notes', 50, 50))
|
||||
store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 60, 60))
|
||||
const before = store.getSnapshot().state!
|
||||
expect(before.floats).toHaveLength(2)
|
||||
expect(before.floats.at(-1)!.tab.type).not.toBe('notes')
|
||||
service.activateTab('notes')
|
||||
const after = store.getSnapshot().state!
|
||||
expect(after.floats.at(-1)!.tab.type).toBe('notes')
|
||||
// Two activations total: the second openTab's dedupe focus (before the
|
||||
// float) plus THIS explicit activateTab — both legitimate focuses.
|
||||
expect(onActivate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,9 +90,9 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: no general switch is on (openByDefault and agentOpenTools
|
||||
// both default off), and both tabs + the image viewer cards pressed
|
||||
// (3 aria-pressed cards).
|
||||
// Default prefs: the general switch is off (agentOpenTools defaults off),
|
||||
// and both tabs + the image viewer cards are pressed (3 aria-pressed
|
||||
// cards).
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
@@ -178,9 +178,9 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||||
expect(html).not.toContain('<select')
|
||||
expect(html).toContain('>Auto-detect<')
|
||||
// Two general-row switches remain (openByDefault + agentOpenTools), neither
|
||||
// on by default — the scheme row is a dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
|
||||
// One general-row switch remains (agentOpenTools), off by default — the
|
||||
// scheme row is a dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
|
||||
// Auto (default) needs no further settings → no gear.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
@@ -319,10 +319,10 @@ describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
|
||||
|
||||
describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () => {
|
||||
it('plugin rows read from their OWN value source — a plugin key colliding with a host pref never reads the host value', () => {
|
||||
const prefs = { ...SIDEBAR_PREFS_DEFAULTS, openByDefault: true }
|
||||
const toggle = { key: 'openByDefault', title: 'My flag' }
|
||||
const prefs = { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: true }
|
||||
const toggle = { key: 'agentOpenTools', title: 'My flag' }
|
||||
// valueOf returns undefined (the plugin never wrote this key): the row
|
||||
// must render UNCHECKED even though the host pref openByDefault is true.
|
||||
// must render UNCHECKED even though the host pref agentOpenTools is true.
|
||||
let html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Real-Sidebar regressions for issue #162: background activity should activate
|
||||
* the Tasks page on every viewport, but must not force a narrow full-screen
|
||||
* drawer open over the chat.
|
||||
* Real-Sidebar regressions for issue #162: background activity (a new
|
||||
* subagent, a new background job) activates the Tasks page in the bottom
|
||||
* workbench. The right column belongs to DSH's native Sidebar, so there is no
|
||||
* full-screen drawer to guard against any more — the activation lands in the
|
||||
* docked workbench on every viewport width.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -57,7 +59,7 @@ function setViewport(width: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
|
||||
}
|
||||
|
||||
function mountSidebar(width: number, panelOpen = false): MountedSidebar {
|
||||
function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
||||
setViewport(width)
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const sessionId = `auto-activation-${++sessionSeq}`
|
||||
@@ -72,7 +74,7 @@ function mountSidebar(width: number, panelOpen = false): MountedSidebar {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true })
|
||||
store.setSession(sessionId)
|
||||
store.reduce(state => ({ ...state, panelOpen }))
|
||||
store.reduce(state => ({ ...state, bottomOpen }))
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'subagent', title: 'Subagent', component: () => null })
|
||||
const localeSnapshot = { active: 'en' }
|
||||
@@ -143,14 +145,14 @@ function publishJob(sidebar: MountedSidebar): void {
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubagentFocused(sidebar: MountedSidebar, panelOpen: boolean): void {
|
||||
function expectSubagentFocused(sidebar: MountedSidebar): void {
|
||||
const state = sidebar.store.getSnapshot().state!
|
||||
const right = firstLeaf(state.splits)
|
||||
expect(state.panelOpen).toBe(panelOpen)
|
||||
expect(state.activePane).toBe(right.id)
|
||||
expect(allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
const pane = firstLeaf(state.bottomSplits)
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(state.activePane).toBe(pane.id)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
||||
expect(right.tabs.find(tab => tab.id === right.active)?.type).toBe('subagent')
|
||||
expect(pane.tabs.find(tab => tab.id === pane.active)?.type).toBe('subagent')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -170,11 +172,11 @@ afterEach(() => {
|
||||
|
||||
describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
it.each([
|
||||
{ source: 'subagent', width: 390, opens: false },
|
||||
{ source: 'job', width: 390, opens: false },
|
||||
{ source: 'subagent', width: 1024, opens: true },
|
||||
{ source: 'job', width: 1024, opens: true },
|
||||
] as const)('$source activation at $width px sets panelOpen=$opens', ({ source, width, opens }) => {
|
||||
{ source: 'subagent', width: 390 },
|
||||
{ source: 'job', width: 390 },
|
||||
{ source: 'subagent', width: 1024 },
|
||||
{ source: 'job', width: 1024 },
|
||||
] as const)('$source activation at $width px focuses the Tasks page in the workbench', ({ source, width }) => {
|
||||
const sidebar = mountSidebar(width)
|
||||
if (source === 'subagent') {
|
||||
publishSubagent(sidebar)
|
||||
@@ -182,10 +184,10 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
} else {
|
||||
publishJob(sidebar)
|
||||
}
|
||||
expectSubagentFocused(sidebar, opens)
|
||||
expectSubagentFocused(sidebar)
|
||||
})
|
||||
|
||||
it.each(['subagent', 'job'] as const)('%s activation preserves an already-open narrow drawer', (source) => {
|
||||
it.each(['subagent', 'job'] as const)('%s activation keeps an already-open workbench open', (source) => {
|
||||
const sidebar = mountSidebar(390, true)
|
||||
if (source === 'subagent') {
|
||||
publishSubagent(sidebar)
|
||||
@@ -193,14 +195,14 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
} else {
|
||||
publishJob(sidebar)
|
||||
}
|
||||
expectSubagentFocused(sidebar, true)
|
||||
expectSubagentFocused(sidebar)
|
||||
})
|
||||
|
||||
it('uses the current viewport when the delayed subagent activation fires', () => {
|
||||
it('a resize between arming and firing the debounce does not change the landing', () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
publishSubagent(sidebar)
|
||||
setViewport(390)
|
||||
flushSubagentDebounce()
|
||||
expectSubagentFocused(sidebar, false)
|
||||
expectSubagentFocused(sidebar)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* Sidebar crash tests — the two failure modes behind issue #31.
|
||||
*
|
||||
* 1. Layout-push leak: the layout-push effect writes
|
||||
* `--dsh-sidebar-width/--dsh-sidebar-height` on document.documentElement.
|
||||
* `--dsh-sidebar-height` on document.documentElement (the right column
|
||||
* belongs to DSH's native Sidebar, so no width is pushed).
|
||||
* Unmounting the Sidebar for ANY reason (error-boundary swap, plugin
|
||||
* disable, HMR) must clear them — otherwise layout.css keeps squeezing
|
||||
* disable, HMR) must clear it — otherwise layout.css keeps squeezing
|
||||
* `#root` with a stale margin and "the sidebar cannot be hidden" until a
|
||||
* full page reload.
|
||||
*
|
||||
@@ -57,8 +58,6 @@ function mountSidebar(): MountedSidebar {
|
||||
document.body.append(container)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// Fresh-session seed: open the panel explicitly (openByDefault defaults off).
|
||||
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
|
||||
// Unique session per test — the store persists per-session state to
|
||||
// localStorage (200ms debounce); a shared id lets a previous test's late
|
||||
// write leak into this store's setSession restore.
|
||||
@@ -100,28 +99,25 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('layout-push variable cleanup', () => {
|
||||
it('clears --dsh-sidebar-width/--dsh-sidebar-height when the sidebar unmounts', () => {
|
||||
const { store, unmount } = mountSidebar()
|
||||
it('clears --dsh-sidebar-height when the sidebar unmounts (and never pushes a width)', () => {
|
||||
const { unmount } = mountSidebar()
|
||||
const htmlStyle = document.documentElement.style
|
||||
// The seeded session is open: the layout push is applied on mount.
|
||||
const width = store.getSnapshot().state!.width
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe(`${width}px`)
|
||||
// A fresh session seeds the bottom workbench collapsed: the push is 0.
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('0px')
|
||||
// The right column is DSH's native Sidebar — this shell writes no width.
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('')
|
||||
// Any unmount (boundary swap, plugin disable, HMR) must release the push.
|
||||
unmount()
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('')
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('')
|
||||
})
|
||||
|
||||
it('a size change (release commit) re-applies the variables without removing them mid-commit', () => {
|
||||
it('a size change (release commit) re-applies the variable without removing it mid-commit', () => {
|
||||
const { store, unmount } = mountSidebar()
|
||||
const htmlStyle = document.documentElement.style
|
||||
const width = store.getSnapshot().state!.width
|
||||
const removeSpy = vi.spyOn(htmlStyle, 'removeProperty')
|
||||
// Simulate a drag release: the bottom panel opens and its height commits.
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
act(() => { store.reduce(s => setBottomHeight(s, 300)) })
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe(`${width}px`)
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('300px')
|
||||
// The commit must NOT have removed the variables at any point. React
|
||||
// runs every effect cleanup before every effect setup in a commit, so a
|
||||
@@ -134,18 +130,17 @@ describe('layout-push variable cleanup', () => {
|
||||
// the full page then bounce back" flash.
|
||||
const removalProps = removeSpy.mock.calls
|
||||
.map(call => call[0] as string)
|
||||
.filter(prop => prop === '--dsh-sidebar-width' || prop === '--dsh-sidebar-height')
|
||||
.filter(prop => prop === '--dsh-sidebar-height')
|
||||
expect(removalProps).toHaveLength(0)
|
||||
// Only unmounting may remove them (issue #31).
|
||||
// Only unmounting may remove it (issue #31).
|
||||
unmount()
|
||||
expect(removeSpy.mock.calls.some(call =>
|
||||
call[0] === '--dsh-sidebar-width' || call[0] === '--dsh-sidebar-height')).toBe(true)
|
||||
expect(removeSpy.mock.calls.some(call => call[0] === '--dsh-sidebar-height')).toBe(true)
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab crash containment', () => {
|
||||
it('a crashing tab shows an in-pane strip while the cluster and panel survive', () => {
|
||||
it('a crashing tab shows an in-pane strip while the panel survives', () => {
|
||||
const { container, service, store } = mountSidebar()
|
||||
service.registerTab({
|
||||
id: 'crash',
|
||||
@@ -156,11 +151,12 @@ describe('tab crash containment', () => {
|
||||
// The strip lives inside the tab's pane — the crash is contained.
|
||||
expect(container.textContent).toContain('boom')
|
||||
expect(container.textContent).toContain(t('terminalRetry'))
|
||||
// The toggle cluster and the panel itself survived (no full-tree swap):
|
||||
// the collapse button is still there and the layout push is still live.
|
||||
expect(container.querySelector(`[aria-label="${t('collapse')}"]`)).not.toBeNull()
|
||||
expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')).toBe(
|
||||
`${store.getSnapshot().state!.width}px`,
|
||||
// The workbench itself survived (no full-tree swap): the collapse control
|
||||
// is still there and the layout push is still live (the content open
|
||||
// expanded the panel).
|
||||
expect(container.querySelector(`[aria-label="${t('collapseBottomPanel')}"]`)).not.toBeNull()
|
||||
expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')).toBe(
|
||||
`${store.getSnapshot().state!.bottomHeight}px`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
/**
|
||||
* The layout-push contract is split between the shell (DOM writes) and
|
||||
* layout-push.ts (the cap): these source assertions pin the seams a
|
||||
* refactor silently breaks — every push must ride the shared cap, the
|
||||
* keyboard inset must extend the PUSH (never the panel's own height), and
|
||||
* the cap must be measured against the visual viewport.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync('src/client/Sidebar.tsx', 'utf8')
|
||||
|
||||
describe('Sidebar layout-push integration', () => {
|
||||
it('does not bypass the shared bottom-height cap during width drags', () => {
|
||||
it('routes every push through the shared bottom-height cap', () => {
|
||||
expect(source.match(/bottomPushHeight\(\{/g)).toHaveLength(2)
|
||||
expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)')
|
||||
expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('caps panel geometry against the viewport visible above the keyboard', () => {
|
||||
it('caps the geometry against the visible height above the keyboard', () => {
|
||||
expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))')
|
||||
expect(source).toContain('visualViewportHeight ?? viewport.height')
|
||||
expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4)
|
||||
expect(source).toContain('viewport.width, layoutViewportHeight, keyboardInset]')
|
||||
expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('adds the keyboard inset to the conversation push, not the panel height', () => {
|
||||
@@ -22,9 +28,11 @@ describe('Sidebar layout-push integration', () => {
|
||||
expect(source).not.toContain('height: bottomPanelHeight + keyboardInset')
|
||||
})
|
||||
|
||||
it('reapplies the visible-height cap to every vertical drag result', () => {
|
||||
expect(source).not.toMatch(/const height = clampHeight\(/)
|
||||
expect(source).not.toMatch(/height = clampHeight\(/)
|
||||
expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6)
|
||||
it('reapplies the height clamp to every vertical drag result', () => {
|
||||
// Both live pointer paths (move + up) clamp before writing or committing.
|
||||
expect(source.match(/clampHeight\(startHeight \+ \(startY - event\.clientY\)\)/g)).toHaveLength(2)
|
||||
// The interrupted-drag paths (pointercancel / lost capture) clamp too.
|
||||
expect(source.match(/clampHeight\(bottomDrag\.current\.startHeight/g)).toHaveLength(1)
|
||||
expect(source.match(/clampHeight\(lastDragHeight\.current/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
+6
-9
@@ -992,8 +992,6 @@ describe('side card settings routes', () => {
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: {
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 35,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false, agentOpenTools: false,
|
||||
@@ -1013,7 +1011,6 @@ describe('side card settings routes', () => {
|
||||
browserInterceptHttp: true,
|
||||
browserInterceptHttps: false,
|
||||
browserAllowedLoopback: '',
|
||||
changesDiffFloat: true,
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
@@ -1024,11 +1021,11 @@ describe('side card settings routes', () => {
|
||||
externalDisable: false,
|
||||
})
|
||||
|
||||
const written = await invoke(route, 'settings.update', { patch: { openByDefault: true } })
|
||||
const written = await invoke(route, 'settings.update', { patch: { agentOpenTools: true } })
|
||||
expect(written.ok).toBe(true)
|
||||
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
|
||||
expect(view.value.openByDefault).toBe(true)
|
||||
expect(view.value.defaultWidthPercent).toBe(35)
|
||||
const view = written.value as { value: { agentOpenTools: boolean; terminalFontSize: number }; revision: number }
|
||||
expect(view.value.agentOpenTools).toBe(true)
|
||||
expect(view.value.terminalFontSize).toBe(13)
|
||||
expect(view.revision).toBe(1)
|
||||
})
|
||||
|
||||
@@ -1062,10 +1059,10 @@ describe('side card settings routes', () => {
|
||||
|
||||
it('refuses a stale write with settings-conflict (409)', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
await invoke(route, 'settings.update', { patch: { agentOpenTools: false } })
|
||||
// The second write carries the pre-write revision: the seam refuses it.
|
||||
const stale = await invoke(route, 'settings.update', {
|
||||
patch: { defaultWidthPercent: 40 },
|
||||
patch: { terminalFontSize: 15 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(stale.ok).toBe(false)
|
||||
|
||||
+224
-829
File diff suppressed because it is too large
Load Diff
@@ -30,13 +30,12 @@ function stubZh(): void {
|
||||
})
|
||||
}
|
||||
|
||||
const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
|
||||
const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
|
||||
|
||||
function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void } = {}): {
|
||||
tabEls: HTMLElement[]
|
||||
onClose: ReturnType<typeof vi.fn>
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
onFloatTab: ReturnType<typeof vi.fn>
|
||||
onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
|
||||
unmount: () => void
|
||||
} {
|
||||
@@ -44,7 +43,6 @@ function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope:
|
||||
document.body.append(container)
|
||||
const onClose = vi.fn()
|
||||
const onActivate = vi.fn()
|
||||
const onFloatTab = vi.fn()
|
||||
const onPinTab = opts.onPinTab
|
||||
const root: Root = createRoot(container)
|
||||
act(() => {
|
||||
@@ -56,7 +54,6 @@ function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope:
|
||||
onClose,
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onFloatTab,
|
||||
...(onPinTab !== undefined ? { onPinTab } : {}),
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
@@ -66,7 +63,6 @@ function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope:
|
||||
tabEls,
|
||||
onClose,
|
||||
onActivate,
|
||||
onFloatTab,
|
||||
...(onPinTab !== undefined ? { onPinTab } : {}),
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
@@ -116,15 +112,14 @@ describe('TabBar right-click context menu', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('move to free window floats the target tab without closing it', () => {
|
||||
it('close closes the target tab and dismisses the menu', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, onFloatTab, unmount } = mountBar(fourTabs())
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[0]!.click() })
|
||||
expect(onFloatTab).toHaveBeenCalledTimes(1)
|
||||
expect(onFloatTab).toHaveBeenCalledWith('t2')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
@@ -136,7 +131,7 @@ describe('TabBar right-click context menu', () => {
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
act(() => { menuItems()[0]!.click() })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
@@ -150,7 +145,7 @@ describe('TabBar right-click context menu', () => {
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[2]!.click() })
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4'])
|
||||
expect(onClose).not.toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
@@ -164,7 +159,7 @@ describe('TabBar right-click context menu', () => {
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[2]!) })
|
||||
act(() => { menuItems()[3]!.click() })
|
||||
act(() => { menuItems()[2]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2'])
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
@@ -177,7 +172,7 @@ describe('TabBar right-click context menu', () => {
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[4]!.click() })
|
||||
act(() => { menuItems()[3]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4'])
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
@@ -194,9 +189,9 @@ describe('TabBar right-click context menu', () => {
|
||||
act(() => { rightClick(single.tabEls[0]!) })
|
||||
const items = menuItems()
|
||||
// The Menu renders each row as a disabled <button role="menuitem">.
|
||||
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, true, true])
|
||||
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
|
||||
// Clicking the disabled row must not close anything.
|
||||
act(() => { items[2]!.click() })
|
||||
act(() => { items[1]!.click() })
|
||||
expect(single.onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
single.unmount()
|
||||
@@ -205,9 +200,9 @@ describe('TabBar right-click context menu', () => {
|
||||
const four = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(four.tabEls[0]!) })
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true, false])
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
|
||||
act(() => { rightClick(four.tabEls[3]!) })
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, false, true])
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
|
||||
} finally {
|
||||
four.unmount()
|
||||
}
|
||||
@@ -225,7 +220,7 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
const { tabEls, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[2]!) }) // t3 = terminal
|
||||
// Exactly the legacy 5-item menu, no pin row.
|
||||
// Exactly the legacy close-only menu, no pin row.
|
||||
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
|
||||
} finally {
|
||||
unmount()
|
||||
@@ -239,13 +234,12 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
try {
|
||||
act(() => { rightClick(tabEls[2]!) }) // t3 = UI terminal
|
||||
const labels = menuItems().map(item => item.textContent)
|
||||
// float | pin (with submenu indicator) | close | closeOthers | closeLeft | closeRight
|
||||
expect(labels[0]).toBe('移动到自由窗口')
|
||||
expect(labels[1]).toBe('固定终端')
|
||||
expect(labels[2]).toBe('关闭')
|
||||
// pin (with submenu indicator) | close | closeOthers | closeLeft | closeRight
|
||||
expect(labels[0]).toBe('固定终端')
|
||||
expect(labels[1]).toBe('关闭')
|
||||
// Hovering the pin row reveals the submenu items. React synthesizes
|
||||
// onMouseEnter from the bubbling mouseover event, so dispatch that.
|
||||
act(() => { menuItems()[1]!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
|
||||
act(() => { menuItems()[0]!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
|
||||
const sub = submenuItems().map(item => item.textContent)
|
||||
expect(sub).toContain('固定到工作区')
|
||||
expect(sub).toContain('固定到全局')
|
||||
@@ -268,7 +262,7 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
try {
|
||||
act(() => { rightClick(tabEls[0]!) })
|
||||
const labels = menuItems().map(item => item.textContent)
|
||||
expect(labels[1]).toBe('固定 Agent 终端')
|
||||
expect(labels[0]).toBe('固定 Agent 终端')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
@@ -324,7 +318,7 @@ describe('TabBar context menu flip geometry (submenu clamping)', () => {
|
||||
// rightClick() dispatches at clientY 40 — the top strip, upper half.
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
act(() => { menuItems()[0]!.click() })
|
||||
expect(onClose).toHaveBeenCalledWith('t2')
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
} finally {
|
||||
|
||||
@@ -48,7 +48,6 @@ function mountBar(): {
|
||||
onClose,
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onFloatTab: () => {},
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -37,7 +37,6 @@ function mountBar(overflow: boolean): { list: HTMLElement; unmount: () => void }
|
||||
onClose: () => {},
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onFloatTab: () => {},
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user