* 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.
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
/**
|
|
* Interactive tests for the settings popup's text/number/select rows: the
|
|
* draft is local state committed on blur through the parent's onCommit; the
|
|
* parent's canonical return is adopted (clamped numbers, stored value for
|
|
* invalid input), and the row remounts when the committed pref value changes
|
|
* (a failed commit reverts prefs → the stored value reappears). Select rows
|
|
* commit the picked option's value (single) or the picked values in options
|
|
* order (multi) through onSelectValue.
|
|
*
|
|
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
|
|
* side-card-section.spec.tsx; this file exercises the event paths).
|
|
*/
|
|
// @vitest-environment jsdom
|
|
import { describe, expect, it } from 'vitest'
|
|
import { createElement, type ReactNode } 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 type { SidebarSettingToggle } from '../src/client/service.ts'
|
|
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
|
|
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
|
|
|
/** Render the rows into a detached container under React's act(). */
|
|
function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } {
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const root: Root = createRoot(container)
|
|
act(() => { root.render(node) })
|
|
return {
|
|
container,
|
|
rerender: (next) => { act(() => { root.render(next) }) },
|
|
unmount: () => {
|
|
act(() => { root.unmount() })
|
|
container.remove()
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Type into an input and commit it via blur (React 18: input event +
|
|
* focusout). The native setter bypasses React's value tracker so the
|
|
* change is actually seen. */
|
|
function typeAndBlur(input: HTMLInputElement, value: string): void {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
|
act(() => {
|
|
setter.call(input, value)
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
|
|
|
describe('FeatureSettingsRows typed rows (interactive)', () => {
|
|
it('commits the raw text on blur and adopts the canonical return', () => {
|
|
const commits: Array<[string, string]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'terminalFontFamily',
|
|
type: 'text',
|
|
title: () => 'Font family',
|
|
}
|
|
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
onCommit: (t, raw) => {
|
|
commits.push([t.key, raw])
|
|
return raw
|
|
},
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, 'Monaco')
|
|
expect(commits).toEqual([['terminalFontFamily', 'Monaco']])
|
|
// The canonical return is adopted into the draft.
|
|
expect(input.value).toBe('Monaco')
|
|
unmount()
|
|
})
|
|
|
|
it('clamps numbers into the declared bounds on commit', () => {
|
|
const commits: Array<[string, number]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'terminalFontSize',
|
|
type: 'number',
|
|
title: () => 'Font size',
|
|
min: 9,
|
|
max: 32,
|
|
}
|
|
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: { ...prefs, terminalFontSize: 13 },
|
|
onToggle: () => {},
|
|
onCommit: (t, raw) => {
|
|
const parsed = Number(raw)
|
|
const clamped = Math.min(32, Math.max(9, Math.round(parsed)))
|
|
commits.push([t.key, clamped])
|
|
return String(clamped)
|
|
},
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, '40')
|
|
expect(commits).toEqual([['terminalFontSize', 32]])
|
|
expect(input.value).toBe('32')
|
|
unmount()
|
|
})
|
|
|
|
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
|
|
const commits: Array<[string, number]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'terminalFontSize',
|
|
type: 'number',
|
|
title: () => 'Font size',
|
|
min: 9,
|
|
max: 32,
|
|
}
|
|
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: { ...prefs, terminalFontSize: 13 },
|
|
onToggle: () => {},
|
|
// The parent mirrors the real handler: an emptied number parses to 0
|
|
// and clamps into the bounds (a browser number input never holds a
|
|
// non-numeric string — the draft can only be empty or numeric).
|
|
onCommit: (t, raw) => {
|
|
const clamped = Math.min(32, Math.max(9, Math.round(Number(raw))))
|
|
commits.push([t.key, clamped])
|
|
return String(clamped)
|
|
},
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, '')
|
|
expect(commits).toEqual([['terminalFontSize', 9]])
|
|
expect(input.value).toBe('9')
|
|
unmount()
|
|
})
|
|
|
|
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'terminalFontFamily',
|
|
type: 'text',
|
|
title: () => 'Font family',
|
|
}
|
|
// The parent mirrors the real handler: the optimistic commit adopts the
|
|
// typed value, and a FAILED write reverts prefs to the stored one.
|
|
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
|
|
const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: prefsNow,
|
|
onToggle: () => {},
|
|
onCommit: (_t, raw) => raw,
|
|
}))
|
|
const input = container.querySelector('input')!
|
|
typeAndBlur(input, 'New')
|
|
// Optimistic commit: the draft adopts the typed value.
|
|
expect(input.value).toBe('New')
|
|
// The optimistic pref lands (parent state): the key changes, the row
|
|
// remounts with the same value — no visible reset while editing.
|
|
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
|
|
rerender(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: prefsNow,
|
|
onToggle: () => {},
|
|
onCommit: (_t, raw) => raw,
|
|
}))
|
|
expect(container.querySelector('input')!.value).toBe('New')
|
|
// The write fails: prefs revert to the stored value and the row
|
|
// remounts with it (the stale draft is gone).
|
|
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
|
|
rerender(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs: prefsNow,
|
|
onToggle: () => {},
|
|
onCommit: (_t, raw) => raw,
|
|
}))
|
|
expect(container.querySelector('input')!.value).toBe('Old')
|
|
unmount()
|
|
})
|
|
})
|
|
|
|
/** Open one select row's dropdown: click the anchor, the Menu portals its
|
|
* list into document.body (role="menuitem" rows). */
|
|
function openSelect(container: HTMLDivElement): HTMLElement[] {
|
|
const anchor = container.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!
|
|
act(() => { anchor.click() })
|
|
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
}
|
|
|
|
describe('FeatureSettingsRows select rows (interactive)', () => {
|
|
const options: NonNullable<SidebarSettingToggle['options']> = [
|
|
{ value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' },
|
|
{ value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' },
|
|
]
|
|
|
|
it('commits the picked option value and closes (iconed single select)', () => {
|
|
const commits: Array<[string, unknown]> = []
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'editorExplorer',
|
|
type: 'select',
|
|
title: () => 'Editor explorer',
|
|
options,
|
|
}
|
|
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
onSelectValue: (t, next) => { commits.push([t.key, next]) },
|
|
}))
|
|
// The closed anchor shows the selected option's title (icon variant).
|
|
expect(container.textContent).toContain('Merged')
|
|
const items = openSelect(container)
|
|
// Big-icon cards: title + desc per option.
|
|
expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs'])
|
|
act(() => { items[1]!.click() })
|
|
expect(commits).toEqual([['editorExplorer', false]])
|
|
// Single-pick closes the dropdown.
|
|
expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0)
|
|
unmount()
|
|
})
|
|
|
|
it('toggles membership and commits the picked values in options order (multi)', () => {
|
|
const commits: Array<[string, unknown]> = []
|
|
const plain: NonNullable<SidebarSettingToggle['options']> = [
|
|
{ value: 'a', title: () => 'Alpha' },
|
|
{ value: 'b', title: () => 'Beta' },
|
|
{ value: 'c', title: () => 'Gamma' },
|
|
]
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'pluginKey',
|
|
type: 'select',
|
|
multi: true,
|
|
title: () => 'Pick several',
|
|
options: plain,
|
|
}
|
|
const rows = (value: unknown) => createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
onSelectValue: (t, next) => { commits.push([t.key, next]) },
|
|
valueSource: () => value,
|
|
})
|
|
const { container, rerender, unmount } = mount(rows(['c', 'a']))
|
|
// The anchor shows the picked titles; options follow the declared order.
|
|
const items = openSelect(container)
|
|
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
|
|
// Deselect 'a' → ['c'] is normalized to options order… (['c'] here).
|
|
act(() => { items[0]!.click() })
|
|
expect(commits).toEqual([['pluginKey', ['c']]])
|
|
// The menu stays open under multi; re-render with the committed value.
|
|
rerender(rows(['c']))
|
|
const again = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
expect(again).toHaveLength(3)
|
|
// Select 'b' → committed in OPTIONS order, not pick order.
|
|
act(() => { again[1]!.click() })
|
|
expect(commits[1]).toEqual(['pluginKey', ['b', 'c']])
|
|
unmount()
|
|
})
|
|
|
|
it('shows an em dash when nothing is selected', () => {
|
|
const toggle: SidebarSettingToggle = {
|
|
key: 'editorExplorer',
|
|
type: 'select',
|
|
title: () => 'Editor explorer',
|
|
options,
|
|
}
|
|
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
|
toggles: [toggle],
|
|
prefs,
|
|
onToggle: () => {},
|
|
valueSource: () => undefined,
|
|
}))
|
|
expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—')
|
|
unmount()
|
|
})
|
|
})
|