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:
Menghuan1918
2026-09-09 11:47:31 +08:00
parent ba0114749f
commit 51546f7433
40 changed files with 1560 additions and 1568 deletions
+37 -26
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -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: 'قراءة',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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: 'पठन',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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: '閲覧',
+1
View File
@@ -9,6 +9,7 @@
export const ko: Record<string, string> = {
files: '파일',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
+1
View File
@@ -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',
+1
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -13,6 +13,7 @@
export const ru: Record<string, string> = {
files: 'Файлы',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
+1
View File
@@ -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',
+1
View File
@@ -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: 'อ่าน',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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: '閱讀',
+1
View File
@@ -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: '閱讀',
+1
View File
@@ -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: '閱讀',
+2
View File
@@ -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',
+229
View File
@@ -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 }
+149
View File
@@ -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() },
}
}
+338
View File
@@ -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>
}
+42
View File
@@ -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}`)
}
}
+138
View File
@@ -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
View File
@@ -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. */
+31
View File
@@ -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. */