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:
+49
-18
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user