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
+341 -23
View File
@@ -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) }}
/>
)
}
@@ -1,20 +1,25 @@
/**
* The file explorer: a lazy VSCode-style tree rooted at the session's
* working directory. Levels load on expansion (one API call per directory),
* directories sort first, hidden entries render dimmed, and the expansion
* set lives in the per-session state. Clicking a file opens an editor tab.
* The controlled file tree behind the files window's tree panel (TreePanel
* wraps it with the search box): a lazy VSCode-style tree rooted at the
* session's working directory. Levels load on expansion (one API call per
* directory), directories sort first, hidden entries render dimmed. The
* expansion set lives in the per-session state (owned by the caller); the
* caller also owns the refresh affordance — a `refreshTick` bump wipes the
* level cache so the visible set reloads.
*
* Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` to the composer draft), and right-click
* opens a context menu to copy the relative or absolute path (with a brief
* "copied" label replacing the button after a successful write); file rows
* also offer a download action (the host serves raw bytes, binary-safe).
* opens a context menu: file rows offer the caller's open escapes
* (new tab / to the side, only when the callbacks exist) and a download
* action (the host serves raw bytes, binary-safe); every row can copy the
* relative or absolute path (with a brief "copied" label replacing the
* button after a successful write).
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconLinkOutline16, IconRefreshOutline16, Menu, writeClipboard,
IconLinkOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
@@ -27,7 +32,7 @@ interface LevelData {
}
/** Root label: the last path segment (mirror of the host rootLabel). */
function baseName(path: string): string {
export function baseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
@@ -36,19 +41,24 @@ function baseName(path: string): string {
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
export function ExplorerView(props: {
export function FileTree(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
onOpenFileNewTab?: (path: string) => void
/** Context-menu "open to the side" (file rows; absent → no entry). */
onOpenFileSide?: (path: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
/** Bump to wipe the level cache and reload the visible set. */
refreshTick: number
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
const [refreshTick, setRefreshTick] = useState(0)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
@@ -69,9 +79,19 @@ export function ExplorerView(props: {
})
}, [sessionId, cwd, storeLevel])
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
useEffect(() => {
if (lastTick.current === refreshTick) return
lastTick.current = refreshTick
dataRef.current = {}
setData({})
}, [refreshTick])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh button wipes the cache.
// not refetched. Only the refresh tick wipes the cache.
const root = cwd
if (root === undefined) return
loadDir(root)
@@ -197,64 +217,53 @@ export function ExplorerView(props: {
}
return (
<div className={css.explorer}>
<div className={css.explorerHeader}>
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => {
dataRef.current = {}
setData({})
setRefreshTick(tick => tick + 1)
}}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
<div className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
<div className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the explorer's overflow clip cannot crop it).
(portal so the tree's overflow clip cannot crop it).
*/}
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
: []),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
@@ -266,6 +275,14 @@ export function ExplorerView(props: {
const target = rowMenu
if (target === null) return
setRowMenu(null)
if (id === 'open-new-tab') {
onOpenFileNewTab?.(target.path)
return
}
if (id === 'open-side') {
onOpenFileSide?.(target.path)
return
}
if (id === 'download') {
downloadFile(target.path)
return
+53
View File
@@ -403,6 +403,59 @@
width: 76px;
}
/* Select rows: the closed anchor is a compact button (selected option's
icon when the options carry icons, plus title and a chevron); the dropdown
itself is the primitives Menu (portal). */
.selectAnchor {
display: flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.selectAnchor:hover {
border-color: var(--dsw-alias-label-dimmed);
}
.selectAnchorIcon {
display: inline-flex;
flex: none;
}
.selectAnchorText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One big-icon option card inside the select dropdown. */
.selectOption {
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
}
.selectOptionIcon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-secondary);
}
.selectOptionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.suffix {
font-size: 14px;
line-height: 22px;
+124 -2
View File
@@ -41,9 +41,11 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconChevronDownOutline14,
IconPlusOutline16,
IconSettingsOutline16,
Input,
Menu,
Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
@@ -209,6 +211,10 @@ export function FeatureSettingsRows(props: {
* display (clamped for numbers, the current pref when the input is
* invalid). Optional: rows with no handler keep their draft. */
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
/** Commit one select row: the picked option's value (single) or the array
* of picked values (`multi: true`). Optional: rows with no handler are
* display-only. */
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
/** Explicit value source (v0.12.0+): when given, rows read their values
* from it instead of the `prefs` face — plugin-owned rows read their
* own blob, so a plugin key can never collide with (or silently read)
@@ -216,12 +222,23 @@ export function FeatureSettingsRows(props: {
* the latter collides with the inherited Object.prototype.valueOf.) */
valueSource?: (key: string) => unknown
}) {
const { toggles, prefs, onToggle, onCommit, valueSource } = props
const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
if (toggle.type === 'select') {
return (
<SelectRow
key={toggle.key}
toggle={toggle}
title={title}
value={read(toggle.key)}
onSelectValue={onSelectValue}
/>
)
}
if ((toggle.type ?? 'switch') === 'switch') {
return (
<div key={toggle.key} className={css.popupRow}>
@@ -302,6 +319,99 @@ function TypedRow(props: {
)
}
/**
* One select row: a dropdown over the toggle's declared `options`. When any
* option carries an icon, the dropdown renders big-icon option cards (icon +
* title + desc) and the closed anchor shows the selected option's icon as
* well; without icons both are a single line of text. Single-pick commits the
* option's value and closes; `multi` toggles membership, commits the picked
* values as an array (in options order), and stays open.
*/
function SelectRow(props: {
toggle: SidebarSettingToggle
title: string
value: unknown
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
}) {
const { toggle, title, value, onSelectValue } = props
const options = toggle.options ?? []
const multi = toggle.multi === true
const [open, setOpen] = useState(false)
const hasIcons = options.some(option => option.icon !== undefined)
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
const selected = options.filter(option => picked.includes(option.value))
/** Commit one picked option (toggle semantics under multi). */
const pick = (index: number): void => {
const option = options[index]
if (option === undefined) return
if (!multi) {
onSelectValue?.(toggle, option.value)
setOpen(false)
return
}
const current = Array.isArray(value) ? [...value] : []
const at = current.indexOf(option.value)
if (at >= 0) current.splice(at, 1)
else current.push(option.value)
// Stable wire order: follow the declared options order, not pick order.
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
}
const anchor = (
<button
type="button"
className={css.selectAnchor}
aria-label={title}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => { setOpen(now => !now) }}
>
{!multi && hasIcons && selected[0] !== undefined && (
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
)}
<span className={css.selectAnchorText}>
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
</span>
<IconChevronDownOutline14 size={12} />
</button>
)
return (
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<span className={css.control}>
<Menu
open={open}
anchor={anchor}
items={options.map((option, index) => ({
id: String(index),
label: hasIcons
? (
<span className={css.selectOption}>
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
<span className={css.selectOptionText}>
<span className={css.title}>{textOf(option.title)}</span>
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
</span>
</span>
)
: textOf(option.title),
}))}
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
onSelect={(id) => { pick(Number(id)) }}
onClose={() => { setOpen(false) }}
portal
/>
</span>
</div>
)
}
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
@@ -318,12 +428,14 @@ export function SettingsBody(props: {
service: BetterSidebarService
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
onPluginWrite: (key: string, value: unknown) => void
onClose: () => void
}) {
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
@@ -356,6 +468,7 @@ export function SettingsBody(props: {
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
onSelectValue={onSelectValue}
/>
)}
{pluginToggles.length > 0 && (
@@ -364,6 +477,7 @@ export function SettingsBody(props: {
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]}
/>
)}
@@ -493,6 +607,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
applyPref({ [toggle.key]: next })
}
/** Commit one declaratively-declared select row (the option's value, or an
* array of values under `multi`). */
const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => {
applyPref({ [toggle.key]: next })
}
/**
* Commit one declaratively-declared text/number row. Numbers are parsed
* and clamped to the toggle's declared min/max (an unparsable input falls
@@ -807,8 +927,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
onSelectValue={onSelectSetting}
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }}
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
onClose={() => { setSettingsFor(null) }}
store={store}
+30 -2
View File
@@ -7,7 +7,11 @@
* so this component never fetches or dispatches — it only edits.
*
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
* row below the host's title bar, VSCode-style.
* row below the host's title bar, VSCode-style — unless the host passes
* `toolbar: 'host'` (the merged editor-explorer mode), in which case this
* component skips the row and reports state + registers commands through
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -24,7 +28,7 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
@@ -278,8 +282,31 @@ export function TextEditor(props: FileViewerProps) {
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
// Host-toolbar mode (the merged editor header renders the controls): skip
// the own toolbar row, report the state after every relevant render (the
// JSON key guards redundant calls), and register the commands on mount.
const hostToolbar = props.toolbar === 'host'
const lastToolbarRef = useRef('')
useEffect(() => {
if (!hostToolbar) return
const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
const key = JSON.stringify(state)
if (lastToolbarRef.current === key) return
lastToolbarRef.current = key
props.onToolbarState?.(state)
})
useEffect(() => {
if (!hostToolbar) return
// `save` reads live refs only, and `setMode` is the stable state setter —
// registering this render's closures is safe for the mount's lifetime.
props.onToolbarControls?.({ setMode, save })
return () => { props.onToolbarControls?.(null) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostToolbar])
return (
<>
{!hostToolbar && (
<div className={css.editorHeader}>
{(markdown || html) && (
<div className={css.editorModeToggle}>
@@ -313,6 +340,7 @@ export function TextEditor(props: FileViewerProps) {
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div>
)}
{editable && (
<>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
+121
View File
@@ -0,0 +1,121 @@
/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over either the shared controlled FileTree (empty query) or the flat
* result list (relative paths; click opens through the caller's mode-aware
* open). Owns its refresh tick: the icon next to the search input clears
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
* a drag-resize handle) and provides the file context-menu open escapes.
*/
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { FileTree } from './FileTree.tsx'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css'
export function TreePanel(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** File context-menu "open in a new tab" (passed through to FileTree). */
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
onReferenceFile: (path: string) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
const needle = query.trim()
useEffect(() => {
if (needle === '') {
setResults(null)
setError(null)
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
setResults(found)
setError(null)
}).catch((failure: unknown) => {
if (controller.signal.aborted) return
setResults(null)
setError(failure instanceof Error ? failure.message : String(failure))
})
}, 300)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [sessionId, cwd, needle])
return (
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
<div className={css.editorTreeSearch}>
<input
className={css.editorSearchInput}
value={query}
placeholder={t('editorSearchPlaceholder')}
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
{needle === '' ? (
<FileTree
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
onReferenceFile={onReferenceFile}
refreshTick={refreshTick}
/>
) : (
<div className={css.explorerBody}>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</div>
)}
</div>
)
}
+4
View File
@@ -135,6 +135,10 @@ export const api = {
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/** Global recursive file-name search rooted at the session cwd (the editor
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
+44 -25
View File
@@ -1,17 +1,17 @@
/**
* The 7 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / browser / subagent / editor / diff) through
* The 6 built-in tab descriptors: the plugin registers its own pages
* (editor / git / terminal / browser / subagent / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<n>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota).
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
@@ -59,33 +59,52 @@ function uiTerminalCount(state: SidebarState): number {
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 7 built-in tab descriptors. */
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
id: 'editor',
title: () => t('editor'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
// The single files window: an editor tab with no path IS the file
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
title: () => t('files'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
hidden: false,
dedupeKey: (tab) => tab.path,
// Declarative settings: the file-open behavior picker (in-place switch
// vs per-path windows) renders as an iconed select row under the
// editor card's gear in the Side card settings page.
settings: {
toggles: [{
key: 'editorExplorer',
type: 'select',
title: () => t('editorExplorer'),
desc: () => t('editorExplorerDesc'),
options: [
{
value: true,
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
title: () => t('editorExplorerMerged'),
desc: () => t('editorExplorerMergedDesc'),
},
{
value: false,
icon: (size: number) => <IconCodeOutline16 size={size} />,
title: () => t('editorExplorerSplit'),
desc: () => t('editorExplorerSplitDesc'),
},
],
}],
},
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
scope={scope}
tab={tab}
expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
+30
View File
@@ -11,10 +11,25 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
editorExplorer: '文件打开方式',
editorExplorerDesc: '控制文件打开方式',
editorExplorerMerged: '合并',
editorExplorerMergedDesc: '文件在同一窗口内原地切换;新窗口默认展开文件树',
editorExplorerSplit: '独立',
editorExplorerSplitDesc: '无路径窗口即资源管理器(仅文件树);文件各自新开窗口(带文件树,默认收起)',
editorTreeToggle: '文件树面板',
editorPathPlaceholder: '输入文件路径(相对会话目录或绝对路径),Enter 打开',
editorSearchPlaceholder: '按文件名搜索…',
editorSearchNoResults: '无匹配文件',
editorSearchTruncated: '结果过多,仅显示部分匹配',
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
newTab: '新建标签页',
openExplorer: '资源管理器',
brokenSymlink: '失效的软链接',
@@ -238,10 +253,25 @@ export const zh = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'File open behavior',
editorExplorerDesc: 'Controls how files open',
editorExplorerMerged: 'Merged',
editorExplorerMergedDesc: 'Files switch in place in the same window; new windows start with the tree open',
editorExplorerSplit: 'Separate',
editorExplorerSplitDesc: 'Path-less windows are the standalone explorer (tree only); each file opens its own window (tree docked, closed by default)',
editorTreeToggle: 'File tree panel',
editorPathPlaceholder: 'File path (relative to the session directory or absolute), Enter to open',
editorSearchPlaceholder: 'Search files by name…',
editorSearchNoResults: 'No matching files',
editorSearchTruncated: 'Too many results — showing a partial list',
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
openFileNewTab: 'Open in New Tab',
openFileSide: 'Open to the Side',
newTab: 'New tab',
openExplorer: 'Explorer',
brokenSymlink: 'Broken symlink',
+3
View File
@@ -60,6 +60,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
titleBarCompat: typeof record.titleBarCompat === 'boolean'
? record.titleBarCompat
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
+54 -2
View File
@@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select'
/** One option of a `type: 'select'` setting row. */
export interface SidebarSettingSelectOption {
/** The value written to the setting key when this option is picked
* (JSON-serializable: string / number / boolean). */
value: string | number | boolean
/** Option title (i18n friendly: string or () => string). */
title: string | (() => string)
/** Option description (i18n friendly); rendered under the title in the
* icon dropdown. */
desc?: string | (() => string)
/** Option icon: when ANY option declares one, the dropdown renders
* big-icon option cards and the closed control shows the selected
* option's icon too; without icons both are a single line of text. */
icon?: ReactNode | ((size: number) => ReactNode)
}
/** One declarative setting of a tab/viewer, rendered as a nested row in the
* Side card settings page (e.g. the Subagent page's "auto-open when a
* subagent appears" switch, or the terminal's custom font rows). `type`
* selects the control: 'switch' (default) renders the custom switch,
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
* input clamped to `min`/`max`. */
* input clamped to `min`/`max`, 'select' a dropdown over the declared
* `options` (single-pick writes the option's value; `multi: true` writes
* the array of picked values and defaults to false). */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
@@ -73,6 +91,11 @@ export interface SidebarSettingToggle {
placeholder?: string
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
unit?: string
/** Options of a `type: 'select'` row. */
options?: readonly SidebarSettingSelectOption[]
/** Whether a `type: 'select'` row allows picking several options (the
* stored value is then an array of option values); defaults to false. */
multi?: boolean
}
/** Props of a descriptor's custom settings panel (`settings.render`). */
@@ -240,6 +263,33 @@ export interface FileViewerProps {
mediaUrl?: string
/** custom load() return value (fetchStrategy='custom'). */
customData?: unknown
/** Internal (built-in text editor): 'host' asks the viewer to skip its own
* toolbar row — the editor host's merged-mode header renders it instead,
* fed through the two callbacks below. Viewers that ignore these fields
* render exactly as before. */
toolbar?: 'self' | 'host'
/** Internal: the viewer reports its toolbar state (mode/dirty/save). */
onToolbarState?: (state: EditorToolbarState) => void
/** Internal: the viewer registers its toolbar commands on mount (null on
* unmount). */
onToolbarControls?: (controls: EditorToolbarControls | null) => void
}
/** The toolbar state a text editor reports to the host's merged-mode header. */
export interface EditorToolbarState {
/** Whether the preview/edit mode toggle applies (markdown/html). */
modes: boolean
mode: 'preview' | 'edit'
dirty: boolean
/** Whether saving applies (text content loaded). */
editable: boolean
saveState: 'idle' | 'saving' | 'saved' | 'failed'
}
/** The commands the host's merged-mode header sends back to the viewer. */
export interface EditorToolbarControls {
setMode(mode: 'preview' | 'edit'): void
save(): void
}
/** Describes one file previewer (builtins register themselves too). */
@@ -434,6 +484,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.3'
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -445,6 +496,7 @@ export const SIDEBAR_FEATURES = [
'tabMeta',
'pluginSettings',
'urlTarget',
'settingSelect',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
+144
View File
@@ -666,6 +666,8 @@
flex: 1;
min-height: 0;
overflow-y: auto;
/* Deep trees truncate their labels instead of scrolling horizontally. */
overflow-x: hidden;
padding: 2px 6px 8px;
}
@@ -677,7 +679,12 @@
display: flex;
align-items: center;
gap: 6px;
/* border-box keeps the inline indent padding (depth * 22 + 6) INSIDE the
row's 100% width — without it deep rows overflow and force a horizontal
scrollbar on the tree body. */
box-sizing: border-box;
width: 100%;
max-width: 100%;
height: 34px;
padding: 0 8px;
border: none;
@@ -716,8 +723,12 @@
}
.explorerName {
/* min-width: 0 lets the flex item shrink below its content so the label
truncates with an ellipsis instead of widening the row. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The hover-revealed @-reference button at the row's far right (and the
@@ -799,6 +810,139 @@
white-space: nowrap;
}
/* Merged mode (the editorExplorer pref): the header's path input replaces
the plain title, and a toggleable file tree docks at the tab's right edge. */
.editorPathInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorPathInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
/* The tree-panel toggle in the header: active = panel open (the selected
fill of the tab strip). */
.editorTreeToggleActive {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-active);
}
.editorBody {
flex: 1;
min-height: 0;
display: flex;
}
.editorMain {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The docked file tree: the wrapper owns the (drag-adjustable) width and
the hairline separation; the resize handle is a wider invisible strip on
its left edge with a hover affordance. */
.editorTreeDock {
position: relative;
flex: none;
min-height: 0;
display: flex;
border-left: 1px solid var(--dsw-alias-border-l1);
}
.editorTreeResize {
position: absolute;
top: 0;
bottom: 0;
/* Inside the dock (left edge): pointer capture on the handle keeps the
drag alive even when the pointer leaves the strip. */
left: 0;
width: 6px;
cursor: col-resize;
touch-action: none;
z-index: 3;
}
.editorTreeResize:hover {
background: var(--dsw-alias-border-l2);
}
.editorTreePanel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The tree panel's full-window form (the `full` prop): fill the tab body. */
.editorTreePanelFull {
flex: 1;
}
.editorTreeSearch {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorSearchInput {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorSearchInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.editorSearchHint {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.editorSearchResult {
display: block;
width: 100%;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editorSearchResult:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorStatus {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
+49 -18
View File
@@ -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).
+2 -1
View File
@@ -99,7 +99,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
openByDefault: z.boolean().default(false),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
@@ -108,6 +108,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
interceptOpenPath: z.boolean().default(true),
editorExplorer: z.boolean().default(true),
titleBarCompat: z.boolean().default(false),
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
htmlViewerNoSandbox: z.boolean().default(false),
+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 }
}
+9
View File
@@ -29,6 +29,7 @@ import {
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
@@ -213,6 +214,14 @@ function buildApi(
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
return listDirectory(target, resolved.listLimit)
},
'fs.search': async (payload) => {
// The editor side panel's global name search: rooted at the session
// cwd (not caller-targetable — the walk is unbounded by design and
// must never escape the workspace), budgeted inside searchFiles.
const { cwd } = cwdOf(payload)
const query = requireString(payload, 'query')
return searchFiles(cwd, query)
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative
+12 -2
View File
@@ -60,6 +60,15 @@ export interface SidebarPrefs {
* own enable switch gates it too (both must be on for the takeover).
*/
interceptOpenPath: boolean
/**
* Whether the editor tab runs in merged mode: a path input replaces the
* plain header and a toggleable file-tree panel (with a global name
* search) docks at the tab's right edge. On by default; also makes brand
* new sessions seed an empty editor tab (tree panel open) instead of the
* explorer tab. The switch lives under the editor card's gear in the
* Side card settings; off restores the pre-merge editor exactly.
*/
editorExplorer: boolean
/**
* Position compatibility mode: reserves space at the top for the native
* Windows title bar (drawn at the window's top-right corner over the web
@@ -156,7 +165,7 @@ export interface SidebarPrefs {
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30
export const WIDTH_PERCENT_DEFAULT = 35
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9
@@ -170,7 +179,7 @@ export const TITLE_BAR_STRIP_DEFAULT = 40
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
openByDefault: false,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenJobs: true,
@@ -179,6 +188,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true,
editorExplorer: true,
titleBarCompat: false,
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
htmlViewerNoSandbox: false,