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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user