DSH / Atlas
2026-09-05implementedarchitecturearchived 2026-09-30

Right Sidebar tab types and navigation

右侧 Sidebar 的 tab 类型与导航

The [docking surface](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) gives the right Sidebar panes, tabs, and floating panels, but a pane full of tabs is only useful if other plugins can put content into them. That needs three contracts the surface itself does not define: how a plugin declares a kind of tab and the addresses it can show, how any caller — a produced-file chip in the conversation, a row

English

Problem

The docking surface gives the right Sidebar panes, tabs, and floating panels, but a pane full of tabs is only useful if other plugins can put content into them. That needs three contracts the surface itself does not define: how a plugin declares a kind of tab and the addresses it can show, how any caller — a produced-file chip in the conversation, a row in a file tree, a plugin's own button — asks the Sidebar to show something, and what a tab's body may rely on at runtime. Each contract is a public face that plugins shipped from outside this repository will write against, so each has to be settled before those plugins exist: a renamed field, a changed enum value, or a different address grammar afterwards breaks every one of them.

Two constraints shaped the answers. Dynamic client plugins may not import runtime values from one another — a function, a constant, a class — only types, so nothing in these contracts may require a helper function or an exported constant from the Sidebar package. And the Web client already has one component model, the Slot system; a second one for tabs would be a parallel framework to learn and maintain.

Decision

A tab type is a static registration into ctx.sidebarRightTabs; a tab's body and title are ordinary keyed Slot registrations; ctx.sidebarRight opens content in exactly two ways — a resource by address, or a page by kind — and otherwise only operates the layout; and bodies read occurrence information through the framework-injected useTabInfo(). The four faces are described below in the order a plugin author meets them.

The type registry: ctx.sidebarRightTabs

register(definition): () => void records one tab type and returns the disposer the caller holds in its own ctx.effect, so a type lives exactly as long as the plugin that contributed it. The definition is static:

interface SidebarRightTabDefinition {
  readonly id: string                                   // this implementation's identity in the tab system
  readonly kind: string                                 // what the tabs of this type are; what openTab names
  readonly patterns?: readonly string[]                 // resource-address globs; omitted by a page type
  readonly priority?: 'extension' | 'builtin' | 'fallback'   // defaults to extension
  readonly canOpen?: (address: string) => boolean       // veto after a glob matched
  readonly title: (address: string) => string           // chip text, captured at open time
  readonly guide?: readonly SidebarRightGuideEntry[]    // entry boxes on the guide page
}

id and kind are different things. kind is the type discriminator — what a tab is, what openTab names, what tab identity is built from. id is the identity of one implementation of a kind, unique across every registration; a package name is the natural value. The two are separate because a kind is not unique: an extension may register the kind a builtin already holds, and the two implementations then coexist in the registry with the extension in force. The registry rejects a second registration of an id, a second registration in the same band of a kind, and any registration meeting a fallback of the same kind; it accepts exactly the extension-over-builtin pair, and the builtin resumes when the extension unregisters.

patterns are globs over resource addresses, matched with picomatch under VS Code's editor-resolver rule with one local change: a pattern containing : is matched against the whole address (dsh-resource://file/**), one without is matched against the URI's path at any depth (*.md), matching ignores case and does not hide dotfiles, and an address that is not a URI matches no path pattern. A page type — the guide, the file tree — recognizes no address and omits patterns; it is opened by kind.

priority is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: extension is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; builtin is the ordinary band for shipped types; fallback is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. candidates(address) returns every type whose globs match and whose canOpen does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. claim(address, kind?) takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. get(kind) returns the type in force; entries() and guide() list the types and their guide boxes in force; subscribe observes changes.

title(address), guide[].title(), and optional guide[].description() are thunks read on every use, so a language change needs no re-registration. The Sidebar README defines description visibility and fallback-glyph rules. The registry itself is a plain object provided at apply's top level without Service.tracker: a tracker would rebind this.ctx to the caller's context, and a cross-package register() would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.

Bodies and titles: keyed Slot seats under the definition's id

The type registry says what a type is; the Slot system says what it looks like. A type registers its body into the keyed, session-scoped seat sidebar.right.pane.tab under its own id, and may register a title component into sidebar.right.pane.tab.title under the same key. The seat that draws a tab resolves the tab's kind to the type in force through the registry and dispatches to that type's id, so an extension taking over a builtin's kind is rendered without either package knowing about the other, and without any priority number crossing a package boundary. A kind with no type in force renders the owner's "nothing can view this" notice; a type with no title registration gets the title(address) text the registry captured when the tab opened.

Two further seats extend the guide and the menu: sidebar.right.tab.guide is a chain whose first non-declining entry replaces the shipped guide body without replacing the tab, and sidebar.right.tab.menu.item is a list appended after the kit's own layout actions, for actions that mean something about a tab's content. A type's controls — a reload, a wrap toggle — live inside its own body; the strip belongs to the panel and carries only the panel's controls. A type's own state is an ordinary Slot store and inject face on the body registration; the framework adds nothing to the component model.

Tab occurrence information

Responsive Sidebar and tab information supersedes this note's choice of flat owner props for occurrence information. Bodies, titles and guide replacements receive the framework-injected useTabInfo() to read { sidebar, panel, tab }. The record, navigation, visibility, signal and bound actions live inside tab; exact fields belong to the Sidebar reference.

The Tab domain still owns one occurrence per committed record, with an AbortController, navigation snapshot and actions bound to its Session. It pins the address in the resource model for the record's lifetime; hiding and switching Sessions do not end it, while closing the record aborts and releases it. Existing framework store and navigation hooks provide live reads, without subscriptions in tab implementations.

Navigation: ctx.sidebarRight

The face opens content in two ways and does nothing else with content:

openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
openTab<K extends string>(kind: K, options?: { params?: SidebarRightTabParamsFor<K>; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void

openResource takes a resource address — a dsh-resource://<type>/… URI, the only scheme the resource model has — and asks the registry who shows it: without kind, every type is consulted and the ranking decides; with kind, that type's implementation in force opens it. An address with any other scheme fails on the same path as an address nothing claims. openTab opens a page type by kind and never sees an address: the Sidebar records the tab under sidebar://<kind>, composed in one place inside the package, so that a page tab has a contentId for identity and history like any other tab. The scheme is bookkeeping: no caller composes it, no business package contains the literal, and the file tree and the guide are opened as openTab('files') and openTab('guide').

Both opens run the same four steps: resolve the type (by ranking or by kind), locate an existing tab by (kind, contentId) unless revealIfOpened is false, place the tab — in replaceTab's pane and strip slot, in paneId, or in the active pane — and record the expansion, the open-or-focus, and the replaceTab close as one history entry before handing { address, params } to the tab domain. Placement is the caller's business, never a type-level trait: the file tree opens into its own pane because it says so, as VS Code's Explorer passes SIDE_GROUP or ACTIVE_GROUP itself. replaceTab means one thing — open in that tab's place and close it in the same step — and exists for the guide's entry boxes, which hand their tab over to the page they name.

Parameters are typed by what is being opened, through two merge-extensible maps declared in the Sidebar package and augmented by the owners of the keys:

interface SidebarRightResourceParamsMap {}   // key: resource type — the text preview declares { line?: number }
interface SidebarRightTabParamsMap {}        // key: kind — a page type declares its own shape, or nothing

openResource accepts the union of every declared resource shape and openTab<K> the shape declared for K; a body narrows navigation.params by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims file addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone.

Beside the two opens, the face carries close(tabId), active(), isExpanded(), toggleExpanded(), and four operational methods — focus(tabId), split(paneId?) (returning the new pane, or undefined when the pane budget or the width rule forbids the split, recording nothing), float(tabId, rect?), and dock(paneId) — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as mounted; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.

Addresses

Addresses come in two families that never mix. Resource addresses are the resource model's dsh-resource://<type>/… URIs (a workspace file is dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>, an arbitrary file dsh-resource://file/absolute/<absolute path>, both built and parsed by dsh-util-workspace-path); they are what openResource takes, what patterns match, and what useResource reads. Navigation addresses name pages rather than data; today the only one is the internal sidebar://<kind> a page tab is recorded under. Only the resource family is a contract: the navigation family is composed and consumed inside the Sidebar, and a fuller navigation protocol is a later decision that this one leaves room for by keeping every navigation literal in one place.

Entry points

The conversation's openFile(path, { line? }) — tool-row path links, produced-file chips, closing-message mentions — encodes the path as a file resource address for the Session, and calls openResource with params.line when the caller knows one; the read tool row passes the line its offset argument started from. The strip's + calls openTab('guide', { paneId, revealIfOpened: false }) for the pane it sits in; a guide entry box calls tab.actions.openTab(entry.kind, { replaceTab: true }); a file-tree row calls tab.actions.openResource(address), which lands in the tree's own pane.

Alternatives considered

A chain slot for tab dispatch, or a keyed slot alone. A chain's select is not enumerable, and the guide page and the navigation face must enumerate types; a keyed slot carries a body and nothing else, so a type's title and address recognition had nowhere to live. Two stages — a definition registry plus keyed component seats — is the repository's existing pattern (ConversationViewRegistry).

Runtime hooks or an instance object per tab. Several forms were tried on paper — a Cordis fiber per tab, an abstract base class, an initial/create pair returning an instance with dispose, a set of useTab* hooks, a framework-managed useTabResource(fetch), a useTabStream. Rejected in turn: a fiber per tab is far too heavy; dynamic packages cannot share a base class or an exported constant; an instance layer duplicates what a Slot store and inject face already are; per-tab hooks restate owner props; a framework-owned fetch has no good cache key; and a stream hook on the tab domain asks the wrong owner — chat data must come from the chat domain, file data from the workspace file service. What remains is owner props plus one client-wide useResource. visible was later added as a prop rather than a hook for the same reason: it is one more fact about the occurrence, and the props already carry the occurrence. The rejection of occurrence-reading hooks is superseded by the tab information decision; the independent instance, fiber and data-stream ownership rationale still applies.

A per-pane tools seat for the active tab's controls (sidebar.right.pane.tab.tools). Shipped for one review round, then removed: it put type-private buttons on the panel's strip beside the split and collapse controls, where they read as panel chrome. A type's controls belong in its own body.

Type-level placement (opensInto) and a hidden sibling heuristic. Rejected: where a tab lands is the opener's business, exactly as VS Code's Explorer decides sideBySide itself.

Extension lists and numeric priorities. claims.extensions cannot express .d.ts, Dockerfile, a directory constraint, or a whole scheme — it is a degenerate glob; numeric priorities need an exported constant that dynamic packages cannot import. Literal bands over globs. VS Code's own bands were reduced from five to three: an option band (listed, never chosen automatically) has no consumer until an "open with…" affordance exists, and a default band was renamed extension because the name read as the lowest tier while it is the highest.

One open(address) for everything, with a helper that builds page addresses. The first design opened pages by address too, so a business package needed a sidebar://<kind> literal or a sidebarAddress(kind) helper from the Sidebar package. Both are forbidden by the value-import rule and both leak a navigation scheme that is not yet designed. Splitting the face into openResource and openTab puts the only literal inside the package and lets each mode type its parameters.

Naming a specific implementation when opening (?impl=), find(address), mode()/setMode(), a layout snapshot, a features list. All considered and left out. Naming an implementation belongs to a navigation protocol that does not exist yet; find and a snapshot would make the face a view of the layout when it is meant to be control over it; presentation mode is a UI toggle, not a plugin concern; a capability list is premature while the face is settling.

Slot priorities to express an extension taking over a builtin, then registry-minted slot keys. The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from register(), which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own id — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates.

A binding the seat publishes from its own effects. The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can.

Consequences

  • A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected useTabInfo(). The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package.
  • Two opens with two parameter maps mean a caller cannot open a page by address or a resource by kind alone, and the compiler tells it so; the cost is that every new resource type or page kind that wants typed parameters augments a map.
  • id and kind being distinct lets an extension replace a shipped type in place, per kind, with the builtin resuming when the extension unregisters; the cost is one more required field on every definition.
  • The navigation face is control-only. A plugin that needs to know the layout cannot ask for it, which keeps the layout's shape out of every plugin's contract until a navigation protocol decides what to expose.
  • The sidebar://<kind> literal lives in one file. Changing the navigation grammar later touches the Sidebar package and nothing else.
  • These faces are the part of the Sidebar that is fixed: addresses, registration fields and bands, the two opens and their parameter maps, seat names and injected tab information. Everything a user sees as behaviour — where a float snaps, when a split control greys out, the copy, the tree's ordering — is a product rule outside every contract here and changes without notice to any plugin.

Testing

ui-sidebar-right specs cover the registry (bands, extension-over-builtin with resumption, id and same-band collisions, glob and path matching, canOpen, ranking and tiebreaks), both opens (normal, edge, and failure paths including the wrong scheme and an unregistered kind), replaceTab as one history entry, the seat resolving a kind to the implementation in force and back, useTabInfo() including tab.visible under collapse and floating, and the operational methods with their no-op and throw cases. The Web e2e suite drives the guide, the file tree, and a file open through the real plugin graph in Chromium. Both suites are keyless.

Deferred

  • A navigation protocol beyond sidebar://<kind>: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
  • Opening into a session other than the one on screen from the public face, which acts on the on-screen Session only; a tab's own actions already act on their tab's session.
  • A localized message when an open fails from the conversation; the failure is currently the thrown error's text.

中文

Problem

停靠面给了右侧 Sidebar 分栏、tab 与浮动面板,但一格 tab 只有在别的插件能往里放内容时才有用。这需要三份停靠面自身不定义的契约:插件如何声明一种 tab 及其能展示的地址;任何调用方——会话区里的产出文件 chip、文件树里的一行、插件自己的按钮——如何请 Sidebar 展示某样东西;以及 tab 的正文在运行时能依赖什么。每一份都是仓外插件将来要对着写的公开面,所以必须在那些插件出现之前定下来:之后改一个字段名、一个枚举值或地址语法,就会同时弄坏它们全部。

两条约束决定了答案。动态客户端插件之间不允许引用运行时值——函数、常量、类——只能引类型,因此这些契约里不得要求从 Sidebar 包引入帮助函数或导出常量。而 Web 客户端已经有一套组件模型,即 Slot 系统;再为 tab 造一套,就是第二个要学要维护的并行框架。

Decision

tab 类型是向 ctx.sidebarRightTabs 的一次静态注册;tab 的正文与标题是普通的 keyed Slot 注册;ctx.sidebarRight 只以两种方式打开内容——按地址开资源、按 kind 开页——其余只操作布局;正文通过框架注入的 useTabInfo() 读取实例信息。下面按插件作者遇到的顺序描述这四个面。

类型注册表:ctx.sidebarRightTabs

register(definition): () => void 记录一种 tab 类型并返回注销器,调用方把它放进自己的 ctx.effect,于是类型的寿命恰好等于贡献它的插件。定义是静态的:

interface SidebarRightTabDefinition {
  readonly id: string                                   // this implementation's identity in the tab system
  readonly kind: string                                 // what the tabs of this type are; what openTab names
  readonly patterns?: readonly string[]                 // resource-address globs; omitted by a page type
  readonly priority?: 'extension' | 'builtin' | 'fallback'   // defaults to extension
  readonly canOpen?: (address: string) => boolean       // veto after a glob matched
  readonly title: (address: string) => string           // chip text, captured at open time
  readonly guide?: readonly SidebarRightGuideEntry[]    // entry boxes on the guide page
}

id 与 kind 是两回事。kind 是类型判别符——tab 是什么、openTab 点名什么、tab 身份由什么构成。id 是某个 kind 的一个实现的身份,在全部注册里唯一,包名是自然的取值。两者分开是因为 kind 并不唯一:extension 可以注册一个 builtin 已持有的 kind,两个实现随即在注册表里共存,生效的是 extension。注册表拒绝重复的 id、同一 kind 在同一档的第二次注册、以及任何与同 kind 的 fallback 相遇的注册;它只接受 extension 压 builtin 这一对,extension 注销后 builtin 恢复。

patterns 是资源地址上的 glob,用 picomatch 按 VS Code 编辑器解析器的规则匹配,只有一处本地改动:含 : 的 pattern 匹配整个地址(dsh-resource://file/**),不含的匹配 URI 的路径且任意深度(*.md),匹配不区分大小写、不隐藏 dotfile,不是 URI 的地址不匹配任何路径 pattern。页类型——引导页、文件树——不识别任何地址,省略 patterns,按 kind 打开。

priority 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:extension 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;builtin 是随包类型的常规档;fallback 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。candidates(address) 返回 glob 命中且 canOpen 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。claim(address, kind?) 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。get(kind) 返回生效类型;entries() 与 guide() 列出生效类型及其引导入口;subscribe 观察变化。

title(address)、guide[].title() 与可选的 guide[].description() 都是每次使用时重读的 thunk,语言切换无需重新注册。Sidebar README定义描述显示与兜底图标规则。注册表本身是 apply 顶层提供的普通对象,不带 Service.tracker:tracker 会把 this.ctx 重绑到调用方上下文,跨包 register() 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。

正文与标题:按定义 id keyed 的 Slot 坑位

类型注册表说类型是什么;Slot 系统说它长什么样。类型把正文注册进 keyed、session 作用域的坑位 sidebar.right.pane.tab,键是自己的 id,并可把标题组件注册进 sidebar.right.pane.tab.title,键相同。画 tab 的座位经注册表把 tab 的 kind 解析成生效类型,再派发到该类型的 id,于是 extension 接管 builtin 的 kind 时两个包互不知晓也能正确渲染,且没有任何优先级数字跨过包边界。没有生效类型的 kind 渲染属主的「没有东西能查看它」提示;没注册标题的类型得到注册表在打开时捕获的 title(address) 文本。

另有两个坑位扩展引导与菜单:sidebar.right.tab.guide 是 chain,第一个不拒绝的条目在不替换 tab 的前提下替换随包引导正文;sidebar.right.tab.menu.item 是 list,追加在库自身布局动作之后,放与 tab 内容有关的动作。类型自己的控件——重载、换行开关——住在自己正文里;tab 条属于面板,只放面板的控件。类型自己的状态是正文注册上普通的 Slot store 与 inject 面;框架不给组件模型添任何东西。

标签实例信息

响应式 Sidebar 与标签信息取代本记录中以平铺 owner props 传递实例信息的选择。正文、标题与引导页替换项接收框架注入的 useTabInfo(),以 { sidebar, panel, tab } 读取所属 Sidebar、窗格与标签。实例的记录、导航、可见性、signal 与绑定动作均在 tab 内;精确字段见 Sidebar 参考。

标签域仍为每个已提交记录拥有一个实例,包括 AbortController、导航快照和绑定到所属 Session 的动作。记录存活期间,其地址被钉在资源模型中;隐藏与切换 Session 不结束实例,关闭记录则中止并释放它。框架已有的存储与导航钩子提供实时读取,类型不自行订阅。

导航:ctx.sidebarRight

该面以两种方式打开内容,对内容不做别的事:

openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
openTab<K extends string>(kind: K, options?: { params?: SidebarRightTabParamsFor<K>; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void

openResource 接一个资源地址——dsh-resource://<type>/… URI,资源模型仅有的 scheme——并问注册表谁来展示:不带 kind 时问遍所有类型由排序决定;带 kind 时由该类型生效的实现打开。其它 scheme 的地址与无人认领的地址走同一条失败路径。openTab 按 kind 打开页类型,永远见不到地址:Sidebar 把该 tab 记账在 sidebar://<kind> 下,这个字面量只在包内一处拼装,为的是页 tab 与其它 tab 一样有 contentId 供身份与历史使用。这个 scheme 只是记账:没有调用方拼它,业务包里没有这个字面量,文件树与引导页分别以 openTab('files')、openTab('guide') 打开。

两种打开走同样四步:解析类型(按排序或按 kind);除非 revealIfOpened 为 false,否则按 (kind, contentId) 定位已有 tab;落位——落在 replaceTab 的格与条位、paneId、或活跃格;把展开、打开或聚焦、以及 replaceTab 的关闭记为一条历史,再把 { address, params } 交给 tab 域。落位是调用方的事,从不是类型级特性:文件树把文件开进自己的格是因为它自己说了,正如 VS Code 的 Explorer 自己传 SIDE_GROUP 或 ACTIVE_GROUP。replaceTab 只有一个含义——在那个 tab 的位置打开并在同一步关掉它——为的是引导页入口框把自己的 tab 交给所点的页。

参数按被打开的东西定型,经 Sidebar 包声明、由键的拥有者增补的两张可声明合并表:

interface SidebarRightResourceParamsMap {}   // key: resource type — the text preview declares { line?: number }
interface SidebarRightTabParamsMap {}        // key: kind — a page type declares its own shape, or nothing

openResource 接受所有已声明资源形状的联合,openTab<K> 接受为 K 声明的形状;正文按自己所服务的协议或 kind 收窄 navigation.params。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 file 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。

除两种打开外,该面还有 close(tabId)、active()、isExpanded()、toggleExpanded(),以及四个操作型方法——focus(tabId)、split(paneId?)(返回新格,预算或宽度规则不允许分栏时返回 undefined 且不记账)、float(tabId, rect?) 与 dock(paneId)——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 mounted;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。

地址

地址分两族,永不混用。资源地址是资源模型的 dsh-resource://<type>/… URI(工作区文件是 dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>,任意文件是 dsh-resource://file/absolute/<绝对路径>,都由 dsh-util-workspace-path 构造与解析);它们是 openResource 的入参、patterns 的匹配对象、useResource 的读取对象。导航地址命名的是页而非数据;今天唯一的一种是页 tab 记账用的内部 sidebar://<kind>。只有资源族是契约:导航族在 Sidebar 内部拼装与消费,更完整的导航协议是之后的决定,本决定通过把所有导航字面量留在一处为它预留空间。

入口

会话区的 openFile(path, { line? })——工具行路径链接、产出文件 chip、收尾消息提及——把路径编码为该 Session 的文件资源地址并调用 openResource,调用方知道行号时带 params.line;read 工具行传入其 offset 参数起始的行。tab 条的「+」为所在格调用 openTab('guide', { paneId, revealIfOpened: false });引导入口框调用 tab.actions.openTab(entry.kind, { replaceTab: true });文件树的一行调用 tab.actions.openResource(address),落在树自己的格里。

Alternatives considered

用 chain 坑位派发 tab,或只用 keyed 坑位。 chain 的 select 不可枚举,而引导页与导航面必须枚举类型;keyed 坑位只带正文,类型的标题与地址识别无处可住。两段——定义注册表加 keyed 组件坑位——是仓库既有模式(ConversationViewRegistry)。

运行时 hook 或每 tab 一个实例对象。 纸面上试过多种形态——每 tab 一个 Cordis fiber、抽象基类、返回带 dispose 实例的 initial/create 对、一组 useTab* hook、框架托管的 useTabResource(fetch)、useTabStream。依次否决:每 tab 一个 fiber 太重;动态包无法共享基类或导出常量;实例层重复了 Slot store 与 inject 面已经是的东西;每 tab hook 复述 owner props;框架托管的 fetch 没有好的缓存键;tab 域上的流 hook 问错了主人——聊天数据必须来自聊天域,文件数据来自工作区文件服务。剩下的是 owner props 加一个全客户端的 useResource。visible 后来以 prop 而非 hook 加入也是同一理由:它是关于该次出现的又一个事实,而 props 已经承载了该次出现。 对实例读取钩子的否决由标签信息决策取代;对独立实例对象、fiber 与数据流所有权的理由仍适用。

每格一个工具区坑位放活跃 tab 的控件(sidebar.right.pane.tab.tools)。 上线一轮评审后删除:它把类型私有按钮放到面板 tab 条上、与分栏和收起控件并列,读起来像面板 chrome。类型的控件属于自己的正文。

类型级落位(opensInto)与隐藏的相邻格启发式。 否决:tab 落在哪是开启方的事,正如 VS Code 的 Explorer 自己决定 sideBySide。

扩展名列表与数字优先级。 claims.extensions 表达不了 .d.ts、Dockerfile、目录约束或整个 scheme——它是退化的 glob;数字优先级需要动态包无法引入的导出常量。字面量档位加 glob。VS Code 自己的档位从五个收成三个:option 档(只列出、永不自动选中)在「用其他方式打开」存在之前没有消费者,default 档改名 extension,因为那个名字读起来像最低档而它是最高档。

一个 open(address) 包打天下,外加拼页地址的帮助函数。 第一版页也按地址打开,于是业务包需要 sidebar://<kind> 字面量或来自 Sidebar 包的 sidebarAddress(kind) 帮助函数。两者都被值引用规则禁止,也都泄露了尚未设计的导航 scheme。把面拆成 openResource 与 openTab,唯一的字面量留在包内,且每种模式各自定型参数。

打开时点名某个实现(?impl=)、find(address)、mode()/setMode()、布局快照、features 清单。 都考虑过并留在外面。点名实现属于尚不存在的导航协议;find 与快照会把该面变成布局的视图,而它本该是对布局的控制;呈现模式是 UI 开关不是插件关心的事;能力清单在该面尚在收敛时为时过早。

用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 register() 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 id——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。

由座位在自己的 effect 里发布绑定。 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。

Consequences

  • 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 useTabInfo() 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。
  • 两种打开配两张参数表,意味着调用方无法只按地址开页或只按 kind 开资源,编译器会说明;代价是每个想要类型化参数的新资源类型或页 kind 都要增补一张表。
  • id 与 kind 分离让 extension 能按 kind 原位替换随包类型,extension 注销后 builtin 恢复;代价是每个定义多一个必填字段。
  • 导航面只有控制权。需要知道布局的插件无法索取,这让布局的形状在导航协议决定暴露什么之前不进任何插件的契约。
  • sidebar://<kind> 字面量住在一个文件里。之后改导航语法只碰 Sidebar 包。
  • 这些面是 Sidebar 里被定死的部分:地址、注册字段与档位、两种打开及其参数表、slot 名与注入的标签信息。用户看到的一切行为——浮窗贴到哪、分栏控件何时置灰、文案、树的排序——都是这里任何契约之外的产品规则,改动无需通知任何插件。

Testing

ui-sidebar-right 的 spec 覆盖注册表(档位、extension 压 builtin 及恢复、id 与同档冲突、glob 与路径匹配、canOpen、排序与平局)、两种打开(正常、边界与失败路径,含错误 scheme 与未注册 kind)、replaceTab 记一条历史、座位把 kind 解析到生效实现并回退、useTabInfo() 含折叠与浮窗下的 tab.visible、以及操作型方法的 no-op 与抛错情形。Web e2e 套件在 Chromium 里经真实插件图驱动引导页、文件树与一次文件打开。两套均无需密钥。

Deferred

  • sidebar://<kind> 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
  • 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。
  • 从会话区打开失败时的本地化提示;目前是抛错文本本身。