diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index a43efa0..6537280 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 条 | @@ -619,7 +620,15 @@ 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 把一切 + * path seed 都改道文件资源打开,组件型 tab 的组件不会挂载,#632)。 * url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子); * 聚焦既有 tab 时 url 不会覆写其 path。 * 被设置禁用的类型是 no-op(console.warn 提示)。注意:available 不拦截 openTab。 @@ -666,6 +675,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)