* feat(editor): merged explorer mode — path input, docked file tree, global name search
New editorExplorer pref (default on, editor card gear): the editor tab gains
a path-input header and a toggleable right-docked tree panel (per-tab state
in tab.meta.treeOpen; file tabs default closed, the seeded path-less home
tab defaults open). Fresh sessions seed that empty editor tab instead of
the explorer tab when merged mode is on; the standalone explorer tab stays
in the + menu unchanged.
- Extract FileTree.tsx from ExplorerView (shared by both hosts)
- Host fs.search route: recursive case-insensitive name search rooted at
the session cwd, budget-capped (200 matches / 100k visited), skips .git,
never descends symlink dirs
- EditorHost reads the pref reactively (boolean snapshot) and keeps the
pre-merge layout verbatim when off
* feat(settings): select row type for declarative settings; editorExplorer as iconed select
- SidebarSettingToggle gains type 'select' with options {value,title,desc,icon}
and multi (default false; stores the picked values as an array in options
order). Any option carrying an icon renders big-icon option cards in the
dropdown and the icon in the closed anchor; icon-less rows are one-line text.
- SelectRow in SideCardSection rides the primitives Menu (portal); single-pick
commits and closes, multi toggles and stays open. Wired for both host-prefs
toggles and plugin-owned pluginToggles via the new onSelectValue seam.
- The editorExplorer merged-mode pref is now an iconed select (merged vs
separate) instead of a switch; the stored value stays boolean.
- Advertise the capability as 'settingSelect' in SIDEBAR_FEATURES.
* feat(editor): unify explorer into the editor surface, resizable tree dock, hoisted toolbar
- One tree implementation, two presentations: the shared TreePanel (global
name search + refresh + FileTree) docks as the editor tab's right panel
and — in merged mode — fills the explorer tab's window; the pref-off
explorer keeps its legacy header shell.
- The docked panel drag-resizes from its left edge (160-480px clamp),
persisted as tab.meta.treeWidth alongside treeOpen.
- The text editor's preview/edit/save toolbar moves into the path-input
header row: FileViewerProps gains internal toolbar fields ('host' mode
skips the viewer's own row, reports EditorToolbarState and registers
EditorToolbarControls); non-merged mode is byte-identical to before.
* feat(editor): single files window with in-place switching; explorer descriptor removed
- editor tab replaces explorer on the + menu (order 10, Files); persisted
explorer tabs migrate to the editor home tab via sanitizeState
- editorExplorer now only controls open behavior: merged = in-place
updateTab, independent = openSidebarFile; both modes share the merged
chrome (path input + hoisted preview/edit/save toolbar + tree toggle)
- file tree context menu gains 'open in new tab' / 'open to the side'
(mintTabId avoids the id safety net; insertLeafAt splits right)
- tree dock resize uses pointer capture (touch-action none, 6px handle)
- defaults: openByDefault off, sidebar width 35%
- file tree clips horizontal overflow (no sideways scroll)
- e2e: mount lane arms the editor-chunk wait before goto and clicks the
tree row's left edge (hover @-reference button sits mid-row); drag lane
expands the panel first (collapsed push writes 0px), waits for the
slide-in to settle, and picks the leftmost col-resize strip; 'Files'
title assertions require draggable to skip the bottom panel's welcome
cards
* feat(editor): split mode turns the path-less window into the standalone explorer
In independent (split) mode a path-less files window renders ONLY the
tree panel (search + FileTree, full-window form) — no path input, no
hoisted toolbar, no tree toggle. File clicks open per-path editor tabs
(full chrome, tree docked closed by default) through openSidebarFile,
unchanged. Merged mode is untouched (chrome + docked tree, in-place
switching). TreePanel's previously reserved 'full' prop now has its
consumer.
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
/**
|
|
* 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.
|
|
* Unmounting the Sidebar for ANY reason (error-boundary swap, plugin
|
|
* disable, HMR) must clear them — otherwise layout.css keeps squeezing
|
|
* `#root` with a stale margin and "the sidebar cannot be hidden" until a
|
|
* full page reload.
|
|
*
|
|
* 2. Tab crash containment: a render error inside ONE tab's content must not
|
|
* take down the whole sidebar. The per-tab boundary shows a strip inside
|
|
* that tab's pane while the toggle cluster, the other tabs, and the panel
|
|
* itself stay alive; the retry button recovers a transient crash.
|
|
*
|
|
* Rendered with the REAL Sidebar shell + real store/service against a minimal
|
|
* fake context (createRoot + act(), the repo's jsdom pattern).
|
|
*/
|
|
// @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).
|
|
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
|
|
import { Sidebar } from '../src/client/Sidebar.tsx'
|
|
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
|
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
|
import { t } from '../src/client/locales.ts'
|
|
|
|
/** 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) {}
|
|
}
|
|
|
|
interface MountedSidebar {
|
|
container: HTMLDivElement
|
|
store: SidebarStore
|
|
service: BetterSidebarService
|
|
unmount: () => void
|
|
}
|
|
|
|
/** Mount the real Sidebar shell against a minimal context (real store + service). */
|
|
function mountSidebar(): MountedSidebar {
|
|
vi.stubGlobal('WebSocket', FakeWebSocket)
|
|
const container = document.createElement('div')
|
|
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 })
|
|
store.setSession('s1')
|
|
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
|
|
// services return stable objects) — a fresh object per call loops forever.
|
|
const localeSnapshot = { active: 'en' }
|
|
const sessionsSnapshot = {
|
|
current: 's1',
|
|
// cwd present → api.sessionCwd is never called in these tests.
|
|
byId: { s1: { cwd: '/tmp' } },
|
|
}
|
|
const ctx = {
|
|
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
|
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
|
|
betterSidebar: service,
|
|
}
|
|
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()
|
|
},
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('layout-push variable cleanup', () => {
|
|
it('clears --dsh-sidebar-width/--dsh-sidebar-height when the sidebar unmounts', () => {
|
|
const { store, 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`)
|
|
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('0px')
|
|
// 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('')
|
|
})
|
|
})
|
|
|
|
describe('tab crash containment', () => {
|
|
it('a crashing tab shows an in-pane strip while the cluster and panel survive', () => {
|
|
const { container, service, store } = mountSidebar()
|
|
service.registerTab({
|
|
id: 'crash',
|
|
title: 'Crash',
|
|
component: () => { throw new Error('boom') },
|
|
})
|
|
act(() => { service.openTab({ type: 'crash', title: 'Crash' }) })
|
|
// 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`,
|
|
)
|
|
})
|
|
|
|
it('the retry button recovers a tab whose crash has since been fixed', () => {
|
|
// A PERSISTENT render error reaches the boundary strip (React 18.2
|
|
// auto-recovers transient throws — one bad render followed by a good
|
|
// retry is swallowed as a recoverable error and never shows a strip).
|
|
let shouldThrow = true
|
|
const { container, service } = mountSidebar()
|
|
service.registerTab({
|
|
id: 'crash-until-fixed',
|
|
title: 'Crash until fixed',
|
|
component: () => {
|
|
if (shouldThrow) throw new Error('transient')
|
|
return createElement('div', null, 'recovered')
|
|
},
|
|
})
|
|
act(() => { service.openTab({ type: 'crash-until-fixed', title: 'Crash until fixed' }) })
|
|
expect(container.textContent).toContain('transient')
|
|
const retry = [...container.querySelectorAll('button')]
|
|
.find(button => button.textContent === t('terminalRetry'))
|
|
expect(retry).toBeDefined()
|
|
// The crash condition is gone (e.g. the data arrived): the retry button
|
|
// remounts the tab's content and the strip clears.
|
|
shouldThrow = false
|
|
act(() => { retry!.click() })
|
|
expect(container.textContent).toContain('recovered')
|
|
expect(container.textContent).not.toContain('transient')
|
|
})
|
|
})
|