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