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:
+341
-23
@@ -1,22 +1,43 @@
|
||||
/**
|
||||
* The editor tab host: resolves a file's previewer through the sidebar
|
||||
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
||||
* fetch strategy, and renders its component — or the shared download pane
|
||||
* when nothing can render the file. The header shows the file title; the
|
||||
* editable code/markdown viewers render their own toolbar below it.
|
||||
* The editor tab host: the single FILES WINDOW. It resolves a file's
|
||||
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
|
||||
* per the matched viewer's fetch strategy, and renders its component — or
|
||||
* the shared download pane when nothing can render the file. A tab without
|
||||
* a path (the seeded "Files" home) renders an empty-state hint instead of
|
||||
* the viewer loading flow; that path-less window IS the file explorer.
|
||||
*
|
||||
* The chrome depends on the `editorExplorer` mode (read reactively so
|
||||
* toggling it re-renders without a reload):
|
||||
* - merged (in-place): tree click / path-input Enter switch the CURRENT
|
||||
* tab in place (updateTab rewrites path/title; the tab keeps its id and
|
||||
* meta, so treeOpen/treeWidth survive the switch);
|
||||
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
|
||||
* and a PATH-LESS window is the standalone explorer — it renders ONLY
|
||||
* the tree panel (search + FileTree, full-window), no editor chrome.
|
||||
* Editor tabs (with a path) keep the full chrome in both modes.
|
||||
* The tree's context menu offers the explicit escapes in both modes: open
|
||||
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
|
||||
* rightward split of the current pane).
|
||||
*
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
||||
import { baseName } from './FileTree.tsx'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
@@ -25,11 +46,156 @@ type EditorLoad =
|
||||
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' }
|
||||
|
||||
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
/** The docked tree panel's width bounds (drag-resize clamps into them). */
|
||||
const TREE_WIDTH_DEFAULT = 240
|
||||
const TREE_WIDTH_MIN = 160
|
||||
const TREE_WIDTH_MAX = 480
|
||||
|
||||
/** The tab's persisted meta object (a malformed meta reads as empty). */
|
||||
function metaOf(tab: SidebarTab): Record<string, unknown> {
|
||||
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
|
||||
? tab.meta as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
/** Read the persisted tree-panel flag of one editor tab: an explicit
|
||||
* boolean meta wins; otherwise path-less tabs (the seeded home) default
|
||||
* open and file tabs default closed. */
|
||||
function treeOpenOf(tab: SidebarTab): boolean {
|
||||
const treeOpen = metaOf(tab).treeOpen
|
||||
return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
|
||||
}
|
||||
|
||||
/** Read the persisted tree-panel width (clamped; default 240). */
|
||||
function treeWidthOf(tab: SidebarTab): number {
|
||||
const width = metaOf(tab).treeWidth
|
||||
return typeof width === 'number' && Number.isFinite(width)
|
||||
? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
|
||||
: TREE_WIDTH_DEFAULT
|
||||
}
|
||||
|
||||
/** Merge a patch into the tab's persisted meta (rides the layout). */
|
||||
function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
|
||||
ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
|
||||
}
|
||||
|
||||
/** Clamp one dock width into the contract range. */
|
||||
function clampTreeWidth(value: number): number {
|
||||
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
export function EditorHost(props: {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
expanded: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
|
||||
const path = tab.path ?? ''
|
||||
const title = tab.title
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
|
||||
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
|
||||
// reload. The snapshot is the bare boolean so unrelated store churn never
|
||||
// re-renders the editor.
|
||||
const inPlace = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
|
||||
)
|
||||
// A path-less tab shows the empty-state hint in merged mode — and in split
|
||||
// mode it is the standalone explorer (tree-only, see the render below).
|
||||
const showEmpty = path === ''
|
||||
const treeOnly = showEmpty && !inPlace
|
||||
|
||||
/**
|
||||
* Open a file from THIS window (tree click / search row / path input):
|
||||
* merged mode switches this tab in place (stable id, meta survives);
|
||||
* split mode opens a per-path dedupe tab through openSidebarFile.
|
||||
*/
|
||||
const openFile = (absolute: string): void => {
|
||||
if (inPlace) {
|
||||
ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
|
||||
} else {
|
||||
openSidebarFile(ctx, store, scope.sessionId, absolute)
|
||||
}
|
||||
}
|
||||
|
||||
/** The context menu's explicit "new tab" escape (per-path dedupe). */
|
||||
const openFileNewTab = (absolute: string): void => {
|
||||
openSidebarFile(ctx, store, scope.sessionId, absolute)
|
||||
}
|
||||
|
||||
/**
|
||||
* The context menu's "open to the side": a fresh editor tab (uid id — the
|
||||
* `'editor:' + path` convention would clash with the id safety net on a
|
||||
* second side-open of the same file) in a rightward split of THIS pane.
|
||||
*/
|
||||
const openFileSide = (absolute: string): void => {
|
||||
store.reduce((state) => {
|
||||
const key = treeOf(state, tab.id)
|
||||
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
|
||||
const fresh: SidebarTab = {
|
||||
id: mintTabId(),
|
||||
type: 'editor',
|
||||
title: baseName(absolute),
|
||||
path: absolute,
|
||||
meta: { treeOpen: false },
|
||||
}
|
||||
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
|
||||
return { ...state, [key]: node, activePane: leafId }
|
||||
})
|
||||
}
|
||||
|
||||
// The viewer's toolbar, hoisted into THIS header: the text editor reports
|
||||
// its state and registers its commands (both null/absent for viewers
|
||||
// without a toolbar — image, pdf, binary download).
|
||||
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
|
||||
const controlsRef = useRef<EditorToolbarControls | null>(null)
|
||||
const onToolbarState = useCallback((next: EditorToolbarState) => {
|
||||
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
|
||||
}, [])
|
||||
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
|
||||
controlsRef.current = controls
|
||||
}, [])
|
||||
|
||||
// The docked panel's drag-resize: pointer capture on the handle itself
|
||||
// (no window listeners — the captured pointer keeps tracking even off the
|
||||
// handle). Local width while dragging, persisted into meta.treeWidth on
|
||||
// release. The panel docks right, so dragging LEFT widens it.
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
||||
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
const treeWidth = dragWidth ?? treeWidthOf(tab)
|
||||
|
||||
const onResizeStart = (event: React.PointerEvent): void => {
|
||||
event.preventDefault()
|
||||
// jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
dragRef.current = { startX: event.clientX, startWidth: treeWidth }
|
||||
}
|
||||
const onResizeMove = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
|
||||
}
|
||||
const onResizeEnd = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
dragRef.current = null
|
||||
setDragWidth(null)
|
||||
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
||||
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// A (re)load or a path-less tab clears any hoisted toolbar state — the
|
||||
// fresh viewer re-registers its own.
|
||||
setToolbar(null)
|
||||
// The seeded home tab (no path) never loads a viewer — the empty-state
|
||||
// hint renders until the user picks a file.
|
||||
if (showEmpty) return
|
||||
let cancelled = false
|
||||
// Aborts the matched viewer's `load` when the editor tears down (tab
|
||||
// closed, path changed, session switched) or re-matches the viewer.
|
||||
@@ -81,24 +247,176 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
|
||||
|
||||
const treeOpen = treeOpenOf(tab)
|
||||
/** Persist the panel flag on the tab (survives reloads with the layout). */
|
||||
const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
|
||||
const saveLabel = toolbar === null ? ''
|
||||
: toolbar.saveState === 'saving' ? t('loading')
|
||||
: toolbar.saveState === 'saved' ? t('saved')
|
||||
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
|
||||
|
||||
// Split mode: the path-less window IS the standalone explorer — the tree
|
||||
// panel fills the whole tab (search + FileTree, full form), no editor
|
||||
// chrome. File opens land in new per-path tabs through openFile above.
|
||||
if (treeOnly) {
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<TreePanel
|
||||
full
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
<EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
|
||||
{toolbar?.modes === true && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
|
||||
onClick={() => { controlsRef.current?.setMode('preview') }}
|
||||
>
|
||||
{t('preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
|
||||
onClick={() => { controlsRef.current?.setMode('edit') }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
|
||||
{toolbar?.editable === true && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('save')}
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={() => { controlsRef.current?.save() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && (
|
||||
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
|
||||
aria-label={t('editorTreeToggle')}
|
||||
title={t('editorTreeToggle')}
|
||||
aria-pressed={treeOpen}
|
||||
onClick={toggleTree}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.editorBody}>
|
||||
<div className={css.editorMain}>
|
||||
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
|
||||
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
// The viewer's toolbar always hoists into this host's header.
|
||||
toolbar: 'host',
|
||||
onToolbarState,
|
||||
onToolbarControls,
|
||||
})}
|
||||
</div>
|
||||
{treeOpen && (
|
||||
<div className={css.editorTreeDock} style={{ width: treeWidth }}>
|
||||
<div
|
||||
className={css.editorTreeResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('editorTreeToggle')}
|
||||
onPointerDown={onResizeStart}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeEnd}
|
||||
onPointerCancel={onResizeEnd}
|
||||
/>
|
||||
<TreePanel
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's path input: shows the current file relative to the session
|
||||
* cwd (absolute when outside it). Enter resolves the typed path (relative
|
||||
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
|
||||
* and opens it through the parent's mode-aware open (in-place switch or a
|
||||
* per-path dedupe tab); Escape/blur restores the current value. The parent
|
||||
* keys it by `path` so an in-place switch remounts and reseeds the draft.
|
||||
*/
|
||||
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
|
||||
const { path, cwd, onOpen } = props
|
||||
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
|
||||
const [value, setValue] = useState(display)
|
||||
|
||||
const commit = (): void => {
|
||||
const input = value.trim()
|
||||
if (input === '' || input === display) {
|
||||
setValue(display)
|
||||
return
|
||||
}
|
||||
onOpen(resolveSidebarPath(cwd, input))
|
||||
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
|
||||
// path stays, so the input falls back to its own display value. (Merged
|
||||
// mode remounts this input on the new path; the reset is harmless.)
|
||||
setValue(display)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={css.editorPathInput}
|
||||
value={value}
|
||||
placeholder={t('editorPathPlaceholder')}
|
||||
title={path}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { setValue(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setValue(display)
|
||||
}
|
||||
}}
|
||||
onBlur={() => { setValue(display) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user