Files
apes-sidebar/src/client/EditorHost.tsx
T

565 lines
25 KiB
TypeScript

/**
* The editor tab host: the single FILES WINDOW. It resolves a file's
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
* per the matched viewer's fetch strategy, and renders its component — or
* the shared download pane when nothing can render the file. A tab without
* a path (the seeded "Files" home) renders an empty-state hint instead of
* the viewer loading flow; that path-less window IS the file explorer.
*
* The chrome depends on the `editorExplorer` mode (read reactively so
* toggling it re-renders without a reload):
* - merged (in-place): tree click / path-input Enter switch the CURRENT
* tab in place (updateTab rewrites path/title; the tab keeps its id and
* meta, so treeOpen/treeWidth survive the switch);
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
* and a PATH-LESS window is the standalone explorer — it renders ONLY
* the tree panel (search + FileTree, full-window), no editor chrome.
* Editor tabs (with a path) keep the full chrome in both modes.
* The tree's context menu offers the explicit escapes in both modes: open
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
* rightward split of the current pane).
*
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs.
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import { openSidebarFile } from './intercept.tsx'
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
import { updatePluginSettings } from './plugin-settings.ts'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' }
/** The docked tree panel's width bounds (drag-resize clamps into them). */
const TREE_WIDTH_DEFAULT = 240
const TREE_WIDTH_MIN = 160
const TREE_WIDTH_MAX = 480
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
* would change identity every snapshot and loop useSyncExternalStore). */
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
/** 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.get('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[]
revealed: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
}) {
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
const title = tab.title
// A folder window: the model's `sidebar_open` (or any caller) opens a
// directory as an editor tab carrying `meta.dir: true` with the directory
// as its path. It renders the file tree rooted at that folder instead of
// the viewer loading flow (a directory is not a file).
const isDir = metaOf(tab).dir === true
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
// Manual refresh (issue #167): bumping the sequence re-runs the load effect
// with the same path/scope — the only reload entry besides open/close.
const [reloadSeq, setReloadSeq] = useState(0)
// Manual refresh (issue #167 + PR #228): a dirty draft is dropped by the
// reload (the editor instance remounts), so confirm before discarding it.
const refreshFile = (): void => {
if (toolbar?.dirty === true) {
const confirmed = typeof window.confirm === 'function'
? window.confirm(t('refreshUnsavedConfirm'))
: false
if (!confirmed) return
}
setReloadSeq(sequence => sequence + 1)
}
// 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]),
)
// The file tree's "open with" configuration (pluginSettings['editor']): a
// blob subscription, so a pin click or a settings-page edit re-renders the
// menu immediately. The parsed config also drives which targets are shown
// (SSH mode hides the host-local ones).
const editorBlob = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
)
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// 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). A
// folder tab is a folder window in BOTH modes: the tree rooted at the
// folder, no editor chrome.
const showEmpty = path === ''
const treeOnly = showEmpty && !inPlace
const folderRoot = isDir ? path : undefined
/**
* 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.get('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 context menu's "open with" action: reveal the path in the OS file
* manager, or hand the target's URL to its opener — local `file` URLs go
* to the host's external opener, while the SSH-remote form for
* VSCode-family editors launches on the browser/client machine (see
* api.openExternal). Failures are logged only — a missing handler is the
* OS's/browser's dialog, not a sidebar error. */
const openWith = (targetId: string, absolute: string): void => {
const target = openWithTargets.find(item => item.id === targetId)
if (target === undefined) return
if (target.kind === 'reveal') {
void api.openExternal({ action: 'reveal', path: absolute }).catch(
(error: unknown) => { console.error('open external failed', error) },
)
return
}
const url = openWithUrl(target, absolute, openWithConfig)
if (url === undefined) return
void api.openExternal({ action: 'url', url }).catch(
(error: unknown) => { console.error('open external failed', error) },
)
}
/** Toggle one target's pinned state. The write is serialized (see
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
const toggleOpenWithPin = (targetId: string): void => {
updatePluginSettings(store, 'editor', (blob) => {
const config = parseOpenWithConfig(blob.openWith)
const pinned = config.pinned.includes(targetId)
? config.pinned.filter(id => id !== targetId)
: [...config.pinned, targetId]
return { ...blob, openWith: { ...config, pinned } }
})
}
// 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.
// Moves are BATCHED per frame (createFrameBatcher): applying every
// pointermove is a setState that re-renders this host AND the editor
// viewer below it (CodeMirror re-lays out on the width change) at event
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
// the latest width once per frame; release flushes it and commits.
const [dragWidth, setDragWidth] = useState<number | null>(null)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const pendingWidthRef = useRef(0)
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
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
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
}
const onResizeEnd = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
// Flush the last pending frame (a release can land with the final move
// still queued; without the flush a stray frame would re-apply the
// drag width AFTER the null below). Both setStates batch into this same
// event, so the committed treeWidth wins visually.
dragBatcher.flushNow()
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. A folder tab never loads a
// viewer either — its tree is rooted at the folder.
if (showEmpty || isDir) return
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
const controller = new AbortController()
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
if (cancelled) return
switch (action.kind) {
case 'binary':
setLoad({ status: 'binary' })
return
case 'render':
setLoad({
status: 'ready',
viewer: action.viewer,
content: action.content,
truncated: action.truncated,
mediaUrl: action.mediaUrl,
customData: action.customData,
})
return
case 'customLoad':
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
case 'fetchFsRead':
api.fsRead(scope, path).then((result) => {
if (cancelled) return
// Binary reads carry the head bytes for the detect re-match.
const outcome = planFsReadOutcome(action.viewer, {
binary: result.kind === 'binary',
content: result.kind === 'text' ? result.content : '',
truncated: result.truncated,
head: result.kind === 'binary' ? result.head : undefined,
}, (head) => ctx.get('betterSidebar')?.matchFileViewer(path, head), mediaUrlOf)
apply(outcome)
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
}
}
apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq])
// Save-then-refresh in preview mode (issue #167 part C): the edge into
// 'saved' (never a lingering 'saved' state) triggers exactly one reload, so
// a preview-mode Ctrl+S shows the fresh content immediately. Edit mode is
// left alone — reloading would remount the editor and drop the caret.
const prevSaveState = useRef<EditorToolbarState['saveState'] | undefined>(undefined)
useEffect(() => {
const current = toolbar?.saveState
if (prevSaveState.current !== 'saved' && current === 'saved' && toolbar?.mode === 'preview') {
setReloadSeq(sequence => sequence + 1)
}
prevSaveState.current = current
}, [toolbar?.saveState, toolbar?.mode])
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.
// A folder window (meta.dir, any mode) renders the SAME surface rooted
// at the folder instead of the session cwd.
if (treeOnly || folderRoot !== undefined) {
return (
<div className={css.editor}>
<TreePanel
full
store={store}
sessionId={scope.sessionId}
cwd={folderRoot ?? scope.cwd}
expanded={expanded}
revealed={revealed}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
/>
</div>
)
}
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
{toolbar?.modes === true && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
onClick={() => {
// Issue #167 part B: returning from edit to preview reloads so
// the preview renders the just-saved content. A dirty draft
// (or a failed save) suppresses the reload — the draft only
// lives in the editor instance and a remount would drop it.
if (toolbar.mode === 'edit' && toolbar.dirty !== true && toolbar.saveState !== 'failed') {
setReloadSeq(sequence => sequence + 1)
}
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>
)}
{toolbar !== null && (
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refreshFile}
>
<IconRefreshOutline14 size={14} />
</button>
)}
<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' && (isOutsideWorkspaceMessage(load.message)
? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
: <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
store={store}
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
revealed={revealed}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
/>
</div>
)}
</div>
</div>
)
}
/**
* The header's path input: shows the current file relative to the session
* cwd (absolute when outside it). Enter resolves the typed path (relative
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
* and opens it through the parent's mode-aware open (in-place switch or a
* per-path dedupe tab); Escape/blur restores the current value. The parent
* keys it by `path` so an in-place switch remounts and reseeds the draft.
*/
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
const { path, cwd, onOpen } = props
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
const [value, setValue] = useState(display)
const commit = (): void => {
const input = value.trim()
if (input === '' || input === display) {
setValue(display)
return
}
onOpen(resolveSidebarPath(cwd, input))
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
// path stays, so the input falls back to its own display value. (Merged
// mode remounts this input on the new path; the reset is harmless.)
setValue(display)
}
return (
<input
className={css.editorPathInput}
value={value}
placeholder={t('editorPathPlaceholder')}
title={path}
spellCheck={false}
onChange={(event) => { setValue(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
} else if (event.key === 'Escape') {
setValue(display)
}
}}
onBlur={() => { setValue(display) }}
/>
)
}