feat(native): render the plugin's tabs in DSH's native right Sidebar
Phase 2 of the 0.1.5 adaptation: the plugin stops drawing its own right
panel and contributes tab TYPES to DSH's native right Sidebar, which owns
the right column. The bottom workbench (terminal dock) stays plugin-owned.
Registrations (src/client/native/):
- every TabDescriptor becomes a native tab type (kind = descriptor.id, the
default `extension` band) plus a keyed body in `sidebar.right.pane.tab`
and a live chip title in `sidebar.right.pane.tab.title`
- the `editor` type also claims `dsh-resource://file/**`, so it outranks
the built-in `text` preview (fallback) and every file the product opens
lands in the plugin's editor
- the built-in `files` page kind is TAKEN OVER by an extension
registration of the same kind: `openTab('files')` draws the plugin's
explorer, and the built-in resumes when the plugin unregisters
- registrations follow the plugin registry + the side-card settings: a
type registered later (external plugin) gets its native type too, a
disabled type is unregistered (absent from the guide, refuses opens)
Adapter (src/client/native/tab-adapter.tsx):
- one synthetic plugin `SidebarTab` per native tab id, minted from the
native record + navigation params (or the descriptor's own createTab,
which a native guide open bypasses), kept live across navigations and
updateTab writes
- the explorer's per-tab expansion/reveal sets and the per-kind instance
counter, which the plugin's own layout used to hold
Service routing (src/client/service.ts):
- `setSurface` installs the native write face; openTab/openFile land
natively (a path becomes a resource address, a path-less editor open
becomes the `files` page, the rest carry the seed as navigation params),
closeTab/updateTab/activateTab recognise native tab ids
- new optional `OpenTabSeed.target` ('right' default, 'bottom' keeps the
bottom workbench); the bottom panel's own + menu and auto-terminal pass
'bottom'
- src/client/native/surface.ts probes the controller's per-session writes
(`openTabIn`/`openResourceIn`/`closeIn`, not in `ISidebarRight`) and
queues opens for a session whose surface is not mounted yet
Right panel retired behaviourally (Phase 3 deletes the code): Sidebar
renders its bottom workbench only, keeps the panel collapsed, and hides
the right-panel toggle. The panel-specific e2e specs are removed; the
mount lane now drives the native guide (tab sweep), the plugin explorer
inside the native pane (editor/mermaid/README chunks) and the sidechat
host routes.
Verified: pnpm typecheck / lint / build / test (126 files, 1349 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (7/7).
This commit is contained in:
+37
-26
@@ -33,7 +33,7 @@ import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab,
|
||||
isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
@@ -56,7 +56,6 @@ import { useHostFeeds } from './sidebar/use-host-feeds.ts'
|
||||
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
|
||||
import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -102,8 +101,23 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
|
||||
return tag
|
||||
}
|
||||
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurface?: boolean }) {
|
||||
const { ctx, store } = props
|
||||
// With DSH's native right Sidebar owning the right column, this panel keeps
|
||||
// only its bottom workbench: the right panel and its toggle are not offered.
|
||||
const nativeSurface = props.nativeSurface === true
|
||||
|
||||
// Keep the retired right panel collapsed: nothing routes content into it any
|
||||
// more, so an open state (restored from a pre-0.1.5 layout, or written by a
|
||||
// stale flow) would reserve width for an empty column.
|
||||
useEffect(() => {
|
||||
if (!nativeSurface) return
|
||||
const enforce = (): void => {
|
||||
store.reduce(state => (state.panelOpen ? { ...state, panelOpen: false } : state))
|
||||
}
|
||||
enforce()
|
||||
return store.subscribe(enforce)
|
||||
}, [nativeSurface, store])
|
||||
|
||||
// Copy freshness: re-render the whole tree when the DSH locale switches.
|
||||
// The module-level t() reads the active locale at call time, so a root
|
||||
@@ -391,7 +405,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// Land the tab in the bottom panel's first pane; the once-flag is set
|
||||
// atomically so later expansions never repeat the auto-open.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'terminal' })
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' })
|
||||
}, [state, store, ctx, narrow])
|
||||
|
||||
// Panel drags: the right panel's width (left edge strip), the bottom
|
||||
@@ -752,14 +766,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
*/
|
||||
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
|
||||
if (sessionId === undefined) return
|
||||
const rel = relativeTo(cwd ?? '', path)
|
||||
if (isDir) {
|
||||
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
|
||||
return
|
||||
}
|
||||
if (!insertFileReference(ctx, sessionId, rel)) {
|
||||
appendToDraft(ctx, sessionId, `@${rel}`)
|
||||
}
|
||||
referenceInChatShared(ctx, sessionId, cwd, path, isDir)
|
||||
}, [ctx, sessionId, cwd])
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
@@ -797,14 +804,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
viewportHeight: layoutViewportHeight,
|
||||
}).height
|
||||
|
||||
const onNewTab = (optionId: string): void => {
|
||||
const onNewTab = (optionId: string, target: 'right' | 'bottom' = 'right'): void => {
|
||||
const service = ctx.get('betterSidebar')
|
||||
const descriptor = service?.getTab(optionId)
|
||||
if (service === undefined || descriptor === undefined) return
|
||||
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
||||
// The session scope rides along: lifecycle callbacks receive it (and
|
||||
// the open stays in the current session, as before).
|
||||
service.openTab({ type: optionId, title }, { sessionId, cwd })
|
||||
// the open stays in the current session, as before). The bottom panel's
|
||||
// own + menu passes 'bottom' so the tab lands in THIS workbench, not in
|
||||
// the native right Sidebar.
|
||||
service.openTab({ type: optionId, title, target }, { sessionId, cwd })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -915,16 +924,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={state.panelOpen ? t('collapse') : t('expand')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!nativeSurface && (
|
||||
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={state.panelOpen ? t('collapse') : t('expand')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The right panel stays mounted while collapsed (hidden off-screen) so
|
||||
@@ -1147,7 +1158,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
tree={state.bottomSplits}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
onNewTab={(optionId) => { onNewTab(optionId, 'bottom') }}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
|
||||
getTabIcon={tabIconOf}
|
||||
getTabBadge={tabBadgeOf}
|
||||
|
||||
+19
-1
@@ -18,6 +18,9 @@ import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { createNativeTabRecords } from './native/tab-adapter.tsx'
|
||||
import { registerNativeSurface } from './native/index.ts'
|
||||
import { createNativeSurface } from './native/surface.ts'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
||||
@@ -137,6 +140,21 @@ export function apply(ctx: Context): void {
|
||||
// are ready by the time the sidebar renders.
|
||||
const service = createBetterSidebarService(sidebarStore)
|
||||
ctx.provide('betterSidebar', service)
|
||||
// The native right-Sidebar surface: the plugin's content is registered as
|
||||
// DSH tab types (one per descriptor) and every open routes there, so the
|
||||
// right column belongs to the host and only the bottom workbench stays
|
||||
// plugin-owned. Both halves live for this fiber's lifetime.
|
||||
const nativeRecords = createNativeTabRecords()
|
||||
const nativeSurface = createNativeSurface(ctx, nativeRecords)
|
||||
service.setSurface(nativeSurface)
|
||||
ctx.effect(
|
||||
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
|
||||
'dsh-better-sidebar: native right-Sidebar registrations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
|
||||
'dsh-better-sidebar: native right-Sidebar surface',
|
||||
)
|
||||
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
|
||||
// instead of "Terminal 1". Start with a safe fallback and replace it as
|
||||
// soon as the host shell info resolves. Tabs created before the response
|
||||
@@ -284,7 +302,7 @@ export function apply(ctx: Context): void {
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore, nativeSurface: true })))
|
||||
mounted = true
|
||||
guardAnchor()
|
||||
scheduleHostCheck()
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ar: Record<string, string> = {
|
||||
files: 'الملفات',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
|
||||
changesRead: 'قراءة',
|
||||
changesMdReading: 'قراءة',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
export const de: Record<string, string> = {
|
||||
files: 'Dateien',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
|
||||
changesRead: 'Lesen',
|
||||
changesMdReading: 'Lesen',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/** fr dictionary for the `betterSidebar` namespace. */
|
||||
export const fr: Record<string, string> = {
|
||||
files: 'Fichiers',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
|
||||
changesRead: 'Lecture',
|
||||
changesMdReading: 'Lecture',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const hi: Record<string, string> = {
|
||||
files: 'फ़ाइलें',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
|
||||
changesRead: 'पढ़ना',
|
||||
changesMdReading: 'पठन',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
export const id: Record<string, string> = {
|
||||
files: 'Berkas',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
|
||||
changesRead: 'Baca',
|
||||
changesMdReading: 'Baca',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
export const it: Record<string, string> = {
|
||||
files: 'File',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
|
||||
changesRead: 'Lettura',
|
||||
changesMdReading: 'Lettura',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const ja: Record<string, string> = {
|
||||
files: 'ファイル',
|
||||
nativeGuideDesc: 'DSH のサイドバーで開く',
|
||||
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
|
||||
changesRead: '読み取り',
|
||||
changesMdReading: '閲覧',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
export const ko: Record<string, string> = {
|
||||
files: '파일',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
export const nl: Record<string, string> = {
|
||||
files: 'Bestanden',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
|
||||
changesRead: 'Lezen',
|
||||
changesMdReading: 'Lezen',
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const pl: Record<string, string> = {
|
||||
files: 'Pliki',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
|
||||
changesRead: 'Odczyt',
|
||||
changesMdReading: 'Czytanie',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const pt: Record<string, string> = {
|
||||
files: 'Arquivos',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
|
||||
changesRead: 'Leitura',
|
||||
changesMdReading: 'Leitura',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
export const ru: Record<string, string> = {
|
||||
files: 'Файлы',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const sv: Record<string, string> = {
|
||||
files: 'Filer',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
|
||||
changesRead: 'Läs',
|
||||
changesMdReading: 'Läsning',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const th: Record<string, string> = {
|
||||
files: 'ไฟล์',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
|
||||
changesRead: 'อ่าน',
|
||||
changesMdReading: 'อ่าน',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const tr: Record<string, string> = {
|
||||
files: 'Dosyalar',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
|
||||
changesRead: 'Okuma',
|
||||
changesMdReading: 'Okuma',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const vi: Record<string, string> = {
|
||||
files: 'Tệp',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
|
||||
changesRead: 'Đọc',
|
||||
changesMdReading: 'Đọc',
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const zhHK: Record<string, string> = {
|
||||
files: '檔案',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const zhMO: Record<string, string> = {
|
||||
files: '檔案',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
||||
export const zhTW: Record<string, string> = {
|
||||
files: '檔案',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: '本作業階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
|
||||
export const zh = {
|
||||
files: '文件',
|
||||
nativeGuideDesc: '在 DSH 原生右侧栏打开',
|
||||
changesSessionEmpty: '本会话还没有文件操作',
|
||||
changesRead: '读取',
|
||||
changesMdReading: '阅读',
|
||||
@@ -472,6 +473,7 @@ 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',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'No file operations in this session yet',
|
||||
changesRead: 'Read',
|
||||
changesMdReading: 'Reading',
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* The plugin's content, registered into DSH's native right Sidebar.
|
||||
*
|
||||
* Every tab the plugin can draw becomes a native tab TYPE plus a BODY:
|
||||
*
|
||||
* - the `editor` type is both a page (the files window) and a resource
|
||||
* viewer — it claims `dsh-resource://file/**` at the default `extension`
|
||||
* band, which outranks the built-in `text` preview (`fallback`) and the
|
||||
* built-in `files` tree (`builtin`), so every file the product opens lands
|
||||
* in the plugin's editor;
|
||||
* - the built-in `files` page kind is TAKEN OVER by an `extension`
|
||||
* registration of the same kind, so `openTab('files')` draws the plugin's
|
||||
* explorer instead of the built-in tree; the built-in resumes when this
|
||||
* plugin unregisters;
|
||||
* - every other descriptor (changes / subagent / side chat / terminal /
|
||||
* browser / diff) becomes a page type of its own.
|
||||
*
|
||||
* The registrations follow the plugin's registry lifecycle: a descriptor
|
||||
* registered later (an external plugin) gets its native type too, and a type
|
||||
* the user disabled in the side-card settings is unregistered — so it is
|
||||
* absent from the native guide and `openTab` refuses it.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { parseFileAddress } from '../resource-address.ts'
|
||||
import type { BetterSidebarService, TabDescriptor } from '../service.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import {
|
||||
NativeTabBody,
|
||||
NativeTabTitle,
|
||||
type NativeBodyInjected,
|
||||
type NativeTabInfo,
|
||||
type NativeTabParams,
|
||||
type NativeTabRecords,
|
||||
} from './tab-adapter.tsx'
|
||||
|
||||
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
|
||||
interface NativeTabRegistry {
|
||||
register(definition: {
|
||||
id: string
|
||||
kind: string
|
||||
patterns?: readonly string[]
|
||||
priority?: 'extension' | 'builtin' | 'fallback'
|
||||
canOpen?: (address: string) => boolean
|
||||
title: (address: string) => string
|
||||
guide?: readonly { order: number; title: () => string; description: () => string; icon?: unknown }[]
|
||||
}): () => void
|
||||
}
|
||||
|
||||
/** The kind the plugin's files window owns (page + file viewer). */
|
||||
const EDITOR_KIND = 'editor'
|
||||
|
||||
/** The built-in page kind this plugin takes over. */
|
||||
const FILES_KIND = 'files'
|
||||
|
||||
/** The plugin's implementation id for a descriptor (unique across kinds). */
|
||||
function nativeId(descriptorId: string): string {
|
||||
return `dsh-better-sidebar:${descriptorId}`
|
||||
}
|
||||
|
||||
/** The descriptor's title text, evaluated fresh for the current locale. */
|
||||
function titleOf(descriptor: TabDescriptor): string {
|
||||
return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
||||
}
|
||||
|
||||
/** One descriptor's live native registrations. */
|
||||
interface Registration {
|
||||
readonly dispose: () => void
|
||||
}
|
||||
|
||||
/** Everything the registrations need. */
|
||||
export interface NativeSurfaceDeps {
|
||||
readonly ctx: Context
|
||||
readonly store: SidebarStore
|
||||
readonly service: BetterSidebarService
|
||||
/** The shared native tab record registry (the surface writes it too). */
|
||||
readonly records: NativeTabRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the plugin's tabs into the native right Sidebar and keep them in
|
||||
* step with the plugin's own registry and settings.
|
||||
* @param deps - client context, the plugin store/service, and the records.
|
||||
* @returns a disposer unregistering everything.
|
||||
*/
|
||||
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
const { ctx, store, service, records } = deps
|
||||
const disposers: Array<() => void> = []
|
||||
// The native seat declares `sidebar.right.pane.tab` when its client half
|
||||
// activates, which may be after this plugin; the slot registry's `inject`
|
||||
// re-runs on every declaration lifetime, and the tab-type registry
|
||||
// (`ctx.sidebarRightTabs`) is provided by that same package, so one wait
|
||||
// covers both.
|
||||
const disposeSeat = ctx.slots.inject('sidebar.right.pane.tab', () => {
|
||||
const tabs = ctx.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
|
||||
if (tabs === undefined) return () => {}
|
||||
const live = new Map<string, Registration>()
|
||||
|
||||
const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => {
|
||||
const address = parseFileAddress(info.tab.contentId)
|
||||
return address === undefined ? undefined : { path: address.path }
|
||||
}
|
||||
const fileSessionIdOf = (info: NativeTabInfo): string | undefined => {
|
||||
const address = parseFileAddress(info.tab.contentId)
|
||||
return address !== undefined && address.scope === 'session' ? address.sessionId : undefined
|
||||
}
|
||||
|
||||
/** Register the body + chip-title slots for one native implementation id. */
|
||||
const registerSlots = (
|
||||
id: string,
|
||||
injected: Omit<NativeBodyInjected, 'sessionId'>,
|
||||
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
|
||||
): Array<() => void> => [
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: id,
|
||||
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
|
||||
}, NativeTabBody)),
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: id,
|
||||
inject: () => ({ records }),
|
||||
}, NativeTabTitle)),
|
||||
]
|
||||
|
||||
/** One descriptor's native type + body + title, as one disposable. */
|
||||
const registerDescriptor = (descriptor: TabDescriptor): (() => void) => {
|
||||
const id = nativeId(descriptor.id)
|
||||
const isEditor = descriptor.id === EDITOR_KIND
|
||||
const icon = descriptor.icon
|
||||
const disposeType = tabs.register({
|
||||
id,
|
||||
kind: descriptor.id,
|
||||
...(isEditor
|
||||
? {
|
||||
patterns: ['dsh-resource://file/**'],
|
||||
canOpen: (address: string) => parseFileAddress(address) !== undefined,
|
||||
}
|
||||
: {}),
|
||||
// An external implementation outranks the product's own viewers, which
|
||||
// is what lets the plugin's editor take over file addresses.
|
||||
priority: 'extension',
|
||||
title: () => titleOf(descriptor),
|
||||
...(descriptor.hidden === true
|
||||
? {}
|
||||
: {
|
||||
guide: [{
|
||||
order: descriptor.order ?? 100,
|
||||
title: () => titleOf(descriptor),
|
||||
description: () => t('nativeGuideDesc'),
|
||||
...(typeof icon === 'function'
|
||||
? { icon: (props: { size?: number }) => icon(props.size ?? 16) }
|
||||
: {}),
|
||||
}],
|
||||
}),
|
||||
})
|
||||
const slots = registerSlots(
|
||||
id,
|
||||
{ ctx, store, service, records, descriptorId: descriptor.id },
|
||||
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
|
||||
)
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
}
|
||||
}
|
||||
|
||||
/** One `files`-kind takeover: the plugin's explorer under the built-in kind. */
|
||||
const registerFilesKind = (): (() => void) => {
|
||||
const id = 'dsh-better-sidebar:files'
|
||||
const disposeType = tabs.register({
|
||||
id,
|
||||
kind: FILES_KIND,
|
||||
priority: 'extension',
|
||||
title: () => t('files'),
|
||||
guide: [{
|
||||
order: 10,
|
||||
title: () => t('files'),
|
||||
description: () => t('nativeGuideDesc'),
|
||||
}],
|
||||
})
|
||||
const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bring the live registrations in line with the registry + the settings. */
|
||||
const sync = (): void => {
|
||||
const wanted = new Map<string, () => () => void>()
|
||||
for (const descriptor of service.getTabs()) {
|
||||
if (!service.isTabEnabled(descriptor.id)) continue
|
||||
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
|
||||
}
|
||||
for (const [descriptorId, registration] of live) {
|
||||
if (wanted.has(descriptorId)) continue
|
||||
registration.dispose()
|
||||
live.delete(descriptorId)
|
||||
}
|
||||
for (const [descriptorId, create] of wanted) {
|
||||
if (live.has(descriptorId)) continue
|
||||
live.set(descriptorId, { dispose: create() })
|
||||
}
|
||||
// The built-in files kind follows the editor type's switch: with the
|
||||
// editor disabled the plugin has no explorer to put there.
|
||||
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
|
||||
const hasFiles = live.has(FILES_KIND)
|
||||
if (wantsFiles && !hasFiles) live.set(FILES_KIND, { dispose: registerFilesKind() })
|
||||
if (!wantsFiles && hasFiles) {
|
||||
live.get(FILES_KIND)?.dispose()
|
||||
live.delete(FILES_KIND)
|
||||
}
|
||||
}
|
||||
|
||||
disposers.push(service.subscribe(sync), store.subscribe(sync))
|
||||
sync()
|
||||
return () => {
|
||||
for (const registration of live.values()) registration.dispose()
|
||||
live.clear()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
disposeSeat()
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export type { NativeTabRecords }
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The plugin's write face over DSH's native right Sidebar (`ctx.sidebarRight`).
|
||||
*
|
||||
* The service speaks in the plugin's own vocabulary (tab type, seed, session
|
||||
* scope); this module turns those into the native surface's vocabulary
|
||||
* (kind + navigation params, or a `dsh-resource://` address) and forwards
|
||||
* tab-record operations to the plugin's native record registry.
|
||||
*
|
||||
* Two native limits shape the implementation:
|
||||
*
|
||||
* - the surface exists only while a session's panel is mounted, and the
|
||||
* service's public face (`ISidebarRight`) writes only into THAT session.
|
||||
* The controller also carries `openTabIn` / `openResourceIn` /
|
||||
* `closeIn`, which act on any session whose store the runtime has minted;
|
||||
* both are probed at call time, and an open for a session that has no
|
||||
* store yet is QUEUED and replayed when that session comes on screen;
|
||||
* - layout state is memory-only, so a queued open is not durable either.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { fileAddressFor } from '../resource-address.ts'
|
||||
import type { NativeTabParams, SidebarSurface } from '../service.ts'
|
||||
import type { NativeTabRecords } from './tab-adapter.tsx'
|
||||
|
||||
/** One open the surface could not place yet. */
|
||||
type Pending =
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
|
||||
|
||||
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
|
||||
interface NativeController {
|
||||
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
close(tabId: string): void
|
||||
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
|
||||
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
closeIn?(sessionId: string, tabId: string): void
|
||||
}
|
||||
|
||||
/** The plugin's write face over the native surface. */
|
||||
export interface NativeSurface extends SidebarSurface {
|
||||
/** Replay opens that were queued for a session that had no mounted surface. */
|
||||
flushPending(): void
|
||||
/** Stop observing the session list. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** The active session id, as the client list reports it. */
|
||||
function activeSessionId(ctx: Context): string | undefined {
|
||||
try {
|
||||
return ctx.sessions.list.getSnapshot().current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the plugin's write face to the native controller.
|
||||
* @param ctx - the client context (session list + `ctx.sidebarRight`).
|
||||
* @param records - the plugin's native tab record registry.
|
||||
* @returns the surface, plus a disposer unbinding its session subscription.
|
||||
*/
|
||||
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
|
||||
const pending: Pending[] = []
|
||||
const controller = (): NativeController | undefined =>
|
||||
ctx.get('sidebarRight') as unknown as NativeController | undefined
|
||||
|
||||
const place = (entry: Pending): boolean => {
|
||||
const api = controller()
|
||||
if (api === undefined) return false
|
||||
const active = activeSessionId(ctx)
|
||||
const onScreen = active !== undefined && active === entry.sessionId
|
||||
if (entry.kind === 'tab') {
|
||||
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
|
||||
if (onScreen) {
|
||||
api.openTab(entry.tabKind, options)
|
||||
return true
|
||||
}
|
||||
if (api.openTabIn !== undefined) {
|
||||
api.openTabIn(entry.sessionId, entry.tabKind, options)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const options = {
|
||||
...(entry.line === undefined ? {} : { params: { line: entry.line } }),
|
||||
revealIfOpened: entry.revealIfOpened,
|
||||
}
|
||||
if (onScreen) {
|
||||
api.openResource(entry.address, options)
|
||||
return true
|
||||
}
|
||||
if (api.openResourceIn !== undefined) {
|
||||
api.openResourceIn(entry.sessionId, entry.address, options)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const flushPending = (): void => {
|
||||
if (pending.length === 0) return
|
||||
for (let index = pending.length - 1; index >= 0; index--) {
|
||||
const entry = pending[index]
|
||||
if (entry !== undefined && place(entry)) pending.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const enqueue = (entry: Pending): void => {
|
||||
if (!place(entry)) pending.push(entry)
|
||||
}
|
||||
|
||||
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
|
||||
return {
|
||||
openTab({ sessionId, kind, params, revealIfOpened }) {
|
||||
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
|
||||
},
|
||||
openResource({ sessionId, address, line, revealIfOpened }) {
|
||||
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened })
|
||||
},
|
||||
fileAddress(sessionId, cwd, path) {
|
||||
return fileAddressFor(sessionId, cwd, path)
|
||||
},
|
||||
close(sessionId, tabId) {
|
||||
const record = records.get(tabId)
|
||||
if (record === undefined) return undefined
|
||||
records.drop(tabId)
|
||||
const api = controller()
|
||||
if (api !== undefined) {
|
||||
if (sessionId === activeSessionId(ctx)) api.close(tabId)
|
||||
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
|
||||
}
|
||||
return { type: record.tab.type, title: record.tab.title }
|
||||
},
|
||||
update(tabId, patch) {
|
||||
if (!records.has(tabId)) return false
|
||||
records.update(tabId, patch)
|
||||
return true
|
||||
},
|
||||
activate(tabId) {
|
||||
// The native surface has no cross-pane activation face the plugin needs:
|
||||
// a tab is focused by opening its (kind, address) again, which the
|
||||
// native open already de-duplicates.
|
||||
return records.has(tabId)
|
||||
},
|
||||
has: tabId => records.has(tabId),
|
||||
flushPending,
|
||||
dispose: () => { unsubscribe() },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* The plugin's side of a native right-Sidebar tab.
|
||||
*
|
||||
* A plugin tab descriptor is one registration in the plugin's own registry
|
||||
* (`ctx.betterSidebar`); this module adapts the native tab record to that
|
||||
* descriptor's component contract. The native surface carries the tab's
|
||||
* record (id, kind, title, content identity, navigation params, lifetime
|
||||
* signal); the plugin's components expect a `SidebarTab` plus the explorer
|
||||
* state the plugin's own layout used to hold — both live here.
|
||||
*
|
||||
* The adapter owns three things the native layout does not carry:
|
||||
*
|
||||
* - a synthetic plugin `SidebarTab` per native tab id, minted from the native
|
||||
* record + `navigation.params` and kept live across navigations, so a
|
||||
* component that retitles itself (`updateTab`) or rewrites its path (the
|
||||
* editor's in-place file switch) keeps working;
|
||||
* - the explorer's per-tab expansion/reveal sets;
|
||||
* - the per-kind instance counter behind titles like "Terminal 2".
|
||||
*
|
||||
* Nothing here is a singleton: the registry is created once per client
|
||||
* activation and handed to every registration.
|
||||
*/
|
||||
import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import { RenderBoundary } from '../RenderBoundary.tsx'
|
||||
import { OrphanedTab } from '../OrphanedTab.tsx'
|
||||
import { referenceInChat } from '../reference-in-chat.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import type { SidebarStore, SidebarTab, TabType } from '../state.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* The plugin-side seed a native open carries in `navigation.params`.
|
||||
* JSON-shaped by convention (the native surface does not validate it).
|
||||
*/
|
||||
export interface NativeTabParams {
|
||||
/** Overrides the descriptor's title for this instance. */
|
||||
readonly title?: string
|
||||
/** A file path (the editor window's content seed). */
|
||||
readonly path?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
readonly url?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
readonly diff?: SidebarTab['diff']
|
||||
/** JSON-serializable custom state carried on the synthetic record. */
|
||||
readonly meta?: unknown
|
||||
/** A line to land on (file addresses carry it as a navigation parameter). */
|
||||
readonly line?: number
|
||||
}
|
||||
|
||||
/** The native tab information this adapter reads (structural mirror of `useTabInfo`). */
|
||||
export interface NativeTabInfo {
|
||||
readonly tab: {
|
||||
readonly id: string
|
||||
readonly kind: string
|
||||
readonly title: string
|
||||
readonly contentId: string
|
||||
readonly visible: boolean
|
||||
readonly navigation: {
|
||||
readonly address: string
|
||||
readonly params: NativeTabParams | undefined
|
||||
readonly revision: number
|
||||
}
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
}
|
||||
|
||||
/** One native tab's plugin-side view state. */
|
||||
interface View {
|
||||
tab: SidebarTab
|
||||
scope: SessionScope
|
||||
expanded: string[]
|
||||
revealed: string[]
|
||||
/** Bumped on every mutation; the components subscribe to it. */
|
||||
version: number
|
||||
}
|
||||
|
||||
/** The plugin-side record registry for native tabs. */
|
||||
export interface NativeTabRecords {
|
||||
/**
|
||||
* The synthetic record for a native tab, minted on first sight and kept
|
||||
* across navigations (a navigation refreshes the seed fields, never the
|
||||
* identity or a plugin-side title/meta mutation).
|
||||
* @param input - the native record and the session it lives in.
|
||||
* @returns the current view state.
|
||||
*/
|
||||
ensure(input: {
|
||||
id: string
|
||||
kind: string
|
||||
title: string
|
||||
params: NativeTabParams | undefined
|
||||
scope: SessionScope
|
||||
/**
|
||||
* The descriptor's own factory, called ONCE for a record that arrives
|
||||
* without seed fields (a native guide open, which knows nothing about the
|
||||
* plugin's per-instance minting): it supplies the title and the meta a
|
||||
* view needs — the side chat's thread bootstrap, the terminal's name.
|
||||
*/
|
||||
mint?: () => { title?: string; meta?: unknown } | undefined
|
||||
}): View
|
||||
/** One record by native tab id. */
|
||||
get(id: string): View | undefined
|
||||
/** Whether this id belongs to a native tab (vs the plugin's own layout). */
|
||||
has(id: string): boolean
|
||||
/** Merge a patch into the synthetic record (the `updateTab` path). */
|
||||
update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** Forget a record (the native tab closed). */
|
||||
drop(id: string): void
|
||||
/** Toggle one directory in a record's expansion set. */
|
||||
toggleExpanded(id: string, path: string): void
|
||||
/** Mint the next instance number of a kind (titles like "Terminal 2"). */
|
||||
nextInstance(kind: string): number
|
||||
/** A per-record version for `useSyncExternalStore`. */
|
||||
versionOf(id: string): number
|
||||
/** Subscribe to record changes (title/path/meta/expanded). */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Create the record registry for one client activation. */
|
||||
export function createNativeTabRecords(): NativeTabRecords {
|
||||
const views = new Map<string, View>()
|
||||
const instances = new Map<string, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
const notify = (): void => { for (const listener of listeners) listener() }
|
||||
const put = (id: string, view: View): void => {
|
||||
views.set(id, { ...view, version: view.version + 1 })
|
||||
notify()
|
||||
}
|
||||
return {
|
||||
ensure({ id, kind, title, params, scope, mint }) {
|
||||
const existing = views.get(id)
|
||||
if (existing === undefined) {
|
||||
const seeded = params?.title === undefined && params?.meta === undefined ? mint?.() : undefined
|
||||
const meta = params?.meta ?? seeded?.meta
|
||||
const minted: View = {
|
||||
tab: {
|
||||
id,
|
||||
type: kind as TabType,
|
||||
title: params?.title ?? seeded?.title ?? title,
|
||||
...(params?.path === undefined ? {} : { path: params.path }),
|
||||
...(params?.diff === undefined ? {} : { diff: params.diff }),
|
||||
...(meta === undefined ? {} : { meta }),
|
||||
},
|
||||
scope,
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
version: 0,
|
||||
}
|
||||
views.set(id, minted)
|
||||
return minted
|
||||
}
|
||||
// A navigation may carry new seed fields (the editor's in-place switch,
|
||||
// a browser tab pointed at another URL); the record's identity and any
|
||||
// plugin-side mutation (title/meta from updateTab) stay.
|
||||
const patch: Partial<SidebarTab> = {}
|
||||
if (params?.path !== undefined && params.path !== existing.tab.path) patch.path = params.path
|
||||
if (params?.diff !== undefined) patch.diff = params.diff
|
||||
if (params?.url !== undefined) {
|
||||
const meta = typeof existing.tab.meta === 'object' && existing.tab.meta !== null
|
||||
? existing.tab.meta as Record<string, unknown>
|
||||
: {}
|
||||
patch.meta = { ...meta, url: params.url }
|
||||
}
|
||||
if (existing.scope.cwd !== scope.cwd) {
|
||||
views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } })
|
||||
return views.get(id)!
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return existing
|
||||
const next: View = { ...existing, tab: { ...existing.tab, ...patch } }
|
||||
views.set(id, next)
|
||||
return next
|
||||
},
|
||||
get: id => views.get(id),
|
||||
has: id => views.has(id),
|
||||
update(id, patch) {
|
||||
const entry = views.get(id)
|
||||
if (entry === undefined) return
|
||||
put(id, { ...entry, tab: { ...entry.tab, ...patch } })
|
||||
},
|
||||
drop(id) {
|
||||
if (views.delete(id)) notify()
|
||||
},
|
||||
toggleExpanded(id, path) {
|
||||
const entry = views.get(id)
|
||||
if (entry === undefined) return
|
||||
const expanded = entry.expanded.includes(path)
|
||||
? entry.expanded.filter(candidate => candidate !== path)
|
||||
: [...entry.expanded, path]
|
||||
put(id, { ...entry, expanded })
|
||||
},
|
||||
nextInstance(kind) {
|
||||
const next = (instances.get(kind) ?? 0) + 1
|
||||
instances.set(kind, next)
|
||||
return next
|
||||
},
|
||||
versionOf: id => views.get(id)?.version ?? 0,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** What a body registration injects (the plugin's business face). */
|
||||
export interface NativeBodyInjected {
|
||||
readonly sessionId: string
|
||||
readonly ctx: Context
|
||||
readonly store: SidebarStore
|
||||
readonly service: BetterSidebarService
|
||||
readonly records: NativeTabRecords
|
||||
/** The descriptor id this body draws (one registration per descriptor). */
|
||||
readonly descriptorId: string
|
||||
/**
|
||||
* Extra seed fields derived from the native record — a file address carries
|
||||
* its path there, not in `navigation.params`.
|
||||
*/
|
||||
readonly paramsOf?: (info: NativeTabInfo) => NativeTabParams | undefined
|
||||
/**
|
||||
* The session the body acts in, when the record names one (a
|
||||
* `session`-scoped file address names its own session); absent falls back to
|
||||
* the session the slot is scoped to.
|
||||
*/
|
||||
readonly sessionIdOf?: (info: NativeTabInfo) => string | undefined
|
||||
}
|
||||
|
||||
/** The props the slot framework adds to every tab body and title. */
|
||||
export interface NativeBodyFrameworkProps {
|
||||
readonly useTabInfo: () => NativeTabInfo
|
||||
}
|
||||
|
||||
/** Subscribe a component to its own record's mutations. */
|
||||
function useRecordVersion(records: NativeTabRecords, id: string): number {
|
||||
return useSyncExternalStore(
|
||||
listener => records.subscribe(listener),
|
||||
() => records.versionOf(id),
|
||||
)
|
||||
}
|
||||
|
||||
/** The current session's workspace root, live from the client session list. */
|
||||
function useSessionCwd(ctx: Context, sessionId: string): string | undefined {
|
||||
return useSyncExternalStore(
|
||||
useMemo(() => (listener: () => void) => ctx.sessions.list.subscribe(listener), [ctx]),
|
||||
() => ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin tab rendered inside the native right Sidebar: the descriptor's
|
||||
* own component with the plugin's props, over a synthetic record minted from
|
||||
* the native tab and dropped when the record ends.
|
||||
*/
|
||||
export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkProps): ReactNode {
|
||||
const { ctx, store, service, records, descriptorId, useTabInfo } = props
|
||||
const info = useTabInfo()
|
||||
const nativeTab = info.tab
|
||||
const version = useRecordVersion(records, nativeTab.id)
|
||||
const sessionId = props.sessionIdOf?.(info) ?? props.sessionId
|
||||
const cwd = useSessionCwd(ctx, sessionId)
|
||||
const scope = useMemo((): SessionScope => ({ sessionId, cwd }), [sessionId, cwd])
|
||||
// `version` is not read: it only forces this render when the record changed.
|
||||
void version
|
||||
const derived = props.paramsOf?.(info)
|
||||
const params = derived === undefined && nativeTab.navigation.params === undefined
|
||||
? undefined
|
||||
: { ...derived, ...nativeTab.navigation.params }
|
||||
const descriptor = service.getTab(descriptorId)
|
||||
const view = records.ensure({
|
||||
id: nativeTab.id,
|
||||
kind: nativeTab.kind,
|
||||
title: nativeTab.title,
|
||||
params,
|
||||
scope,
|
||||
mint: () => {
|
||||
const state = store.getSnapshot().state
|
||||
if (descriptor?.createTab === undefined || state === undefined) return undefined
|
||||
const minted = descriptor.createTab(state)
|
||||
return minted === null ? undefined : { title: minted.tab.title, meta: minted.tab.meta }
|
||||
},
|
||||
})
|
||||
useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id])
|
||||
if (descriptor === undefined) {
|
||||
return createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible })
|
||||
}
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
createElement(descriptor.component, {
|
||||
ctx,
|
||||
store,
|
||||
scope,
|
||||
tab: view.tab,
|
||||
visible: nativeTab.visible,
|
||||
expanded: view.expanded,
|
||||
revealed: view.revealed,
|
||||
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
|
||||
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
|
||||
onOpenDiff: (tab: SidebarTab) => {
|
||||
service.openTab({
|
||||
type: 'diff',
|
||||
title: tab.title,
|
||||
id: tab.id,
|
||||
...(tab.diff === undefined ? {} : { diff: tab.diff }),
|
||||
}, scope)
|
||||
},
|
||||
onSubagentJump: (childSessionId: string) => {
|
||||
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** What a title registration injects. */
|
||||
export interface NativeTitleInjected {
|
||||
readonly records: NativeTabRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* A live tab chip: the synthetic record's title (the editor rewrites it on an
|
||||
* in-place file switch, the side chat on the thread's first prompt). Without
|
||||
* this registration the chip would keep the title captured when the tab
|
||||
* opened.
|
||||
*/
|
||||
export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkProps): ReactNode {
|
||||
const { records, useTabInfo } = props
|
||||
const nativeTab = useTabInfo().tab
|
||||
return useSyncExternalStore(
|
||||
listener => records.subscribe(listener),
|
||||
() => records.get(nativeTab.id)?.tab.title ?? nativeTab.title,
|
||||
)
|
||||
}
|
||||
|
||||
/** The component pair one descriptor contributes to the native surface. */
|
||||
export interface NativeTabComponents {
|
||||
readonly body: ComponentType<NativeBodyInjected & NativeBodyFrameworkProps>
|
||||
readonly title: ComponentType<NativeTitleInjected & NativeBodyFrameworkProps>
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The explorer's `@`-reference button: insert one file or folder reference
|
||||
* into the conversation draft of a session.
|
||||
*
|
||||
* Directories append the folder mention (`@dir/`) as plain text so DSH's
|
||||
* folder decoration and completion keep working; files insert a structured
|
||||
* chip like the native `@` picker, so the whole reference stays one link
|
||||
* instead of decorating only the leading folder. The session-scope context and
|
||||
* the conversation input service are resolved at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*
|
||||
* Shared by the plugin's own panel and the native right-Sidebar tab body, so
|
||||
* both surfaces behave identically.
|
||||
*/
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
|
||||
/**
|
||||
* Reference one path in the session's conversation draft.
|
||||
* @param ctx - the client context.
|
||||
* @param sessionId - the session whose draft receives the reference.
|
||||
* @param cwd - that session's workspace root (for the relative spelling).
|
||||
* @param path - the absolute or workspace-relative path to reference.
|
||||
* @param isDir - whether the path is a directory (folder mention vs file chip).
|
||||
*/
|
||||
export function referenceInChat(
|
||||
ctx: Context,
|
||||
sessionId: string,
|
||||
cwd: string | undefined,
|
||||
path: string,
|
||||
isDir: boolean,
|
||||
): void {
|
||||
const rel = relativeTo(cwd ?? '', path)
|
||||
if (isDir) {
|
||||
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
|
||||
return
|
||||
}
|
||||
if (!insertFileReference(ctx, sessionId, rel)) {
|
||||
appendToDraft(ctx, sessionId, `@${rel}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The DSH resource-address grammar for files (`dsh-resource://file/…`).
|
||||
*
|
||||
* DSH's right Sidebar opens content by ADDRESS, and a file's address IS the
|
||||
* tab's content identity: the same address is the same tab. Two scopes exist:
|
||||
*
|
||||
* - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path
|
||||
* relative to that session's workspace root (`src/a.ts`, no leading `/`);
|
||||
* - `dsh-resource://file/absolute/<path>` names a file by its absolute path
|
||||
* with the leading `/` dropped (`absolute/home/me/x.txt`; Windows
|
||||
* `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment,
|
||||
* `absolute//server/share/x.txt`). It carries no session.
|
||||
*
|
||||
* Every id and path segment is component-encoded, so a name carrying `#`, `?`
|
||||
* or a space survives the round trip; `:` stays literal so a drive letter
|
||||
* reads as written.
|
||||
*
|
||||
* The plugin parses these addresses itself instead of importing
|
||||
* `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate
|
||||
* forbids value-importing an unlisted `@deepseek-ai/*` package. This module
|
||||
* mirrors that package's implementation (`packages/util/workspace-path` in
|
||||
* DSH 0.1.5) and is pinned by tests/resource-address.spec.ts.
|
||||
*/
|
||||
|
||||
/** The scheme and type every file address opens with. */
|
||||
export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
|
||||
|
||||
/**
|
||||
* A file resource address, in one of two scopes.
|
||||
*/
|
||||
export type FileAddress =
|
||||
| {
|
||||
readonly scope: 'session'
|
||||
/** The session whose workspace root the path is relative to. */
|
||||
readonly sessionId: string
|
||||
/** Workspace-relative `/`-separated path, no leading `/`; empty for the root itself. */
|
||||
readonly path: string
|
||||
}
|
||||
| {
|
||||
readonly scope: 'absolute'
|
||||
/** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a drive, `//server/share/a` for UNC. */
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** Component-encode one id or path segment, keeping `:` literal for drive letters. */
|
||||
function encodeSegment(segment: string): string {
|
||||
return encodeURIComponent(segment).replace(/%3A/gi, ':')
|
||||
}
|
||||
|
||||
/** Encode a `/`-separated path segment by segment. */
|
||||
function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeSegment).join('/')
|
||||
}
|
||||
|
||||
/** Whether a decoded first path segment is a Windows drive (`C:`). */
|
||||
function isDriveSegment(segment: string | undefined): boolean {
|
||||
return segment !== undefined && /^[A-Za-z]:$/.test(segment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the address of a file inside one session's workspace.
|
||||
* @param sessionId - the session whose workspace root the path is relative to.
|
||||
* @param path - workspace-relative path; backslashes normalize to `/`, a leading `./` or `/` is dropped.
|
||||
* @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
|
||||
*/
|
||||
export function sessionFileAddress(sessionId: string, path: string): string {
|
||||
const relative = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '')
|
||||
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(relative)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the address of a file by its absolute path.
|
||||
* @param path - absolute path; backslashes normalize to `/` and the leading `/`
|
||||
* is dropped, except that a UNC path keeps one empty first segment.
|
||||
* @returns the `dsh-resource://file/absolute/<path>` address.
|
||||
*/
|
||||
export function absoluteFileAddress(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const unc = normalized.startsWith('//')
|
||||
const absolute = normalized.replace(/^\/+/, '')
|
||||
return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file address back into its parts.
|
||||
* @param address - a candidate address.
|
||||
* @returns the parts, or `undefined` when the string is not a
|
||||
* `dsh-resource://file/` URI in a known scope with a valid path.
|
||||
*/
|
||||
export function parseFileAddress(address: string): FileAddress | undefined {
|
||||
try {
|
||||
const url = new URL(address)
|
||||
if (url.protocol !== 'dsh-resource:' || url.host !== 'file') return undefined
|
||||
const [, scope, ...rest] = url.pathname.split('/')
|
||||
if (scope === 'session') {
|
||||
const [id, ...segments] = rest
|
||||
if (id === undefined || id === '' || segments.length === 0) return undefined
|
||||
return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') }
|
||||
}
|
||||
if (scope === 'absolute') {
|
||||
// An empty first segment with more behind it is a UNC path's `//`; alone it is no path.
|
||||
const unc = rest[0] === '' && rest.length > 1
|
||||
const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent)
|
||||
if (segments.length === 0 || segments[0] === '') return undefined
|
||||
if (unc) return { scope, path: `//${segments.join('/')}` }
|
||||
return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` }
|
||||
}
|
||||
return undefined
|
||||
} catch {
|
||||
// `new URL` throws on a non-URL and `decodeURIComponent` on a malformed
|
||||
// escape; both mean "not a file address".
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a path is absolute in either spelling the host accepts. */
|
||||
function isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
/**
|
||||
* The address for a path as a caller holds it: a relative path, or an absolute
|
||||
* path inside the session's workspace, becomes a `session`-scoped address; an
|
||||
* absolute path outside it, or one whose workspace root is unknown, becomes an
|
||||
* `absolute`-scoped address. (Mirror of `fileAddressFor`.)
|
||||
* @param sessionId - the session the path is read in.
|
||||
* @param cwd - that session's workspace root, when known.
|
||||
* @param path - absolute or workspace-relative path, either separator spelling.
|
||||
* @returns the `dsh-resource://file/…` address.
|
||||
*/
|
||||
export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
if (!isAbsolutePath(normalized)) return sessionFileAddress(sessionId, normalized)
|
||||
const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
|
||||
if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
|
||||
return absoluteFileAddress(normalized)
|
||||
}
|
||||
+145
-6
@@ -23,8 +23,8 @@ import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
|
||||
leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
leafWithTab, openTabInActivePane, openTabInBottomPane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import { extOf } from './paths.ts'
|
||||
@@ -339,6 +339,57 @@ export interface OpenTabSeed {
|
||||
url?: string
|
||||
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
|
||||
meta?: unknown
|
||||
/**
|
||||
* Where the open lands. `'right'` (the default) is DSH's right Sidebar —
|
||||
* the plugin's content is registered there as native tab types; `'bottom'`
|
||||
* is the plugin's own bottom workbench. Only the plugin's own flows pass
|
||||
* `'bottom'` (the bottom panel's + menu, the auto-terminal).
|
||||
*/
|
||||
target?: 'right' | 'bottom'
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin-side seed a native right-Sidebar tab carries in its navigation
|
||||
* params (the native surface passes them back on every navigation).
|
||||
*/
|
||||
export interface NativeTabParams {
|
||||
/** Overrides the descriptor's title for this instance. */
|
||||
title?: string
|
||||
/** A file path (the editor window's content seed). */
|
||||
path?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
url?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
diff?: SidebarTab['diff']
|
||||
/** JSON-serializable custom state carried on the synthetic record. */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's write face over DSH's native right Sidebar.
|
||||
*
|
||||
* Installed by the client half ({@link ./native/surface.ts}) so the service —
|
||||
* and therefore every consumer of `ctx.betterSidebar` — keeps speaking the
|
||||
* plugin's own vocabulary while the content lands natively. Without it the
|
||||
* service writes into the plugin's own layout (the pre-0.1.5 behavior, which
|
||||
* the bottom workbench still uses).
|
||||
* @internal Not part of the consumer contract.
|
||||
*/
|
||||
export interface SidebarSurface {
|
||||
/** Open a page type in one session's native surface. */
|
||||
openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void
|
||||
/** Open a resource address in one session's native surface. */
|
||||
openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void
|
||||
/** The file address of one path (the native surface owns the grammar). */
|
||||
fileAddress(sessionId: string, cwd: string | undefined, path: string): string
|
||||
/** Close one native tab; the closed record's type/title, or undefined when the id is not native. */
|
||||
close(sessionId: string, tabId: string): { type: string; title: string } | undefined
|
||||
/** Patch a native tab's plugin-side record; false when it is not native. */
|
||||
update(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): boolean
|
||||
/** Focus a native tab; false when it is not native. */
|
||||
activate(tabId: string): boolean
|
||||
/** Whether a tab id belongs to the native surface. */
|
||||
has(tabId: string): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,6 +477,11 @@ export interface BetterSidebarService {
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
/**
|
||||
* Install (or clear) the native right-Sidebar write face.
|
||||
* @internal Called once by the client half; not part of the consumer API.
|
||||
*/
|
||||
setSurface(surface: SidebarSurface | undefined): void
|
||||
}
|
||||
|
||||
/** The file name of a path (both separators). */
|
||||
@@ -516,6 +572,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const tabs = new Map<string, TabDescriptor>()
|
||||
const viewers = new Map<string, FileViewerDescriptor>()
|
||||
const listeners = new Set<() => void>()
|
||||
/** The native right-Sidebar write face, installed by the client half. */
|
||||
let surface: SidebarSurface | undefined
|
||||
|
||||
const notify = (): void => {
|
||||
for (const fn of [...listeners]) fn()
|
||||
@@ -608,6 +666,61 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (targetSessionId === undefined) return
|
||||
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
|
||||
// ── Native right Sidebar ──────────────────────────────────────────────
|
||||
// With the native surface installed, every open except an explicit
|
||||
// bottom-panel one lands there: a file path becomes a resource address
|
||||
// (the native registry routes it to the plugin's file type), a path-less
|
||||
// editor open becomes the `files` page kind, and everything else becomes
|
||||
// a page open carrying the seed as navigation params.
|
||||
if (surface !== undefined && seed.target !== 'bottom') {
|
||||
const state = store.getSnapshot().state
|
||||
// The descriptor's own factory mints what a view needs beyond the seed:
|
||||
// the side chat's thread bootstrap / reattach meta, the terminal's
|
||||
// per-instance title. A `null` return refuses the open (terminal cap).
|
||||
const minted = descriptor.createTab === undefined || state === undefined
|
||||
? undefined
|
||||
: descriptor.createTab(state)
|
||||
if (minted === null) return
|
||||
const title = seed.title ?? minted?.tab.title
|
||||
?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title)
|
||||
// Multi-instance kinds (terminal / browser / side chat / diff) mint a
|
||||
// fresh tab per open; single-instance kinds focus the existing one.
|
||||
const revealIfOpened = descriptor.createTab === undefined
|
||||
const synthetic: SidebarTab = {
|
||||
id: seed.id ?? minted?.tab.id ?? seed.type,
|
||||
type: seed.type,
|
||||
title,
|
||||
...(seed.path === undefined ? {} : { path: seed.path }),
|
||||
...(seed.diff === undefined ? {} : { diff: seed.diff }),
|
||||
...(seed.meta === undefined && minted?.tab.meta === undefined ? {} : { meta: seed.meta ?? minted?.tab.meta }),
|
||||
}
|
||||
if (seed.path !== undefined) {
|
||||
surface.openResource({
|
||||
sessionId: targetSessionId,
|
||||
address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path),
|
||||
revealIfOpened: true,
|
||||
})
|
||||
} else if (seed.type === 'editor') {
|
||||
// The path-less editor window IS the file explorer.
|
||||
surface.openTab({ sessionId: targetSessionId, kind: 'files', params: {}, revealIfOpened: true })
|
||||
} else {
|
||||
surface.openTab({
|
||||
sessionId: targetSessionId,
|
||||
kind: seed.type,
|
||||
params: {
|
||||
title,
|
||||
...(seed.url === undefined ? {} : { url: seed.url }),
|
||||
...(seed.diff === undefined ? {} : { diff: seed.diff }),
|
||||
...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }),
|
||||
},
|
||||
revealIfOpened,
|
||||
})
|
||||
}
|
||||
// The native surface reports one open event, not create-vs-focus, so a
|
||||
// lifecycle consumer hears onOpen (documented in the guide).
|
||||
safeCall(() => descriptor.onOpen?.(synthetic, callbackScope))
|
||||
return
|
||||
}
|
||||
// Whether this open targets a session that is NOT the one on screen: a
|
||||
// targeted open must not auto-expand panels the user cannot see (the
|
||||
// expansion is about landing "in sight" for the CURRENT viewer).
|
||||
@@ -617,6 +730,10 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
||||
let created: SidebarTab | undefined
|
||||
let activated: SidebarTab | undefined
|
||||
// A bottom-targeted open lands in the bottom workbench's own pane; the
|
||||
// right tree it would otherwise follow is no longer rendered (DSH's
|
||||
// native sidebar owns the right column).
|
||||
const land = seed.target === 'bottom' ? openTabInBottomPane : openTabInActivePane
|
||||
const reducer = (state: SidebarState): SidebarState => {
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
@@ -625,7 +742,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const result = descriptor.createTab(state)
|
||||
if (result === null) return state
|
||||
tab = result.tab
|
||||
next = applyDedupe(state, result.tab, descriptor)
|
||||
next = applyDedupe(state, result.tab, descriptor, land)
|
||||
if (result.patch !== undefined) next = { ...next, ...result.patch }
|
||||
} else {
|
||||
tab = {
|
||||
@@ -638,7 +755,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
next = applyDedupe(state, tab, descriptor, land)
|
||||
}
|
||||
// Classify the landing against the INPUT state FIRST: a FOCUS fires
|
||||
// onActivate with the tab that is active NOW; a real creation fires
|
||||
@@ -735,6 +852,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string, scope?: SessionScope): void => {
|
||||
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (surface !== undefined && sessionId !== undefined) {
|
||||
const closedNative = surface.close(sessionId, tabId)
|
||||
if (closedNative !== undefined) {
|
||||
const descriptor = tabs.get(closedNative.type)
|
||||
if (descriptor !== undefined) {
|
||||
safeCall(() => descriptor.onClose?.(
|
||||
{ id: tabId, type: closedNative.type as TabType, title: closedNative.title },
|
||||
scope ?? { sessionId },
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
let closed: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op: no state churn, no notify, no
|
||||
@@ -769,6 +900,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
|
||||
/** Patch an open tab's display fields (a missing tab id is a no-op). */
|
||||
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
|
||||
if (surface?.update(tabId, patch) === true) return
|
||||
store.reduce((state) => patchTab(state, tabId, {
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
@@ -778,6 +910,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
|
||||
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
|
||||
const activateTab = (tabId: string, scope?: SessionScope): void => {
|
||||
if (surface?.activate(tabId) === true) return
|
||||
let activated: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op (no state churn / notify).
|
||||
@@ -829,6 +962,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
updateTab,
|
||||
activateTab,
|
||||
openFile,
|
||||
setSurface: (next: SidebarSurface | undefined) => { surface = next },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,7 +973,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
* reducer's job — not re-implemented here).
|
||||
* `single: true` resolves to the id-key sugar when no explicit key is given.
|
||||
*/
|
||||
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
|
||||
function applyDedupe(
|
||||
state: SidebarState,
|
||||
tab: SidebarTab,
|
||||
descriptor: TabDescriptor,
|
||||
land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInActivePane,
|
||||
): SidebarState {
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
@@ -854,7 +993,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
|
||||
const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
|
||||
if (floated !== undefined) return raiseFloat(state, floated.id)
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
return land(state, tab)
|
||||
}
|
||||
|
||||
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
|
||||
|
||||
@@ -660,6 +660,37 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Land a tab in the BOTTOM workbench's first pane (the bottom panel's own
|
||||
* opens — its + menu, the auto-terminal): the plugin's right tree is no longer
|
||||
* rendered on 0.1.5 (DSH's native sidebar is the right column), so an open
|
||||
* that must land in the bottom dock cannot follow the active pane, which may
|
||||
* point at the retired tree.
|
||||
* @param state - the session state.
|
||||
* @param tab - the tab to land.
|
||||
* @returns the next state, with the bottom panel open.
|
||||
*/
|
||||
export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
const targetId = firstLeaf(state.bottomSplits).id
|
||||
// Id-based safety net: if a tab with the same id exists, focus it — in a
|
||||
// pane (activate) or in a free window (raise, no panel switch).
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
const floated = floatWithTab(state, tab.id)
|
||||
if (floated !== undefined) return raiseFloat(state, floated.id)
|
||||
return {
|
||||
...state,
|
||||
bottomOpen: true,
|
||||
activePane: targetId,
|
||||
bottomSplits: mapLeaf(state.bottomSplits, targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a tab from one pane to another (insert at index; -1 appends).
|
||||
* The panes may live in DIFFERENT trees — dragging a tab between the two
|
||||
* panels removes it from its own tree and lands it in the other one. */
|
||||
|
||||
Reference in New Issue
Block a user