/** * The editor tab host: the single FILES WINDOW. It resolves a file's * previewer through the sidebar registry (`matchFileViewer`), fetches bytes * per the matched viewer's fetch strategy, and renders its component — or * the shared download pane when nothing can render the file. A tab without * a path (the seeded "Files" home) renders an empty-state hint instead of * the viewer loading flow; that path-less window IS the file explorer. * * The chrome depends on the `editorExplorer` mode (read reactively so * toggling it re-renders without a reload): * - merged (in-place): tree click / path-input Enter switch the CURRENT * tab in place (updateTab rewrites path/title; the tab keeps its id and * meta, so treeOpen/treeWidth survive the switch); * - split: they open through `openSidebarFile` (a per-path dedupe tab), * and a PATH-LESS window is the standalone explorer — it renders ONLY * the tree panel (search + FileTree, full-window), no editor chrome. * Editor tabs (with a path) keep the full chrome in both modes. * The tree's context menu offers the explicit escapes in both modes: open * in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh * rightward split of the current pane). * * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in * editor-load.ts); this component only wires it to the host APIs. */ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { createElement } from 'react' import clsx from 'clsx' import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { api, mediaUrl, type SessionScope } from './api.ts' import { BinaryDownload } from './binary-download.tsx' import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' import { baseName } from './FileTree.tsx' import { openSidebarFile } from './intercept.tsx' import { TreePanel } from './TreePanel.tsx' import { t } from './locales.ts' import { relativeTo } from './paths.ts' import { resolveSidebarPath } from './produced-files.ts' import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts' import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts' import css from './sidebar.module.css' type EditorLoad = | { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } | { status: 'binary' } /** The docked tree panel's width bounds (drag-resize clamps into them). */ const TREE_WIDTH_DEFAULT = 240 const TREE_WIDTH_MIN = 160 const TREE_WIDTH_MAX = 480 /** The tab's persisted meta object (a malformed meta reads as empty). */ function metaOf(tab: SidebarTab): Record { return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta) ? tab.meta as Record : {} } /** 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): 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({ 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(null) const controlsRef = useRef(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(null) const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) const treeWidth = dragWidth ?? treeWidthOf(tab) const onResizeStart = (event: React.PointerEvent): void => { event.preventDefault() // jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents. event.currentTarget.setPointerCapture?.(event.pointerId) dragRef.current = { startX: event.clientX, startWidth: treeWidth } } const onResizeMove = (event: React.PointerEvent): void => { const drag = dragRef.current if (drag === null) return setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))) } const onResizeEnd = (event: React.PointerEvent): void => { const drag = dragRef.current if (drag === null) return dragRef.current = null setDragWidth(null) const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)) if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth }) } useEffect(() => { // A (re)load or a path-less tab clears any hoisted toolbar state — the // fresh viewer re-registers its own. setToolbar(null) // The seeded home tab (no path) never loads a viewer — the empty-state // hint renders until the user picks a file. if (showEmpty) return let cancelled = false // Aborts the matched viewer's `load` when the editor tears down (tab // closed, path changed, session switched) or re-matches the viewer. const controller = new AbortController() setLoad({ status: 'loading' }) const mediaUrlOf = (): string => mediaUrl(scope, path) const apply = (action: EditorLoadAction): void => { if (cancelled) return switch (action.kind) { case 'binary': setLoad({ status: 'binary' }) return case 'render': setLoad({ status: 'ready', viewer: action.viewer, content: action.content, truncated: action.truncated, mediaUrl: action.mediaUrl, customData: action.customData, }) return case 'customLoad': void action.viewer.load?.(path, scope, controller.signal).then((data) => { if (cancelled) return setLoad({ status: 'ready', viewer: action.viewer, customData: data }) }).catch((error: unknown) => { if (cancelled) return setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) }) return case 'fetchFsRead': api.fsRead(scope, path).then((result) => { if (cancelled) return // Binary reads carry the head bytes for the detect re-match. const outcome = planFsReadOutcome(action.viewer, { binary: result.kind === 'binary', content: result.kind === 'text' ? result.content : '', truncated: result.truncated, head: result.kind === 'binary' ? result.head : undefined, }, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf) apply(outcome) }).catch((error: unknown) => { if (cancelled) return setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) }) return } } apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) return () => { cancelled = true; controller.abort() } }, [scope.sessionId, scope.cwd, path, ctx, showEmpty]) const treeOpen = treeOpenOf(tab) /** Persist the panel flag on the tab (survives reloads with the layout). */ const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) } const saveLabel = toolbar === null ? '' : toolbar.saveState === 'saving' ? t('loading') : toolbar.saveState === 'saved' ? t('saved') : toolbar.saveState === 'failed' ? t('saveFailed') : '' // Split mode: the path-less window IS the standalone explorer — the tree // panel fills the whole tab (search + FileTree, full form), no editor // chrome. File opens land in new per-path tabs through openFile above. if (treeOnly) { return (
) } return (
{toolbar?.modes === true && (
)} {toolbar?.dirty === true && } {toolbar?.editable === true && ( )} {saveLabel !== '' && ( {saveLabel} )}
{showEmpty &&
{t('editorEmptyHint')}
} {!showEmpty && load.status === 'loading' &&
{t('loading')}
} {!showEmpty && load.status === 'error' &&
{load.message}
} {!showEmpty && load.status === 'binary' && } {!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, })}
{treeOpen && (
)}
) } /** * 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 ( { setValue(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault() commit() } else if (event.key === 'Escape') { setValue(display) } }} onBlur={() => { setValue(display) }} /> ) }