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
+49 -18
View File
@@ -13,8 +13,8 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
* / terminal / subagent / diff) through the sidebar service; external
* Tab type identifier. Builtins register their ids (editor / git / terminal
* / subagent / browser / diff) through the sidebar service; external
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
* the registry stays open.
*/
@@ -25,7 +25,7 @@ export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
/** One open tab. `path` carries the file (editor) or is absent (git/terminal);
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
* plugin-owned JSON-serializable state, preserved across reloads. */
export interface SidebarTab {
@@ -105,6 +105,13 @@ function uid(prefix: string): string {
return `${prefix}:${nextIdCounter}`
}
/** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
* covers openSidebarFile opens (per-path dedupe); opens that must not
* dedupe (the tree's "open to the side") mint through here. */
export function mintTabId(): string {
return uid('tab')
}
/**
* The largest numeric suffix across a raw persisted state's counter ids
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
@@ -139,17 +146,24 @@ function maxCounterId(parsed: unknown): number {
return max
}
/** A fresh default state: one explorer tab in one pane, open per the caller's
/** The default tab a fresh session seeds. */
export type DefaultSeed = 'editor-home' | 'none'
/** A fresh default state: one seeded tab in one pane, open per the caller's
* preference. `width` is the caller's preferred panel width (default
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
* true); the store seeds new sessions from the user's side card prefs.
* `seedExplorer` places the default explorer tab — the store passes false
* when the user disabled the explorer tab type in settings, so a fresh
* session starts with an empty pane instead of a tab they turned off. */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
* (an editor tab with no path whose tree panel starts open,
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
* file explorer page — and 'none' starts with an empty pane (the store
* passes it when the user disabled the editor tab type in settings). */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
if (seedExplorer) {
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
if (seed === 'editor-home') {
// No path: the editor host renders its empty-state hint and the docked
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
leaf.active = leaf.tabs[0]!.id
}
// The bottom panel starts closed with an empty pane (its welcome cards
@@ -764,19 +778,21 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// New sessions seed from the user's side card prefs: the width is the
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the default explorer
// tab is skipped when the user disabled the explorer tab type. On a
// NARROW viewport a brand-new session starts collapsed instead — the
// panel is a full-screen drawer there, and auto-opening it on first
// paint would cover the conversation before the user asked. Only the
// first seeding is affected: once the user expands the drawer,
// `panelOpen: true` persists like any other state.
// starts open only when the preference says so, and the seed tab is the
// empty files window (tree panel open) in BOTH editorExplorer modes — a
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
// session starts collapsed instead — the panel is a full-screen drawer
// there, and auto-opening it on first paint would cover the conversation
// before the user asked. Only the first seeding is affected: once the
// user expands the drawer, `panelOpen: true` persists like any other
// state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
return makeDefaultState(width, openByDefault, seed)
}
/**
@@ -891,6 +907,21 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home
// tab — no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
tabs.push({
id: candidate.id,
type: 'editor',
title: 'Files',
meta: { treeOpen: true, ...meta },
})
continue
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).