* 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.
122 lines
4.5 KiB
TypeScript
122 lines
4.5 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|