Files
apes-sidebar/src/client/EditorHost.tsx
T
Atlas Zeng 6ee1ba11d0 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.
2026-08-17 15:00:03 +08:00

423 lines
17 KiB
TypeScript

/**
* 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 { 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 { 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 =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' }
/** 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.
const controller = new AbortController()
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
if (cancelled) return
switch (action.kind) {
case 'binary':
setLoad({ status: 'binary' })
return
case 'render':
setLoad({
status: 'ready',
viewer: action.viewer,
content: action.content,
truncated: action.truncated,
mediaUrl: action.mediaUrl,
customData: action.customData,
})
return
case 'customLoad':
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
case 'fetchFsRead':
api.fsRead(scope, path).then((result) => {
if (cancelled) return
// Binary reads carry the head bytes for the detect re-match.
const outcome = planFsReadOutcome(action.viewer, {
binary: result.kind === 'binary',
content: result.kind === 'text' ? result.content : '',
truncated: result.truncated,
head: result.kind === 'binary' ? result.head : undefined,
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
apply(outcome)
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
}
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() }
}, [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}>
<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>
</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) }}
/>
)
}