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
+86
View File
@@ -0,0 +1,86 @@
/**
* Recursive file-name search for the editor's merged-mode side panel.
* Streams the tree with opendir and matches the query as a case-insensitive
* substring of each entry's NAME (paths stay relative to the search root —
* the client resolves them against the session cwd). No .gitignore semantics
* (this is a name lookup, not a code search), but `.git` directories are
* skipped outright (VCS internals are never useful results) and symlink
* directories are NOT descended (cycle safety).
*
* Two performance budgets bound the walk: `maxMatches` (the client renders
* the flat list) and `maxVisited` (a runaway tree — a home directory root,
* a node_modules forest — must not stall the host). Exceeding either stops
* early with `truncated: true`.
*/
import { opendir } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
/** One search: the relative paths of the matching entries (dirs included so
* the client can hint where matches live) plus the truncation flag. */
export interface FsSearchResult {
matches: string[]
truncated: boolean
}
/** Search budgets (both injectable for tests). */
export interface FsSearchOptions {
/** Row cap of the result list (default 200). */
maxMatches?: number
/** Total entries visited before the walk gives up (default 100_000). */
maxVisited?: number
}
const DEFAULT_MAX_MATCHES = 200
const DEFAULT_MAX_VISITED = 100_000
/**
* Search `root` recursively for entries whose name contains `query`
* (case-insensitive).
* @param root - absolute search root.
* @param query - the name substring; empty matches nothing.
* @param opts - budget overrides (tests).
* @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
* plus whether a budget cut the walk short. An unreadable level is skipped
* (permission errors never fail the whole search).
*/
export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
const needle = query.trim().toLowerCase()
if (needle === '') return { matches: [], truncated: false }
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
const matches: string[] = []
let visited = 0
let truncated = false
const walk = async (dir: string): Promise<void> => {
if (truncated) return
const level = await opendir(dir).catch(() => undefined)
if (level === undefined) return
for await (const dirent of level) {
visited += 1
if (visited > maxVisited) {
truncated = true
return
}
// .git is VCS-internal noise: never matched, never descended.
if (dirent.isDirectory() && dirent.name === '.git') continue
if (dirent.name.toLowerCase().includes(needle)) {
matches.push(join(relative(root, dir), dirent.name))
if (matches.length >= maxMatches) {
truncated = true
return
}
}
// Descend real directories only: a symlinked directory may point back
// up the tree (cycle).
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
await walk(join(dir, dirent.name))
if (truncated) return
}
}
}
await walk(root)
// '/' separators on every platform: the client joins onto the cwd itself.
return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
}