feat(editor): 文件窗口与资源管理器二合一 + 声明式设置 select 行 (#151)

* 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.
This commit is contained in:
Atlas Zeng
2026-08-17 15:00:03 +08:00
committed by GitHub
parent c923fc57b1
commit 6ee1ba11d0
31 changed files with 2054 additions and 263 deletions
+54 -2
View File
@@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select'
/** One option of a `type: 'select'` setting row. */
export interface SidebarSettingSelectOption {
/** The value written to the setting key when this option is picked
* (JSON-serializable: string / number / boolean). */
value: string | number | boolean
/** Option title (i18n friendly: string or () => string). */
title: string | (() => string)
/** Option description (i18n friendly); rendered under the title in the
* icon dropdown. */
desc?: string | (() => string)
/** Option icon: when ANY option declares one, the dropdown renders
* big-icon option cards and the closed control shows the selected
* option's icon too; without icons both are a single line of text. */
icon?: ReactNode | ((size: number) => ReactNode)
}
/** One declarative setting of a tab/viewer, rendered as a nested row in the
* Side card settings page (e.g. the Subagent page's "auto-open when a
* subagent appears" switch, or the terminal's custom font rows). `type`
* selects the control: 'switch' (default) renders the custom switch,
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
* input clamped to `min`/`max`. */
* input clamped to `min`/`max`, 'select' a dropdown over the declared
* `options` (single-pick writes the option's value; `multi: true` writes
* the array of picked values and defaults to false). */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
@@ -73,6 +91,11 @@ export interface SidebarSettingToggle {
placeholder?: string
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
unit?: string
/** Options of a `type: 'select'` row. */
options?: readonly SidebarSettingSelectOption[]
/** Whether a `type: 'select'` row allows picking several options (the
* stored value is then an array of option values); defaults to false. */
multi?: boolean
}
/** Props of a descriptor's custom settings panel (`settings.render`). */
@@ -240,6 +263,33 @@ export interface FileViewerProps {
mediaUrl?: string
/** custom load() return value (fetchStrategy='custom'). */
customData?: unknown
/** Internal (built-in text editor): 'host' asks the viewer to skip its own
* toolbar row — the editor host's merged-mode header renders it instead,
* fed through the two callbacks below. Viewers that ignore these fields
* render exactly as before. */
toolbar?: 'self' | 'host'
/** Internal: the viewer reports its toolbar state (mode/dirty/save). */
onToolbarState?: (state: EditorToolbarState) => void
/** Internal: the viewer registers its toolbar commands on mount (null on
* unmount). */
onToolbarControls?: (controls: EditorToolbarControls | null) => void
}
/** The toolbar state a text editor reports to the host's merged-mode header. */
export interface EditorToolbarState {
/** Whether the preview/edit mode toggle applies (markdown/html). */
modes: boolean
mode: 'preview' | 'edit'
dirty: boolean
/** Whether saving applies (text content loaded). */
editable: boolean
saveState: 'idle' | 'saving' | 'saved' | 'failed'
}
/** The commands the host's merged-mode header sends back to the viewer. */
export interface EditorToolbarControls {
setMode(mode: 'preview' | 'edit'): void
save(): void
}
/** Describes one file previewer (builtins register themselves too). */
@@ -434,6 +484,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.3'
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -445,6 +496,7 @@ export const SIDEBAR_FEATURES = [
'tabMeta',
'pluginSettings',
'urlTarget',
'settingSelect',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */