From d6a816a374fa980fc33606c8d7db69d655783ac4 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Fri, 11 Sep 2026 21:12:15 +0800 Subject: [PATCH 1/2] fix(native): keep component tabs' path seeds on the page open (#632) The native openTab branch rerouted EVERY path seed into openResource (a dsh-resource://file/... open) without looking at seed.type, so any component tab opened with a path landed in the editor (the only dsh-resource://file/** claimant) and its registered component never mounted. This also contradicted the guide, whose TabDescriptor/openTab contract and WebDocsView example document path-seed component tabs. Route by type instead: only the editor kind (the only registration with dsh-resource://file/** patterns) turns its path seeds into resource opens; every other kind keeps the page open and carries the path in the navigation params, which NativeTabRecords.ensure already merges back onto the synthetic record's tab.path for the component. The bottom workbench path is untouched, editor opens behave exactly as before, and onOpen still receives the synthetic tab with the path seed. --- docs/external-plugin-guide.md | 6 +++++ src/client/service.ts | 47 ++++++++++++++++++++++----------- tests/native-surface.spec.ts | 49 ++++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index a43efa0..6176b75 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -25,6 +25,7 @@ | 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 | | 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 | | 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 | +| path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) | | 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 | | 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 | | 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**注意默认组合看不到说明**:插件贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),已超过 4 条上限——要让说明出现,需在插件设置页关掉足够多的 tab 类型把 guide 压到 ≤ 4 条 | @@ -620,6 +621,10 @@ interface BetterSidebarService { * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 + * path 可选:含义跟随类型——editor(唯一认领 dsh-resource://file/** 的 + * 类型)把 path 转成资源地址打开(文件落在编辑器);其余类型 path 是 + * 组件种子,随导航 params 落到 tab.path(v0.19.2+;0.19.0/0.19.1 把一切 + * path seed 都改道文件资源打开,组件型 tab 的组件不会挂载,#632)。 * url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子); * 聚焦既有 tab 时 url 不会覆写其 path。 * 被设置禁用的类型是 no-op(console.warn 提示)。注意:available 不拦截 openTab。 @@ -666,6 +671,7 @@ interface BetterSidebarService { interface OpenTabSeed { type: string title?: string + /** 文件路径:editor = 打开文件资源;其余类型 = 组件种子(落在 tab.path,v0.19.2+) */ path?: string diff?: SidebarTab['diff'] id?: string diff --git a/src/client/service.ts b/src/client/service.ts index e37402d..84dd909 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -396,7 +396,13 @@ export interface OpenTabSeed { type: string /** Overrides the descriptor's title when given (the editor tab shows the file name). */ title?: string - /** A file path (the editor tab's content seed). */ + /** + * A file path. Meaning follows the type: the `editor` kind (the only one + * claiming `dsh-resource://file/**`) opens its path seeds as file + * resources; every other kind treats the path as component state — it + * rides the navigation params onto the tab record's `path` (v0.19.2+; on + * v0.19.0/v0.19.1 every path seed was rerouted into a file open). + */ path?: string /** A diff reference (the diff tab's content seed). */ diff?: SidebarTab['diff'] @@ -422,7 +428,7 @@ export interface OpenTabSeed { export interface NativeTabParams { /** Overrides the descriptor's title for this instance. */ title?: string - /** A file path (the editor window's content seed). */ + /** A file path (the editor window's content seed; component kinds carry their own). */ path?: string /** A URL the tab navigates to on mount (the browser tab's seed). */ url?: string @@ -885,10 +891,15 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe 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. + // bottom-panel one lands there. The path seed's meaning depends on the + // type: `editor` is the only kind registered with + // `dsh-resource://file/**` patterns (src/client/native/index.ts), so its + // path seeds become resource addresses (the native registry routes the + // address back to the editor); a path-less editor open becomes the + // `files` page kind. Every OTHER type keeps the page open — its path is + // component state, not a file to open — and rides the seed (path + // included) as navigation params, which the tab adapter merges onto the + // synthetic record's `tab.path` for the registered component. if (surface !== undefined && seed.target !== 'bottom') { const state = store.getSnapshot().state // The descriptor's own factory mints what a view needs beyond the seed: @@ -911,21 +922,27 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe ...(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 }) + if (seed.type === 'editor') { + if (seed.path !== undefined) { + surface.openResource({ + sessionId: targetSessionId, + address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path), + revealIfOpened: true, + }) + } else { + // The path-less editor window IS the file explorer. + surface.openTab({ sessionId: targetSessionId, kind: 'files', params: {}, revealIfOpened: true }) + } } else { + // A component type's path seed stays on the page open (regression + // #632: rerouting every path seed into openResource sent the open to + // the editor, so the registered component never mounted). surface.openTab({ sessionId: targetSessionId, kind: seed.type, params: { title, + ...(seed.path === undefined ? {} : { path: seed.path }), ...(seed.url === undefined ? {} : { url: seed.url }), ...(seed.diff === undefined ? {} : { diff: seed.diff }), ...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }), diff --git a/tests/native-surface.spec.ts b/tests/native-surface.spec.ts index 468cb51..a82f874 100644 --- a/tests/native-surface.spec.ts +++ b/tests/native-surface.spec.ts @@ -11,7 +11,7 @@ import { act } from 'react-dom/test-utils' import { createNativeTabRecords, NativeTabBody, NativeTabTitle } from '../src/client/native/tab-adapter.tsx' import { registerNativeSurface } from '../src/client/native/index.ts' import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts' -import { createSidebarStore } from '../src/client/state.ts' +import { createSidebarStore, type SidebarTab } from '../src/client/state.ts' const scope = { sessionId: 's1', cwd: '/work' } @@ -119,6 +119,53 @@ describe('service routing into the native surface', () => { expect(calls).toEqual([{ op: 'openTab', sessionId: 's1', kind: 'files', params: {}, revealIfOpened: true }]) }) + it('keeps a component type path seed on the page open (no resource reroute)', () => { + // Regression #632: a path seed on a component type was rerouted into + // openResource, so the editor (the dsh-resource://file/** claimant) + // received the open and the registered component never mounted. + const { service, calls } = mount() + service.registerTab({ id: 'my-plugin:doc', title: 'Doc', component: () => null }) + service.openTab({ type: 'my-plugin:doc', path: '/work/spec.md', title: 'Spec' }, scope) + expect(calls).toEqual([{ + op: 'openTab', + sessionId: 's1', + kind: 'my-plugin:doc', + params: { title: 'Spec', path: '/work/spec.md' }, + revealIfOpened: true, + }]) + }) + + it('carries the path seed and meta on a multi-instance component open', () => { + const { service, calls } = mount() + service.registerTab({ + id: 'my-plugin:console', + title: 'Console', + createTab: (state) => ({ + tab: { id: `console:${state.nextTerminal}`, type: 'my-plugin:console', title: 'Console' }, + patch: { nextTerminal: state.nextTerminal + 1 }, + }), + component: () => null, + }) + service.openTab({ type: 'my-plugin:console', path: '/work/x.md', meta: { k: 1 } }, scope) + expect(calls).toEqual([{ + op: 'openTab', + sessionId: 's1', + kind: 'my-plugin:console', + params: { title: 'Console', path: '/work/x.md', meta: { k: 1 } }, + // Multi-instance kinds mint a fresh tab per open: no forced reveal. + revealIfOpened: false, + }]) + }) + + it('reports a component path seed to onOpen on the synthetic tab', () => { + const { service } = mount() + const seen: Array = [] + service.registerTab({ id: 'my-plugin:doc', title: 'Doc', onOpen: (tab) => { seen.push(tab) }, component: () => null }) + service.openTab({ type: 'my-plugin:doc', path: '/work/spec.md' }, scope) + expect(seen).toHaveLength(1) + expect(seen[0]).toMatchObject({ type: 'my-plugin:doc', path: '/work/spec.md' }) + }) + it('keeps a bottom-targeted open in the plugin layout', () => { const { service, calls } = mount() service.openTab({ type: 'terminal', target: 'bottom' }, scope) From 5e1594244912fe3ffb3d759d7f7d1d69d0cc3727 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Fri, 11 Sep 2026 21:19:00 +0800 Subject: [PATCH 2/2] docs: scope the createTab seed-ignore rule to the bottom workbench Copilot review: the openTab JSDoc claimed createTab descriptors ignore title/path/id, but that is only the bottom-workbench landing (the factory mints the tab wholesale there). On the native right Sidebar the seed fields are forwarded as navigation params (createTab values as defaults, seed wins) and only the id is host-minted. State both landings so consumers do not drop path seeds. --- docs/external-plugin-guide.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 6176b75..6537280 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -620,7 +620,11 @@ interface BetterSidebarService { /** * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); - * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 + * 有 createTab 的 descriptor 分落点:底部工作台(target: 'bottom')由 createTab + * 整体铸造 tab,忽略 seed 的 title/path/id(url 种子仍预填新建 tab 的 path); + * 原生右侧栏只忽略 id(原生 tab id 由宿主铸造,seed.id 仅影响 onOpen 收到的 + * 合成 tab),createTab 铸造的 title/meta 作缺省、seed 字段优先(v0.19.2+ 起 + * path 也随导航 params 下发,见下)。 * path 可选:含义跟随类型——editor(唯一认领 dsh-resource://file/** 的 * 类型)把 path 转成资源地址打开(文件落在编辑器);其余类型 path 是 * 组件种子,随导航 params 落到 tab.path(v0.19.2+;0.19.0/0.19.1 把一切