diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb82ab..80a37b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-09-02 + +### Added + +- Workset list, detail, and creation flows for saved multi-folder workspaces that contain the current project. +- Project switching from Workset members, with a validated path back to the original project. +- Whole-Workset opening through the saved opener, plus an optional one-time opener override that does not change the saved Workset. +- Trusted Store members in Workset detail, clearly separated from project members and available as validated planning-root targets. + +### Changed + +- Store and Workset actions now preserve the current project, planning root, watcher, and visible data until fresh CLI validation succeeds. +- Workset creation uses the official selector-free OpenSpec CLI flow and includes the current project as a required member. + +### Fixed + +- Windows OpenSpec launcher resolution now passes arguments without shell expansion, including values containing `%VAR%` patterns. + ## [0.2.0] - 2026-08-26 ### Added @@ -94,7 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workspace and bundled path resolution for extension resources. -[Unreleased]: https://github.com/RandyZ/openspec-ext/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/RandyZ/openspec-ext/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/RandyZ/openspec-ext/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/RandyZ/openspec-ext/compare/v0.1.4...v0.2.0 [0.1.4]: https://github.com/RandyZ/openspec-ext/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/RandyZ/openspec-ext/compare/v0.1.2...v0.1.3 diff --git a/README.md b/README.md index eb7a822..d8633c9 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ OpenSpec brings change planning and execution into the editor. Review what needs ### Highlights - **Project-first sidebar**: Fixed Changes, Specs, Worksets, and Dashboard entry points, with clear active, focus, and unavailable states. +- **Stores and Worksets**: Use registered Stores as planning roots, then browse, inspect, create, and open trusted multi-folder Worksets without leaving the sidebar. - **Recommended actions**: A compact, resolver-backed action rail surfaces up to three next steps by priority: Needs Attention, Ready to Verify, then Recommended. - **Change detail**: Proposal, Specs, Design, Tasks, and Verify & Archive tabs with markdown rendering, task progress, and workflow controls. - **Safe workflow routing**: Review and verify actions open the appropriate detail or interactive terminal flow; high-impact archive actions stay confirmation-protected. -- **CLI integration**: OpenSpec CLI list, status, new, and archive flows with retry, timeout, and `openspec.cliPath` fallback. +- **CLI integration**: OpenSpec CLI list, status, new, archive, Store, and Workset flows with retry, timeout, and `openspec.cliPath` fallback. - **Editor-native**: Uses VS Code theme tokens, Codicons, keyboard-accessible controls, and narrow-sidebar-friendly layouts. ## Screenshots @@ -51,6 +52,19 @@ If Cursor or VS Code cannot see the CLI that works in your terminal, set `opensp `Review & Archive` is the primary Detail action and starts the interactive `/opsx-archive ` session. `Archive Now` is a secondary, confirmation-protected direct CLI action enabled only when the bound resolver reports all required artifacts and tasks complete. Dashboard Verify/Archive actions open this Detail surface; they never archive directly. +### Stores and Worksets + +Store and Workset controls are available with OpenSpec CLI 1.5.0 or newer. A **Store** is a writable planning root for changes and specs. Select or create one from the OpenSpec Root controls; the extension switches data only after the CLI validates the binding. + +A **Workset** is a machine-local, named multi-folder workspace. Open **Worksets** to: + +1. Browse Worksets that contain the current project and inspect their members without launching another editor window. +2. Switch the sidebar to another project member, or use a validated Store member as the planning root. +3. Open the complete Workset with its saved opener or a one-time opener override that does not modify the saved setting. +4. Create a Workset from the current project plus folders selected with the native folder picker. + +Opening a complete Workset changes the editor workspace; selecting a project member changes only the project shown in the OpenSpec sidebar. Store members are never treated as project targets. + ### Commands Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`): diff --git a/README.zh-CN.md b/README.zh-CN.md index ae8c984..b281483 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,10 +11,11 @@ OpenSpec 将 change 规划与执行带回编辑器:集中查看需要关注的 ### 核心能力 - **项目优先侧栏**:固定提供 Changes、Specs、Worksets、Dashboard 四个入口,清晰表达 active、focus 和不可用状态。 +- **Stores 与工作集**:将已注册 Store 用作规划根,并直接在侧栏中浏览、查看、创建和打开可信的多文件夹工作集。 - **推荐动作**:紧凑的、由 resolver 驱动的动作栏按 Needs Attention、Ready to Verify、Recommended 优先级展示最多三条下一步动作。 - **Change 详情**:提供 Proposal、Specs、Design、Tasks、Verify & Archive 标签页、Markdown 渲染、任务进度和工作流控制。 - **安全路由**:Review 和 Verify 会进入对应详情页或交互式终端;高影响归档动作始终保留确认保护。 -- **CLI 集成**:支持 OpenSpec CLI 的 list、status、new、archive,并提供重试、超时和 `openspec.cliPath` 兜底。 +- **CLI 集成**:支持 OpenSpec CLI 的 list、status、new、archive、Store 和 Workset 流程,并提供重试、超时和 `openspec.cliPath` 兜底。 - **编辑器原生体验**:复用 VS Code 主题 token 和 Codicon,支持键盘操作,并适配窄侧栏布局。 ## 截图 @@ -51,6 +52,19 @@ OpenSpec 将 change 规划与执行带回编辑器:集中查看需要关注的 `Review & Archive` 是详情页的主动作,会启动交互式 `/opsx-archive ` 会话。`Archive Now` 是次要的、执行前需要确认的 direct CLI 动作,只有当前 binding 的 resolver 判定必需 artifact 和任务全部完成时才可用。Dashboard 的 Verify/Archive 入口只打开这个详情页,不会直接归档。 +### Stores 与工作集 + +Store 和 Workset 控件需要 OpenSpec CLI 1.5.0 或更高版本。**Store** 是 changes 和 specs 的可写规划根;可从 OpenSpec Root 控件选择或创建,扩展只会在 CLI 验证 binding 后切换数据。 + +**工作集(Workset)** 是保存在本机的具名多文件夹工作区。打开 **Worksets** 后可以: + +1. 浏览包含当前项目的工作集,并在不启动新编辑器窗口的情况下查看成员详情。 +2. 将侧栏切换到另一个项目成员,或把经过验证的 Store 成员设为规划根。 +3. 使用已保存的 opener 打开完整工作集,或使用不会修改已保存设置的一次性 opener。 +4. 用当前项目和原生文件夹选择器选中的其他目录创建工作集。 + +打开完整工作集会切换编辑器 workspace;选择项目成员只会切换 OpenSpec 侧栏中展示的项目。Store 成员不会被当作项目目标。 + ### 命令 在命令面板中输入 OpenSpec: diff --git a/articles/juejin-part4-openspec-extension.md b/articles/juejin-part4-openspec-extension.md new file mode 100644 index 0000000..2ea8687 --- /dev/null +++ b/articles/juejin-part4-openspec-extension.md @@ -0,0 +1,114 @@ +# OpenSpec 实战第四篇:从单仓到多仓,一个 Dashboard 看清所有变更 + +> 这是 OpenSpec 实战系列的第四篇。 +> 上一篇聊了 OpenSpec 和 Superpowers 的分层关系,再往前两篇分别讲了「为什么 AI 编码缺的不是提示词而是规范」和一次完整的 `/opsx:new → /opsx:ff → /opsx:apply → /opsx:archive` 实战。 +> 这一篇不讲理念了——最近 OpenSpec 推出了 stores/worksets beta,规划可以独立成库、跨仓协作。而我把整套流程(包括这套新机制)做成了一个 VS Code 扩展。 + +--- + +## 为什么要写这个扩展 + +说实话,前三篇讲的流程我自己每天都在跑。但跑得越多越发现,**最打断心流的不是流程本身,而是「确认状态」这件事**。 + +其实推进工作流本身很顺:在 Agent 会话里敲 `/opsx:continue`、`/opsx:apply` 就行。但**只要想知道「现在有哪些 change、进行到哪了」,就得切出会话、打开终端敲 CLI**。这种上下文切换单仓时代已经很频繁了: + +- 查看进度要敲 `openspec list` / `openspec status`——终端输出看完就忘,过十分钟又得敲一遍 +- 想看某个 change 的 proposal 或 tasks 原文,得去 `openspec/changes/` 目录里翻 markdown,在 Agent 会话、文件树和终端之间反复横跳 +- tasks.md 里几十个复选框,完成度全靠肉眼数 +- archive 之前心里没底:任务到底清完没有?产物齐不齐? + +最近 stores beta 出来之后,这种「看不见」的痛直接翻倍: + +- 查看 store 里的 change,每条 CLI 命令都得带上 `--store team-plans`,少打一次就查到错的 root 上 +- `store:` 指针、`defaultStore`、`--store` 优先级层层覆盖——「这条命令到底作用在哪」全靠脑补 +- references 声明的上游 store 没注册、没 clone,要跑到 `openspec doctor` 才发现 + +**AI 编码的可视化工具不少,但 spec-driven 这条链路上,一直缺一个「控制台」。** 自己的需求自己最懂——于是我写了一个,顺手开源。 + +【对比图位置:左边终端连敲 N 条命令的输出 vs 右边侧边栏一屏看全,图你自己做】 + +## 30 秒认识 OpenSpec(老读者可跳过) + +OpenSpec 是一个 spec-driven 的 AI 编码工作流:让 AI 写代码之前,先把「要做什么」落成一份份 markdown 产物,AI 按产物执行,全程可追溯。 + +每个需求是一个 change,包含 proposal(为什么做)、specs(做成什么样)、design(怎么做)、tasks(做哪些事)。在 Agent 会话里用 `/opsx:new → /opsx:ff → /opsx:apply → /opsx:archive` 推进整个生命周期,产物全部存在仓库的 `openspec/` 目录里,跟着 git 走。 + +想深入了解的话,强烈推荐按顺序读前三篇: + +- 第一篇:《为什么 AI 编码真正缺的不是提示词,而是规范》【链接待补】 +- 第二篇:[一次完整的 OpenSpec 实战:需求实现与知识传递](https://juejin.cn/post/7614057963394547727) +- 第三篇:[OpenSpec vs Superpowers:别再把它们当竞品了](https://juejin.cn/post/7615801634949890082) + +## 功能巡礼 + +### 侧边栏 Dashboard:下一步该做什么,直接告诉你 + +打开侧边栏,Changes / Specs / Worksets / Dashboard 四个固定入口一字排开,激活、聚焦、不可用状态一目了然。 + +但我最喜欢的是 **Recommended Actions**:扩展会根据所有 change 的状态,自动算出最多 3 条「下一步」,按优先级排好——有问题的(Needs Attention)排最前,该验证的(Ready to Verify)其次,然后是常规推荐。以前这些信息要靠 `openspec status` 一条条看输出、在脑子里排优先级,现在打开面板就在那了。 + +整个面板用的是 VS Code 主题 token 和 Codicons,深浅色主题下都不违和,窄栏布局也能用——看起来就像编辑器自带的功能。 + +【截图:项目 Dashboard 侧边栏(openspec-dashboard.png)】 + +### 变更详情:从 Proposal 到 Tasks,一页看全 + +点开任何一个 change,Proposal / Specs / Design / Tasks / 验证与归档五个 Tab 全在一页里,markdown 直接渲染,不用再去 `openspec/changes/` 里翻文件。 + +Tasks Tab 解决了我最大的痛点:**进度可视化**。多少个任务、完成几个、卡在哪个,一眼看清。而且它不是简单显示复选框——默认开启「先完成前置任务」策略,前置任务没做完会拦住你(可以在设置里改成 warn 放行);每个任务还标着「上次执行成功 / 失败」,哪些任务翻过车一目了然。 + +【截图:变更详情与任务操作(openspec-change-detail.png)】 + +### 一键把命令发给 Agent + +看到任务是一回事,让 Agent 干活是另一回事。点任务旁边的执行按钮,扩展会按优先级选择执行者:**Copilot Chat > Cursor Agent CLI > 剪贴板**。装了 Copilot Chat 就直接预填进 Chat 输入框,没装就退到剪贴板,自己粘贴给任何 Agent。 + +这里有个设计取舍值得说一下:发出去的只是 `/opsx:apply ` 这样的简洁命令,**不是一大段冗长的 prompt**。因为 OpenSpec 的 skills 会自己加载上下文——工具该做的只是把「意图」准确送达,而不是越俎代庖。 + +【GIF 位置:点执行按钮 → 命令预填进 Copilot Chat → Agent 开始干活】 + +### Verify & Archive:高危操作必须走安全通道 + +完成度高了,就该验证了。扩展里的 `/opsx:verify` 会检查三个维度:**完整性**(所有 tasks 完成、所有需求有代码对应)、**正确性**(实现匹配 spec 意图、边界已处理)、**一致性**(设计决策反映在代码里)。 + +Verify 和 Archive 都不是静默执行的——它们在 VS Code 官方**交互式终端**里跑,Agent 中途反问时你可以直接继续输入,对话不会断。 + +归档操作做了明确的分层:「审查并归档」是主操作,打开交互式会话走完整流程;「立即归档」是次要操作,有确认弹窗保护,而且**只有当所有必需产物和任务都完成时才可用**。归档之后 change 变只读,防止误操作。 + +## 多仓时代:Stores & Worksets 可视化 + +[To be written:本篇重点节,呼应 OpenSpec stores beta] +- store 注册与状态一览 +- References 面板:上游需求只读引用 +- Worksets:一键把规划库 + 代码仓拉进同一个工作区 +- 当前作用域(scope)始终可见,不再脑补 root 解析 + +## 快速上手 + +[To be written:安装 + 前置要求] +- Marketplace / Open VSX 安装 +- 前置:OpenSpec CLI、`openspec/config.yaml` +- 找不到 CLI 时用 `openspec.cliPath` 兜底 + + + +## 给同类开发者的技术彩蛋(可选) + +[To be written:一段带过] +- React 19 + Tailwind + Radix UI 的 webview 技术栈 +- 中英双语 i18n(跟随编辑器语言) +- VS Code 主题 token 自适应,深浅色不违和 + + + +## 结尾 + +[To be written:路线图(跟进 stores beta 演进)+ 求 Star / 反馈 / PR] + +**项目地址**: +- GitHub:https://github.com/RandyZ/openspec-ext (欢迎 Star / Issue / PR) +- VS Code Marketplace:[链接待补] +- Open VSX:[链接待补] diff --git a/docs/superpowers/specs/2026-09-01-worksets-list-detail-create-design.md b/docs/superpowers/specs/2026-09-01-worksets-list-detail-create-design.md new file mode 100644 index 0000000..25cb424 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-worksets-list-detail-create-design.md @@ -0,0 +1,319 @@ +# Worksets 列表、详情与创建体验设计 + +## 决策摘要 + +Worksets 采用一条连续的侧边栏流程,而不是把列表和详情做成两个竞争方案: + +```mermaid +flowchart LR + L[Worksets 列表] -->|选择 Workset| D[Workset 详情] + D -->|返回| L + L -->|Create Workset| C[创建 Workset] + C -->|创建成功| D + C -->|取消| L + D -->|确认删除| L +``` + +- 列表负责发现和进入 Workset。 +- 详情负责理解成员拓扑、打开整个 Workset、切换当前项目和切换 Planning root。 +- 创建是从列表进入的第三种状态,不是独立的信息架构。 +- 页面继续使用现有 VS Code 主题变量、紧凑密度、轻量分隔线和 Codicon 风格图标。 + +本设计以 2026-09-01 已确认的高保真合并稿为视觉基准。实现时应匹配现有 OpenSpec Sidebar,而不是引入新的全屏工作台或新的视觉系统。 + +## 背景与现状 + +当前代码已经具备以下基础: + +- `WorksetsPage` 可以展示机器级 Workset 列表,并发送打开、删除消息。 +- `WorksetProjectPicker` 能读取当前项目关联的 Workset,区分 `project` 与 `store` 成员,并切换可选项目。 +- `ProjectDataGateway` 会重新读取官方 Workset 和 Store 清单、规范化绝对路径,并在 Store 清单不可用时 fail closed,避免把 Store 错当成可写项目。 +- `DataManager.openWorkset()` 使用普通命令执行器调用 `openspec workset open `。 +- `DataManager.removeWorkset()` 通过官方 CLI 删除保存记录,删除前已有模态确认;成员目录不会被删除。 +- `selectScope` 已能切换 Planning root,`selectWorksetProject` 已能切换当前项目上下文。 + +当前缺口是: + +- Project-first Sidebar 中的 Workset 直接展开全部成员,缺少清晰的列表与详情层级。 +- 没有 Create Workset UI。 +- 没有一次性 `--tool` 覆盖入口。 +- 当前 Workset 详情无法把“当前项目”和“Planning root”作为两个独立上下文明确表达。 + +OpenSpec 官方语义保持不变:Workset 是机器本地的多目录视图;Store 是 OpenSpec 的 Planning root。Workset 不负责复制上下文、任务分发、权限授予或 Git 同步。官方说明见 [Stores beta user guide](https://github.com/Fission-AI/OpenSpec/blob/main/docs/stores-beta/user-guide.md)。 + +## 目标 + +- 在窄侧边栏中提供可扫描的 Workset 列表。 +- 进入详情后明确显示成员角色,以及当前项目和 Planning root。 +- 支持通过 UI 创建 Workset,并明确第一个成员是 Primary。 +- 支持按保存工具打开,也支持一次性工具覆盖。 +- 复用现有 CLI、路径规范化、Store 分类和项目切换能力。 +- 保持 Workset 与 Store selector、Git 管理和 OpenSpec root 解析相互独立。 + +## 非目标 + +- 不实现 Git clone、pull、push 或仓库同步。 +- 不解析或修改 OpenSpec 私有的全局 Workset 文件。 +- 不实现成员编辑;CLI 没有正式 update 命令前,不通过“删除再创建”伪装成编辑。 +- 不新增 `Reference` Workset 成员角色;当前模型只支持 `project | store`。 +- 不让打开 Workset 自动改变 Planning root。 +- 不新增路由库、状态管理库或完整组件系统。 +- 不因为 Workset 存在而增加自动激活;继续使用当前显式 View 入口。 + +## 信息架构 + +### 1. Worksets 列表 + +入口为 Project-first Sidebar 的 `Worksets` tab。 + +顶部继续复用现有 Header 所表达的两个上下文: + +- `Project`:当前浏览和执行项目级操作的目录。 +- `Planning root`:当前 OpenSpec 命令解析到的 Local root 或 Store。 + +如果 Header 已经展示这两个上下文,列表内容区不再重复绘制一张上下文卡片。 + +列表行包含: + +- Workset 名称。 +- 成员数量。 +- 保存的工具;未设置时显示 `Default tool`。 +- `Open` 快捷操作。 +- 进入详情的整行点击区域和 chevron。 + +行为: + +- 点击行主体进入详情,不立即打开外部工具。 +- 点击 `Open` 直接使用保存工具打开;事件必须阻止行点击冒泡。 +- 顶部 `Create Workset` 进入创建状态。 +- 列表只显示包含当前项目的 Workset,沿用 `ProjectDataGateway.loadWorksetNavigation()` 的现有过滤语义。 +- 空状态说明当前项目尚未加入 Workset,并保留 `Create Workset` 主操作。 + +### 2. Workset 详情 + +详情顶部包含: + +- 返回列表。 +- Workset 名称。 +- 保存工具的只读显示;它不是编辑已保存 Workset 的入口。 +- `Open all` 主操作。 +- `Open with another tool` 次操作;展开后输入一次性 tool id。 + +成员区只使用一个分组 surface,以行分隔,不为每个成员创建独立卡片。 + +成员行为按角色决定: + +| 成员状态 | 展示 | 可用操作 | +|---|---|---| +| 当前 Project | `Project · Current` | 无操作,不重复切换 | +| 其他可选 Project | `Project` | `Switch project` | +| 不可选 Project | `Project · Unavailable` | 只读显示路径或原因 | +| 当前 Planning Store | `Store · Current root` | 无操作 | +| 其他 Store | `Store` | `Use as planning root` | + +约束: + +- `Switch project` 继续发送现有 `selectWorksetProject(worksetName, memberPath)`;Extension Host 必须重新读取官方 Workset 清单后再接受路径。 +- `Use as planning root` 只能对已由官方 Store inventory 匹配出的成员开放。Webview 只提交 Workset 名称和成员路径,Host 重新读取官方 Workset/Store inventory 后,再映射并选择对应 Store scope。 +- Store inventory 获取失败时不猜测角色、不显示可执行的项目或 Store 切换动作。 +- 打开 Workset 不触发 `selectScope`。 +- `Remove Workset` 保持危险操作样式,并继续使用现有模态确认。 + +### 3. Create Workset + +创建采用单屏表单,不做多步骤向导。 + +字段: + +1. `Name` + - trim 后必须非空。 + - 重名和 CLI 命名规则由官方 CLI 最终校验。 +2. `Primary project` + - 默认当前项目。 + - 可从已选成员中指定其他 Primary,但当前项目仍必须保留在 Members 中。 + - Primary 必须同时出现在 Members 中。 + - 发送 CLI 参数时永远排在第一个 `--member`。 +3. `Members` + - 当前项目默认加入,并且在 Project-first 创建流程中不可移除,确保新 Workset 创建后仍能在当前列表中找到。 + - 通过 VS Code 原生文件夹选择器追加一个或多个目录。 + - 相同规范化路径只保留一次。 + - 至少一个成员。 +4. `Preferred tool` + - 可选。 + - 使用可编辑 combobox:快捷项提供 `code`、`cursor`,同时允许输入用户在 OpenSpec `openers` 配置中的自定义 id。 + - Extension 不读取或写入 OpenSpec 私有全局配置。 + +提交摘要明确显示: + +- 将打开多少个目录。 +- Primary 是哪个目录。 +- 创建 Workset 不改变当前 Planning root。 + +创建成功后刷新 Project Sidebar 数据,并直接进入新 Workset 详情。创建失败时保留表单内容并展示错误,不做乐观成功状态。 + +## 状态模型 + +组件只需要三个本地视图状态: + +```ts +type WorksetsViewState = + | { kind: 'list' } + | { kind: 'detail'; name: string } + | { kind: 'create' }; +``` + +不引入路由。离开 `Worksets` tab 后允许状态重置为列表;当前版本不需要跨会话持久化所选 Workset。 + +刷新规则: + +- 详情中的 Workset 刷新后仍存在:保持详情。 +- Workset 被外部删除或重命名:回到列表并显示一次轻量提示。 +- 创建中发生普通数据刷新:保留用户草稿。 +- 切换当前项目:重新加载该项目关联的 Workset,并回到列表。 + +## 数据与消息流 + +### 现有消息继续复用 + +| 消息 | 用途 | +|---|---| +| `openWorkset(name)` | 使用保存工具打开整个 Workset | +| `removeWorkset(name)` | 确认后删除本地保存记录 | +| `selectWorksetProject(worksetName, memberPath)` | 切换到另一个 Project 成员 | +| `selectScope(scopeId)` | 保留给现有 Root selector 使用 | + +### 最小新增消息 + +| 消息 | 方向 | 用途 | +|---|---|---| +| `pickWorksetMembers` | Webview → Host | 打开 VS Code 文件夹多选器 | +| `worksetMembersPicked` | Host → Webview | 返回用户选择的绝对目录 | +| `createWorkset(name, members, tool?)` | Webview → Host | 创建 Workset;`members[0]` 为 Primary | +| `worksetCreateResult` | Host → Webview | 明确返回成功或失败,成功时包含名称 | +| `selectWorksetStore(worksetName, memberPath)` | Webview → Host | 重新验证成员后切换对应 Store scope | + +扩展现有 `openWorkset` 为: + +```ts +openWorkset(name: string, tool?: string) +``` + +- 没有 `tool`:执行 `openspec workset open `。 +- 有 `tool`:执行 `openspec workset open --tool `。 +- 一次性覆盖不修改保存的 `WorksetView.tool`。 + +创建命令保持 selector-free: + +```text +openspec workset create \ + --member \ + --member ... \ + [--tool ] \ + --json +``` + +`workset list/create/open/remove` 都不能附加当前 Store selector。Workset 是机器级状态,不属于某个 Planning root。 + +## 组件映射 + +首期优先修改现有路径,不建立新的通用框架: + +- `Dashboard.tsx` + - 保留现有 `Worksets` tab 和数据加载。 + - 连接创建、打开覆盖、Store 选择消息。 +- `WorksetProjectPicker.tsx` + - 继续作为 Project-first Worksets 工作区的协调组件。 + - 在内部管理 `list/detail/create` 状态。 + - 复用现有成员角色、当前项目判断和切换逻辑。 +- `WorksetsPage.tsx` + - 保持机器级 Workset 管理页可用。 + - 复用相同的列表行视觉规则;不强行加入缺少 Project 上下文的切换动作。 +- `messages.ts` + - 扩展 `openWorkset` 可选 tool。 + - 增加创建、文件夹选择和 Workset Store 选择消息。 +- `DataManager` + - 增加 `createWorkset()`。 + - 扩展 `openWorkset()` 的一次性 tool 参数。 +- `dashboardViewProvider` / `webviewMessageHandler` + - 调用 VS Code 文件夹选择器。 + - 成功创建后刷新正确的 Project Sidebar 数据,而不仅是旧 Dashboard 数据。 + +如果 `WorksetProjectPicker.tsx` 因三种状态明显失去可读性,再拆出 `WorksetListView`、`WorksetDetailView`、`WorksetCreateForm`;首个实现不提前建立组件目录或抽象层。 + +## 错误处理与安全边界 + +- Workset capability 明确为 `false`:隐藏创建、打开、删除入口,沿用升级提示。 +- `workset list --json` 失败:不把失败伪装成空列表;Project navigation 保持 fail closed。 +- 文件夹选择取消:保留创建表单,不显示错误。 +- 成员目录不存在或无法规范化:不加入表单,提示用户重新选择。 +- CLI 创建失败:不刷新为成功状态,不清空草稿。 +- 打开工具失败:通过现有 `error` 消息反馈,详情保持不变。 +- Store inventory 不可靠:隐藏 `Switch project` / `Use as planning root` 等需要角色信任的动作。 +- `selectWorksetStore` 必须像现有 Project 切换一样重新读取官方成员关系,不能直接信任 Webview 提交的 Store id 或路径。 +- 删除仅删除 Workset 记录;确认文案明确“不删除成员目录、仓库或 Store”。 +- 所有来自 Webview 的名称、tool id 和路径都视为不可信输入;Host 侧必须校验类型,并由 CLI 或重新读取的官方清单完成最终授权。 + +## 可访问性与视觉约束 + +- 行主体和行内 `Open` 是两个独立可聚焦控件。 +- 所有 icon-only button 必须有 `aria-label` 和 tooltip。 +- 当前项目使用文字 `Current`,不能只靠绿色。 +- Store 与 Project 除颜色外还要有文字标签和不同图标。 +- focus 使用 `--vscode-focusBorder`,不能只设计 hover。 +- 窄宽度下操作换行或降级为行尾菜单,不截断 Workset 名称和关键角色。 +- 尊重 `prefers-reduced-motion`;状态切换只需 120–160ms 的轻量颜色/透明度变化。 +- 继续使用 VS Code theme tokens,不写死深色背景或前景色。 + +## TDD 与验收标准 + +实现按以下顺序先写失败测试,再补最小代码: + +### 组件测试 + +- 列表默认不展开成员,点击行进入对应详情。 +- `Open` 不触发详情导航,只发送一次打开消息。 +- 返回详情前一层后恢复列表。 +- Current Project、可选 Project、Store 分别显示正确动作。 +- Store inventory 不可用时不显示角色切换动作。 +- Create 表单拒绝空名称、空成员和重复成员。 +- Primary 始终排在提交 members 的第一位。 +- 一次性工具覆盖只影响本次 open 消息。 +- 键盘可完成列表进入、返回、打开和创建流程。 + +### Extension Host 测试 + +- `createWorkset()` 生成准确的 selector-free CLI 参数,并使用 JSON runner。 +- `openWorkset(name)` 继续使用普通 runner,不请求 JSON。 +- `openWorkset(name, tool)` 只追加 `--tool `。 +- `selectWorksetStore` 只接受重新读取后仍属于该 Workset 的 Store 成员。 +- 文件夹选择取消不发送成功结果。 +- 创建成功刷新 Project Sidebar 数据;失败发送错误且不伪造刷新。 +- 删除仍保留现有确认与“只删记录”语义。 + +### 回归门槛 + +- `pnpm test` +- `pnpm run build` +- `npx eslint src/`;只报告与本次改动相关的新问题,现有环境 globals 问题单独标注。 +- 在 VS Code Extension Development Host 中人工验证:列表 → 详情 → 创建 → 打开 → 一次性 tool override → 删除取消/确认。 + +## 分阶段交付 + +### 阶段 1:列表与详情 + +- 只重排现有数据和动作。 +- 不新增 CLI mutation。 +- 验证 Project 与 Planning root 不互相污染。 + +### 阶段 2:创建与一次性工具覆盖 + +- 新增文件夹选择、create 命令和 tool override。 +- 创建成功进入详情。 +- 保持所有 Workset 命令 selector-free。 + +### 阶段 3:视觉与文档收口 + +- 按高保真稿校正间距、边框、focus 和窄宽度。 +- 更新中英文文案、README/截图和命令边界说明。 + +成员编辑、Git 管理和自动激活不进入这三个阶段;只有 OpenSpec 发布正式 Workset update 能力或实际用户证据证明需要时,再单独设计。 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/.openspec.yaml b/openspec/changes/add-configurable-structured-spec-viewer/.openspec.yaml new file mode 100644 index 0000000..61502f4 --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: aihelp-dev +created: 2026-09-01 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/design.md b/openspec/changes/add-configurable-structured-spec-viewer/design.md new file mode 100644 index 0000000..3db512e --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/design.md @@ -0,0 +1,138 @@ +## Context + +需求、交互选择与视觉基线见 [explore.md](./explore.md);行为契约见 [artifact-viewing delta spec](./specs/artifact-viewing/spec.md)。当前 Host 已按所选 scope 读取完整主 Spec 或 delta spec,Webview 的 `SpecViewer` 与 `ArtifactViewer` 最终都调用通用 `MarkdownRenderer`。本变更跨越 VS Code 配置、Host 消息契约和 React 渲染,但不改变内容读取、OpenSpec CLI 或 scope containment。 + +用户提供的深色界面参考图是视觉验收基线:Requirement 标题与规范正文构成一组,规范正文优先可见,Scenario 仅显示可展开摘要;实际颜色、边框和焦点态必须使用 VS Code theme token,不能硬编码成截图主题。 + +## Goals / Non-Goals + +**Goals:** + +- 让主 Spec 与 delta spec 复用同一套结构解析、折叠和关键字上色行为。 +- 保留完整 Markdown 能力,并对非标准或无法安全分段的文档完整回退。 +- 在 Host 侧把用户配置归一化成可安全传入 Webview 的生效颜色表。 +- 用原生 disclosure 和既有 `marked` 渲染器完成实现,不引入依赖或自定义状态机。 + +**Non-Goals:** + +- 不改变 Spec 文件语法、CLI 输出、Store/Workset 或 scope 解析。 +- 不为 proposal、design、tasks 启用结构化 Spec 模式。 +- 不支持多词/正则关键字、全局全部展开/收起、折叠状态持久化或配置热更新广播。 +- 不在本变更实现 delta diff 配色。 + +## Decisions + +### 1. Host 只负责配置边界,Webview 负责文档结构 + +新增一个小型纯函数配置读取器,复用 `vscode.workspace.getConfiguration('openspec')` 模式。它将内置映射与 `specKeywordColors` 合并,并只接受: + +- 最多 64 个配置项;关键字长度不超过 32 且匹配 `[A-Z][A-Z0-9_-]*`。 +- `#RRGGBB`;或匹配 VS Code theme color id 语法的 `vscode:`。 +- theme id 在 Host 转为安全的 `var(--vscode-...)` CSS 值;Webview 不再解释任意 CSS。 + +内置映射分别使用蓝、绿、灰、红类别的现有 VS Code theme token。无效自定义项被忽略;无效内置覆盖不会移除默认值。读取发生在 `getSpecContent` 和 `getDeltaSpecContent` 响应时,因此刷新或重新打开会自然获得新值,无需新增配置监听器。 + +```text +Webview request + getSpecContent / getDeltaSpecContent + | + v +webviewMessageHandler -------------------+ + read scope-contained Markdown | + read + normalize workspace setting | + | | + +--> specContent | + | content | + | keywordColors | + | | + +--> deltaSpecContent | + content | + keywordColors | + v + shared SpecDocumentRenderer +``` + +备选方案是单独广播全局配置消息;它会增加生命周期与缓存同步问题,而首版只要求下一次加载生效,因此不采用。 + +### 2. 一个共享、行级的 canonical Spec 分段器 + +新增 `SpecDocumentRenderer`,由 `SpecViewer` 直接使用;`ArtifactViewer` 仅在当前输出 `kind === 'specs'` 时使用,其他 artifact 仍走 `MarkdownRenderer`。组件内导出一个纯分段函数供测试,避免再引入 parser 抽象层。 + +分段器逐行扫描并跟踪 fenced code block,只在代码围栏之外识别: + +- `### Requirement: `:开始 Requirement。 +- `#### Scenario: <title>`:开始当前 Requirement 的 Scenario。 +- 其他内容:原样归入前置 Markdown、Requirement 规范正文或 Scenario 明细。 + +输出只需要两类顶层 segment:普通 Markdown 与包含 `title/body/scenarios` 的 Requirement。找不到 Requirement、出现孤立 Scenario、边界不一致或解析抛错时返回失败标记,调用方把原始全文交回现有 `MarkdownRenderer`。这样不会出现“部分结构化、部分丢失”的中间状态。 + +备选方案是扩展 `marked` renderer 或引入 Markdown AST 插件;前者难以表达嵌套 disclosure,后者增加依赖与迁移成本,均不采用。 + +### 3. 原生 disclosure 对齐认可的阅读层级 + +每个 Requirement 渲染为带 `open` 的 `<details>`,每个 Scenario 渲染为不带 `open` 的嵌套 `<details>`。`<summary>` 保留可聚焦、Enter/Space 和 pointer 的平台行为;不在 React state 中镜像开关状态。重新加载组件即恢复默认状态。 + +```text +<details class="spec-requirement" open> + <summary>Requirement title</summary> + [Markdown: complete normative body] + <details class="spec-scenario"> + <summary>Scenario title</summary> + [Markdown: complete scenario body] + </details> +</details> +``` + +样式放入现有 `src/webview/index.css`:使用 `panel-border` 表达左侧层级线,Requirement 标题采用正文强调色,Scenario 使用次级前景色,并补齐 `:focus-visible`。窄宽度下只允许正文换行,不引入固定宽度或横向布局。 + +### 4. 在已生成 DOM 的文本节点上安全上色 + +`MarkdownRenderer` 增加可选 `keywordColors` 属性。HTML 仍由既有 `marked` 配置生成;随后 effect 使用 `TreeWalker` 遍历文本节点,用转义并按长度排序的关键字构造一次完整单词 matcher,把命中片段替换成仅设置已归一化 `color` 的 `<span>`。 + +遍历时跳过祖先为 `CODE`、`PRE`、`A` 或 `.mermaid` 的节点,也跳过已生成的关键字 span。由于替换发生在文本节点而不是 HTML 字符串上,`<strong>` 等 Markdown 结构保持不变,也不会把配置值注入 markup。内容或颜色表变化时,React 先重建原始 `dangerouslySetInnerHTML`,再重新执行一次高亮,不需要清理增量 DOM。 + +备选方案是在 Markdown 原文上做正则替换;这会污染代码块、链接和 HTML,且容易产生注入问题,因此不采用。 + +### 5. 消息与组件状态保持最小扩展 + +`specContent` 与 `deltaSpecContent` 在共享消息类型中各增加 `keywordColors: Record<string, string>`。`SpecViewer` 保存对应内容和颜色表;`ChangeDetail` 在接收 delta 内容时把颜色表与当前 spec 内容一起交给 `ArtifactViewer`。普通 `artifactContent` 消息不变。 + +```text +main Spec: specContent ------> SpecViewer ------+ + | + v + SpecDocumentRenderer + ^ + | +delta Spec: deltaSpecContent -> ChangeDetail -> ArtifactViewer(kind=specs) + +other artifactContent -------------------------> MarkdownRenderer +``` + +测试按边界分层:Host 纯函数覆盖默认值、添加、覆盖和非法输入;Webview 纯分段函数覆盖主/delta 结构、代码围栏和 fail-open;DOM 测试覆盖 disclosure 默认状态、键盘原生元素、完整单词、加粗保留及跳过节点;既有 `ArtifactViewer`/消息处理测试覆盖主与 delta 接线。 + +## Risks / Trade-offs + +- **[行级解析只支持 canonical heading]** → 这是明确的 MVP 边界;任何不确定结构整篇回退,保证内容完整。 +- **[大 Spec 会进行一次 DOM 文本遍历]** → 单次 `TreeWalker` 与一个合并 matcher,且配置最多 64 项;不引入逐关键字全树扫描。 +- **[分段后 heading id 作用域可能重复]** → Requirement/Scenario 标题由 `<summary>` 承担,不依赖原 heading anchor;普通 Markdown 仍沿用现有 heading 行为。 +- **[用户选择的 literal hex 可能在某些主题对比度不足]** → 默认值始终 theme-aware;自定义 literal 是用户显式选择,focus 与 disclosure 状态仍不依赖颜色。 +- **[配置改变不会立即更新已打开页面]** → UI 不伪装实时更新;按契约在刷新或重新打开时读取最新值。 + +## Migration Plan + +1. 先加入配置归一化及单元测试,再扩展 typed 消息,保证 Host 永远发送安全且包含默认值的映射。 +2. 加入纯分段器、共享 renderer 与 DOM 测试,再接入主 Spec 和 delta spec 两条现有路径。 +3. 补充主题/窄宽度样式与真实 Extension Host 视觉验收,对照用户提供的参考图检查层级、默认开关和四类颜色。 +4. 发布不需要数据迁移;未配置用户自动使用默认映射。 + +回滚时移除新配置声明、消息字段和 Spec 专用 renderer,两个入口恢复直接调用现有 `MarkdownRenderer`;Spec 文件与用户内容无需转换。 + +## Open Questions + +无。多词匹配、状态持久化与实时配置更新保留为有真实需求时再评估的后续项。 + +## Spec Amendments + +- [x] **Capability: `artifact-viewing`** — 设计确认必须给配置匹配建立确定上限;已在 delta spec 中补充最多 64 项、关键字最长 32 字符,并把超限归入安全忽略场景。 +- [x] **Capability: `artifact-viewing`** — 最终校验发现 MODIFIED requirement 必须保留既有 `Render specs` 场景;已补回该兼容场景,并保留更具体的主 Spec 与 delta spec 场景。 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/explore.md b/openspec/changes/add-configurable-structured-spec-viewer/explore.md new file mode 100644 index 0000000..d0390da --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/explore.md @@ -0,0 +1,107 @@ +<!-- Exploration output for this change — input for proposal, not the contract. --> + +## Clarified requirements and constraints + +### Problem + +当前主 Spec 预览和 change 内的 delta spec 都只把完整 `spec.md` 交给通用 Markdown renderer。Requirement、规范正文和 Scenario 虽能阅读,但没有结构化层级:Scenario 明细会一次性展开,BDD/规范关键字也没有语义配色。用户需要接近已提供视觉参考的阅读体验,让规范句优先、Scenario 明细按需展开。 + +### Required outcomes + +- 主 Spec 与 delta spec 使用一致的结构化阅读模式。 +- `Purpose` 等非 Requirement 内容继续按普通 Markdown 显示。 +- 每个 `### Requirement:` 默认展开,标题下立即显示其到首个 Scenario 之间的完整规范正文。 +- 每个 `#### Scenario:` 默认收起,只显示标题;点击或键盘操作后显示完整明细。 +- 默认关键字配色: + - `GIVEN`、`WHEN`:蓝色主题 token。 + - `THEN`:绿色主题 token。 + - `AND`:灰色说明 token。 + - `MUST`、`SHALL`、既有规格要求的 `SHOULD`:红色错误/测试失败 token。 +- 新增 `openspec.specKeywordColors` 设置,允许用户新增任意单词型关键字,也允许覆盖内置关键字颜色。 +- 配置值支持十六进制颜色和 `vscode:<theme-color-id>`;Host 校验、归一化并与默认值合并。 +- 只匹配大写完整单词;保留 Markdown 加粗,但跳过代码块、行内代码、链接和 Mermaid。 +- 无法识别标准 OpenSpec 标题结构时,fail-open 到当前完整 Markdown 渲染,不丢正文。 + +### Constraints and boundaries + +- 复用 Host 已经发送的完整 Spec 文本;不修改 OpenSpec CLI、Spec 文件格式、Content Access 或 Store/Project binding。 +- 不新增运行时依赖、路由、全局折叠状态或新的 Markdown 引擎。 +- 使用原生 `<details>/<summary>` 获得键盘与可访问性基础行为。 +- 配置变化在下一次打开或刷新 Spec 时生效;首版不增加全局配置热更新广播。 +- 自定义关键字首版为单个英文 token:`[A-Z][A-Z0-9_-]*`;不支持多词短语或正则表达式。 +- 不提供“全部展开/收起”,不持久化展开状态,不改变普通 proposal/design/tasks 的 Markdown 表现。 + +## Agreed design direction + +### Approaches considered + +| Approach | Benefits | Costs | Decision | +|---|---|---|---| +| 单一 `keyword -> color` 对象设置 | 可新增和覆盖,设置面最小,容易与默认值合并 | 需要 Host 校验和一条 typed config payload | Selected | +| 为 GIVEN/WHEN、THEN、规范关键字分别提供固定设置 | Schema 提示明确 | 不能自然增加 `MAY`、`NEVER` 等任意关键字,设置项膨胀 | Rejected | +| 注册 VS Code `contributes.colors` 并让关键字引用静态 color id | 主题集成原生 | 动态关键字无法动态注册 color contribution,结构过重 | Rejected | + +### Final interaction model + +```text +Spec title + | + +-- Purpose normal Markdown + | + +-- Requirements + | + +-- ▼ Requirement A open by default + | normative statement always visible while open + | ▸ Scenario A1 closed by default + | ▸ Scenario A2 closed by default + | + +-- ▼ Requirement B + normative statement + ▸ Scenario B1 +``` + +Requirement 使用外层 `<details open>`,Scenario 使用嵌套且不带 `open` 的 `<details>`。刷新或重新打开后恢复该默认状态。 + +### Effective configuration + +```text +Built-in keyword colors + + +openspec.specKeywordColors + | + v +Host normalize + validate + | + v +specContent / deltaSpecContent + | + v +Structured Spec renderer +``` + +配置示例: + +```json +{ + "openspec.specKeywordColors": { + "WHEN": "#C586C0", + "MAY": "vscode:editorWarning.foreground", + "NEVER": "#FF8800" + } +} +``` + +无效的内置覆盖回退到默认色;无效的自定义项被忽略并记录一次诊断,不阻断 Spec 阅读。 + +## Key decisions + +- **Webview owns structure rendering:** Host 继续返回完整 Markdown;结构化解析只影响展示。 +- **One shared Spec renderer:** 主 Spec 与 delta spec 复用同一组件,普通 Artifact 继续使用现有 `MarkdownRenderer`。 +- **Canonical grammar, fail-open:** 只识别标准 `### Requirement:` 与 `#### Scenario:`;遇到不规则结构时完整回退。 +- **Native disclosure controls:** 使用 `<details>/<summary>`,不实现自定义 Accordion 状态机。 +- **Safe text-node highlighting:** 在 Markdown 已生成的 DOM 文本节点上应用转义后的完整单词 matcher,跳过非语义节点,避免正则替换 HTML。 +- **Theme-first defaults:** 默认值使用 VS Code theme tokens;用户可显式选择 literal hex。 +- **Bounded custom input:** 限制关键字数量和长度,拒绝正则、多词短语及非法颜色,避免异常配置造成慢匹配或 CSS 注入。 +- **No live config synchronization in MVP:** 重新打开或刷新时读取最新配置。 +- **Existing contract alignment:** `artifact-viewing` 已要求区分 Requirement/Scenario 并高亮 `SHALL/MUST/SHOULD`;正式 delta spec 将补足折叠、具体颜色、自定义配置和安全回退语义。 + diff --git a/openspec/changes/add-configurable-structured-spec-viewer/proposal.md b/openspec/changes/add-configurable-structured-spec-viewer/proposal.md new file mode 100644 index 0000000..2514d2a --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/proposal.md @@ -0,0 +1,30 @@ +<!-- Distilled from explore.md saved at openspec/changes/add-configurable-structured-spec-viewer/explore.md --> + +## Why + +当前 Spec 预览把 Requirement 与 Scenario 当作普通 Markdown 连续展示,规范句不够突出,Scenario 明细也会挤占阅读空间。需要在不改变 OpenSpec 文件格式的前提下,让主 Spec 和 delta spec 都能优先呈现规范正文、按需展开 Scenario,并允许团队按自身词汇调整语义配色。 + +## What Changes + +- 为主 Spec 与 change 内的 delta spec 提供一致的结构化阅读模式:Requirement 默认展开并显示完整规范正文,Scenario 默认收起且可通过原生 disclosure 控件展开。 +- 为 `GIVEN`、`WHEN`、`THEN`、`AND`、`MUST`、`SHALL`、`SHOULD` 提供主题感知的默认语义配色。 +- 新增 `openspec.specKeywordColors` 设置,允许用户增加单词型关键字或覆盖内置关键字颜色;支持十六进制颜色与 VS Code theme color 引用。 +- 在代码、链接与 Mermaid 等非规范文本上下文中跳过关键字上色,并在无法可靠识别 Spec 结构时回退到现有完整 Markdown 渲染。 +- MVP 不包含全部展开/收起、折叠状态持久化、多词或正则关键字,以及配置热更新广播。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `artifact-viewing`: 明确 Spec 的 Requirement/Scenario 分层折叠、默认与自定义关键字配色、安全跳过上下文及结构解析失败回退行为。 + +## Impact + +- Webview 的主 Spec、delta spec 与 Markdown 渲染路径将增加共享的 Spec 结构展示和安全文本高亮逻辑。 +- Extension Host 将读取、校验并归一化 `openspec.specKeywordColors`,把生效后的颜色映射附加到现有 Spec 内容消息。 +- `package.json` 将声明新的 VS Code 配置项;现有 OpenSpec CLI、Spec 文件格式、Store/Project binding 和普通 artifact 渲染不变。 +- 不新增运行时依赖,也没有破坏性变更。 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/specs/artifact-viewing/spec.md b/openspec/changes/add-configurable-structured-spec-viewer/specs/artifact-viewing/spec.md new file mode 100644 index 0000000..d7608e0 --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/specs/artifact-viewing/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Artifact Content Rendering +The system SHALL render artifact content in a readable format and SHALL use a shared structured reading mode for canonical main and delta Spec documents. + +#### Scenario: Render proposal +- **GIVEN** a `proposal.md` file +- **WHEN** the user views it +- **THEN** the markdown MUST be rendered as formatted HTML +- **AND** headings, lists, and code blocks MUST be styled correctly +- **AND** links MUST be clickable + +#### Scenario: Render specs +- **GIVEN** a main or change `specs/<domain>/spec.md` file +- **WHEN** the user views it +- **THEN** requirements and scenarios MUST be clearly distinguished +- **AND** normative keywords `SHALL`, `MUST`, and `SHOULD` MUST be highlighted +- **AND** scenario `GIVEN`/`WHEN`/`THEN` structure MUST remain readable + +#### Scenario: Render structured main spec +- **GIVEN** a main `openspec/specs/<domain>/spec.md` file with canonical `### Requirement:` and `#### Scenario:` headings +- **WHEN** the user views it +- **THEN** content outside Requirement blocks MUST render as ordinary Markdown +- **AND** each Requirement MUST render as an expanded disclosure with its complete normative body visible +- **AND** each Scenario MUST render as a nested collapsed disclosure showing its title +- **AND** the user MUST be able to expand or collapse each disclosure with pointer or keyboard controls + +#### Scenario: Render structured delta spec +- **GIVEN** a change `specs/<domain>/spec.md` file with canonical `### Requirement:` and `#### Scenario:` headings +- **WHEN** the user views it +- **THEN** it MUST use the same Requirement and Scenario structure as a main Spec +- **AND** delta operation headings and content outside Requirement blocks MUST remain visible as ordinary Markdown + +#### Scenario: Restore disclosure defaults +- **GIVEN** the user changed Requirement or Scenario disclosure state +- **WHEN** the Spec is reopened or refreshed +- **THEN** every Requirement MUST return to expanded state +- **AND** every Scenario MUST return to collapsed state + +#### Scenario: Fall back for a non-canonical spec +- **GIVEN** a Spec whose Requirement and Scenario structure cannot be parsed safely +- **WHEN** the user views it +- **THEN** the complete source MUST render through the existing Markdown presentation +- **AND** no source content MUST be omitted +- **AND** the rendering failure MUST NOT prevent other artifacts from being viewed + +#### Scenario: Render design +- **GIVEN** a `design.md` with ASCII diagrams +- **WHEN** displayed +- **THEN** ASCII diagrams MUST preserve formatting with a monospace font +- **AND** code blocks MUST have syntax highlighting +- **AND** headings MUST create a navigable outline + +#### Scenario: Render tasks +- **GIVEN** a `tasks.md` file +- **WHEN** displayed +- **THEN** tasks MUST show as interactive checkboxes as defined by the Task Management spec +- **AND** non-task content MUST render as Markdown +- **AND** task hierarchy MUST be visually clear + +## ADDED Requirements + +### Requirement: Configurable Spec keyword highlighting +The system SHALL highlight semantic keywords in structured main and delta Specs with theme-aware defaults and SHALL allow valid workspace configuration to add or override keyword colors. + +#### Scenario: Apply default semantic colors +- **GIVEN** no `openspec.specKeywordColors` overrides are configured +- **WHEN** a structured Spec is rendered +- **THEN** complete uppercase words `GIVEN` and `WHEN` MUST use a blue theme color +- **AND** complete uppercase word `THEN` MUST use a green theme color +- **AND** complete uppercase word `AND` MUST use a gray theme color +- **AND** complete uppercase words `MUST`, `SHALL`, and `SHOULD` MUST use a red theme color +- **AND** the surrounding text and Markdown emphasis MUST remain unchanged + +#### Scenario: Add and override configured keywords +- **GIVEN** `openspec.specKeywordColors` contains uppercase single-token keys matching `[A-Z][A-Z0-9_-]*` +- **AND** it contains no more than 64 entries and each key is no longer than 32 characters +- **AND** each configured value is either a `#RRGGBB` color or a `vscode:<theme-color-id>` reference +- **WHEN** a main or delta Spec is opened or refreshed +- **THEN** a custom key MUST be highlighted with its configured color +- **AND** a configured built-in key MUST override its default color +- **AND** complete-word matching MUST prevent partial-word highlights + +#### Scenario: Ignore invalid keyword configuration safely +- **GIVEN** `openspec.specKeywordColors` contains invalid keys, invalid colors, values of the wrong type, or entries beyond supported bounds +- **WHEN** a Spec is opened or refreshed +- **THEN** invalid custom entries MUST be ignored +- **AND** an invalid built-in override MUST retain its default color +- **AND** valid entries MUST still take effect +- **AND** the Spec MUST remain readable + +#### Scenario: Skip non-semantic text contexts +- **GIVEN** a highlighted keyword also appears inside a code block, inline code, link, or Mermaid diagram +- **WHEN** the structured Spec is rendered +- **THEN** those non-semantic occurrences MUST NOT receive keyword highlighting +- **AND** matching occurrences in ordinary prose and scenario steps MUST remain highlighted + +#### Scenario: Apply configuration on the next content load +- **GIVEN** a user changes `openspec.specKeywordColors` while a Spec is already displayed +- **WHEN** no content reload has occurred +- **THEN** the displayed colors MUST remain unchanged +- **WHEN** the user reopens or refreshes that Spec +- **THEN** the newly effective valid configuration MUST be applied diff --git a/openspec/changes/add-configurable-structured-spec-viewer/task-details/01-config-and-messages.md b/openspec/changes/add-configurable-structured-spec-viewer/task-details/01-config-and-messages.md new file mode 100644 index 0000000..4241170 --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/task-details/01-config-and-messages.md @@ -0,0 +1,64 @@ +# Task 1. 配置与消息契约 + +<!-- covers: Task 1.1, Task 1.2 --> + +### Task 1.1: 声明关键字颜色设置并实现带默认值的安全归一化 + +**Spec coverage:** `artifact-viewing` / `Configurable Spec keyword highlighting` / `Apply default semantic colors`, `Add and override configured keywords`, `Ignore invalid keyword configuration safely` + +**Dependencies / order:** 首个实现任务;完成后 Task 1.2 和 Task 3 才能依赖同一个生效颜色契约。 + +**Files:** +- Create: `src/extension/services/specKeywordColors.ts` +- Modify: `package.json` +- Test: `test/extension/services/specKeywordColors.test.ts`, `test/extension/packageConfiguration.test.ts` + +**Implementation notes:** +- 声明 `openspec.specKeywordColors` 为 object setting,value schema 仅接受 string;描述中写明 key、`#RRGGBB`、`vscode:<theme-color-id>`、64 项和 32 字符边界。 +- 导出 `getSpecKeywordColors(): Record<string, string>`;从 `vscode.workspace.getConfiguration('openspec')` 读取配置并合并内置映射:`GIVEN/WHEN` 使用 `textLink.foreground`,`THEN` 使用 `testing.iconPassed`,`AND` 使用 `descriptionForeground`,`MUST/SHALL/SHOULD` 使用 `testing.iconFailed`。 +- 只把合法 hex 或 theme id 转为 hex/`var(--vscode-...)`;非法覆盖不得删除内置默认值。不要新增依赖或通用配置框架。 + +**Verification:** +- Focused: `rtk pnpm test -- test/extension/services/specKeywordColors.test.ts test/extension/packageConfiguration.test.ts` +- Expected: 两个文件全部通过;默认、添加、覆盖、错误类型、非法颜色、超长 key 和第 65 项均有确定断言。 + +**Risks / edge cases:** +- VS Code theme id 中的 `.` 只转换为 CSS 变量分隔符,其他字符必须先通过白名单校验。 +- 遍历配置时先应用 64 项上限,避免超限输入扩大后续 matcher;合法项顺序不能改变内置回退语义。 + +- [ ] **Step 1 (RED, 2–5 min):** 新增配置解析测试和 package schema 断言,运行 focused 命令并确认因 setting/helper 缺失而失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 在 `package.json` 增加最小 setting 声明并实现默认颜色常量与合法值转换。 +- [ ] **Step 3 (GREEN, 2–5 min):** 加入 key/value/数量边界和“非法内置覆盖保留默认值”逻辑,不处理多词或正则。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS,且测试没有依赖真实 VS Code Host。 + +--- + +### Task 1.2: 在主 Spec 与 delta spec 内容消息中传递生效颜色映射 + +**Spec coverage:** `artifact-viewing` / `Configurable Spec keyword highlighting` / `Add and override configured keywords`, `Ignore invalid keyword configuration safely`, `Apply configuration on the next content load` + +**Dependencies / order:** 依赖 Task 1.1;必须在 Task 4 接入 Webview 状态前完成 typed payload。 + +**Files:** +- Create: None +- Modify: `src/webview/types/messages.ts`, `src/extension/providers/webviewMessageHandler.ts` +- Test: `test/extension/providers/webviewMessageHandler.test.ts`, `test/extension/providers/dashboardViewProvider.test.ts` + +**Implementation notes:** +- 给 `specContent` 和 `deltaSpecContent` 响应增加必填 `keywordColors`,包括 stale cache 和 fresh 两条响应;内容缓存格式保持 string,不缓存配置。 +- 每次处理内容请求时调用一次 `getSpecKeywordColors()`,让同一请求的 cached/fresh 消息使用相同快照;下一次请求重新读取设置。 +- 保持 scope 解析、路径 containment、错误消息和普通 `artifactContent` 完全不变。 + +**Verification:** +- Focused: `rtk pnpm test -- test/extension/providers/webviewMessageHandler.test.ts test/extension/providers/dashboardViewProvider.test.ts` +- Expected: 主/delta 的 cached 与 fresh 响应都包含同一安全映射;既有 store-scoped 内容断言继续通过。 + +**Risks / edge cases:** +- 现有精确对象断言会因新增必填字段失败,必须更新预期而不能放宽成无意义的断言。 +- 不得因配置异常跳过内容响应;helper 必须总能返回默认映射。 + +- [ ] **Step 1 (RED, 2–5 min):** 为主/delta cached 与 fresh 消息增加 `keywordColors` 断言,运行 focused 命令并确认字段缺失失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 扩展共享消息 union,并在两个 handler 分支复用一次配置快照。 +- [ ] **Step 3 (GREEN, 2–5 min):** 更新受影响的精确消息断言,保留 scope、cache metadata 和错误分支检查。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS,且普通 artifact 消息没有新增字段。 + diff --git a/openspec/changes/add-configurable-structured-spec-viewer/task-details/02-structure-and-disclosure.md b/openspec/changes/add-configurable-structured-spec-viewer/task-details/02-structure-and-disclosure.md new file mode 100644 index 0000000..8539296 --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/task-details/02-structure-and-disclosure.md @@ -0,0 +1,63 @@ +# Task 2. Spec 结构解析与折叠 + +<!-- covers: Task 2.1, Task 2.2 --> + +### Task 2.1: 实现 canonical Spec 分段与完整 Markdown 回退 + +**Spec coverage:** `artifact-viewing` / `Artifact Content Rendering` / `Render structured main spec`, `Render structured delta spec`, `Fall back for a non-canonical spec` + +**Dependencies / order:** 可在 Task 1 后独立实现;Task 2.2、Task 4 均依赖分段结果。 + +**Files:** +- Create: `src/webview/components/SpecDocumentRenderer.tsx`, `test/webview/components/specDocumentRenderer.test.tsx` +- Modify: None +- Test: `test/webview/components/specDocumentRenderer.test.tsx` + +**Implementation notes:** +- 在组件文件内导出纯 `parseSpecDocument(content)`;只在 fenced code block 外识别 `### Requirement:` 与属于当前 Requirement 的 `#### Scenario:`。 +- 保留前置/中间 Markdown、完整规范正文和 Scenario 明细原文;delta operation 标题属于普通 Markdown segment。 +- 无 Requirement、孤立 Scenario、未闭合边界或异常一律返回失败结果,由 renderer 把原始全文交给 `MarkdownRenderer`;不要输出部分解析结果。 + +**Verification:** +- Focused: `rtk pnpm test -- test/webview/components/specDocumentRenderer.test.tsx` +- Expected: canonical main/delta fixture 分段稳定;代码围栏内伪 heading 不分段;非 canonical fixture 的回退 HTML 含全部原文。 + +**Risks / edge cases:** +- CRLF 与 LF 必须产生相同段落;空正文和多个 Scenario 不能吞掉相邻内容。 +- 标题识别必须锚定完整行,不能把普通正文中的 `### Requirement:` 当边界。 + +- [ ] **Step 1 (RED, 2–5 min):** 写 main、delta、代码围栏、孤立 Scenario 和无 Requirement 的纯函数测试,运行 focused 命令确认导出缺失失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 实现带 fence 状态的单次行扫描和最小 segment 类型。 +- [ ] **Step 3 (GREEN, 2–5 min):** 在解析失败分支直接复用完整 `MarkdownRenderer`,不添加纠错启发式。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS,逐字断言回退内容未丢失。 + +--- + +### Task 2.2: 渲染默认展开的 Requirement 与默认收起的 Scenario + +**Spec coverage:** `artifact-viewing` / `Artifact Content Rendering` / `Render specs`, `Render structured main spec`, `Render structured delta spec`, `Restore disclosure defaults` + +**Dependencies / order:** 依赖 Task 2.1;完成后 Task 4 才可接入两个入口。 + +**Files:** +- Create: None +- Modify: `src/webview/components/SpecDocumentRenderer.tsx` +- Test: `test/webview/components/specDocumentRenderer.test.tsx` + +**Implementation notes:** +- Requirement 使用 `<details className="spec-requirement" open>`,Scenario 使用嵌套且无 `open` 的 `<details className="spec-scenario">`;summary 显示解析标题。 +- 普通 segment、Requirement body、Scenario body 均复用 `MarkdownRenderer`,并把同一 `keywordColors` 向下传递。 +- 不创建 disclosure React state;组件重新挂载或内容 key 变化后由原生默认属性恢复状态。 + +**Verification:** +- Focused: `rtk pnpm test -- test/webview/components/specDocumentRenderer.test.tsx` +- Expected: server-rendered HTML 中 Requirement 带 `open`、Scenario 不带 `open`,规范正文可见,delta operation heading 保留。 + +**Risks / edge cases:** +- summary 内不得嵌套块级 heading;标题作为文本/最小 inline 内容呈现。 +- Requirement 没有 Scenario 时仍显示完整 body;Scenario 没有 body 时仍保留可操作 summary。 + +- [ ] **Step 1 (RED, 2–5 min):** 增加 disclosure 默认属性、嵌套顺序、正文保留和重新渲染默认态断言,确认当前占位 renderer 失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 用原生 details/summary 渲染 segment,不引入 accordion 或本地开关状态。 +- [ ] **Step 3 (GREEN, 2–5 min):** 把普通/body/scenario Markdown 和颜色表接到既有 renderer。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS;人工检查生成 HTML 中只有 Requirement 默认 open。 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/task-details/03-keyword-highlighting.md b/openspec/changes/add-configurable-structured-spec-viewer/task-details/03-keyword-highlighting.md new file mode 100644 index 0000000..0c2da4f --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/task-details/03-keyword-highlighting.md @@ -0,0 +1,64 @@ +# Task 3. 安全关键字上色 + +<!-- covers: Task 3.1, Task 3.2 --> + +### Task 3.1: 为 Markdown 文本节点实现完整单词关键字上色 + +**Spec coverage:** `artifact-viewing` / `Configurable Spec keyword highlighting` / `Apply default semantic colors`, `Add and override configured keywords` + +**Dependencies / order:** 依赖 Task 1.1 的安全 CSS 映射;可与 Task 2 并行但必须先于 Task 4。 + +**Files:** +- Create: `test/webview/components/markdownRenderer.test.tsx` +- Modify: `src/webview/components/MarkdownRenderer.tsx` +- Test: `test/webview/components/markdownRenderer.test.tsx` + +**Implementation notes:** +- 增加可选 `keywordColors: Record<string, string>` prop,并导出纯 matcher 构建/文本切片 helper 供 node 环境测试。 +- 对 key 做正则转义并按长度降序生成一次大写完整单词 matcher;`MUSTARD`、`WHENEVER` 等不得部分命中。 +- 在现有 HTML 注入后用一个 effect 和一次 `TreeWalker` 替换文本命中;span 只设置 Host 已归一化的 `style.color` 与稳定 class/data attribute。 + +**Verification:** +- Focused: `rtk pnpm test -- test/webview/components/markdownRenderer.test.tsx` +- Expected: 默认 key、custom key、built-in override 和 partial-word fixture 的纯 helper 断言全部通过;无颜色表时输出保持既有 HTML。 + +**Risks / edge cases:** +- effect 不能重复包裹已生成 span;内容/映射变化时应依赖 React 重建原始 innerHTML。 +- 空映射或空内容必须直接返回,避免创建无意义 TreeWalker/regex。 + +- [ ] **Step 1 (RED, 2–5 min):** 写完整单词、重叠长度、自定义 key、覆盖颜色和空映射测试,运行 focused 命令确认 helper/prop 缺失失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 实现最小 matcher 与文本切片 helper,使纯逻辑测试通过。 +- [ ] **Step 3 (GREEN, 2–5 min):** 在 `MarkdownRenderer` 增加一次文本节点遍历并生成安全 span。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS,同时运行 `rtk pnpm test -- test/webview/components/artifactViewer.test.tsx` 确认无 prop 路径无回归。 + +--- + +### Task 3.2: 覆盖自定义颜色、Markdown 强调与非语义节点跳过行为 + +**Spec coverage:** `artifact-viewing` / `Configurable Spec keyword highlighting` / `Apply default semantic colors`, `Ignore invalid keyword configuration safely`, `Skip non-semantic text contexts` + +**Dependencies / order:** 依赖 Task 3.1;完成后实际 DOM 行为由 Task 5.2 在 Extension Host 补充验收。 + +**Files:** +- Create: None +- Modify: `src/webview/components/MarkdownRenderer.tsx` +- Test: `test/webview/components/markdownRenderer.test.tsx` + +**Implementation notes:** +- TreeWalker filter 跳过 `CODE`、`PRE`、`A`、`.mermaid` 祖先和已高亮 span;普通 `STRONG`/`EM` 内文本仍允许命中,以保留外层强调。 +- 高亮 effect 排在 innerHTML 落地之后并跳过 `.mermaid` 原始节点,不能改写 Mermaid source;Mermaid 仍由既有 effect 负责。 +- 用可在 node 测试的纯祖先分类 helper 验证 skip/allow 矩阵;实际 TreeWalker 在 Task 5.2 的真实 Webview 验收。 + +**Verification:** +- Focused: `rtk pnpm test -- test/webview/components/markdownRenderer.test.tsx` +- Expected: code/pre/link/Mermaid 返回 skip,strong/em/普通 prose 返回 allow;自定义 hex/theme CSS 值原样应用到命中片段模型。 + +**Risks / edge cases:** +- anchor 内嵌 strong 仍必须整体 skip;判断需要沿祖先向上走到 renderer root。 +- Mermaid 异步替换失败时也不能被关键字 effect 污染,保持现有警告与原文行为。 + +- [ ] **Step 1 (RED, 2–5 min):** 增加 skip ancestor、strong 保留、已高亮节点和 Mermaid fixture 测试,确认当前遍历规则失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 实现最小祖先分类与重复包装 guard,使 skip/allow 矩阵通过。 +- [ ] **Step 3 (GREEN, 2–5 min):** 调整 effect 依赖为 `html` 与 `keywordColors` 的稳定内容,避免 stale color 或重复包裹。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS;记录真实 DOM/主题检查留给 Task 5.2,不添加 DOM 测试依赖。 + diff --git a/openspec/changes/add-configurable-structured-spec-viewer/task-details/04-spec-integration.md b/openspec/changes/add-configurable-structured-spec-viewer/task-details/04-spec-integration.md new file mode 100644 index 0000000..63b9fcd --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/task-details/04-spec-integration.md @@ -0,0 +1,63 @@ +# Task 4. 主 Spec 与 delta spec 集成 + +<!-- covers: Task 4.1, Task 4.2 --> + +### Task 4.1: 将共享结构化 renderer 接入主 Spec 预览 + +**Spec coverage:** `artifact-viewing` / `Artifact Content Rendering` / `Render specs`, `Render structured main spec`, `Fall back for a non-canonical spec`; `Configurable Spec keyword highlighting` / `Apply configuration on the next content load` + +**Dependencies / order:** 依赖 Task 1.2、Task 2 和 Task 3;先完成主 Spec 再复用到 delta 路径。 + +**Files:** +- Create: `test/webview/components/specViewer.test.tsx` +- Modify: `src/webview/components/SpecViewer.tsx` +- Test: `test/webview/components/specViewer.test.tsx`, `test/webview/app.test.tsx` + +**Implementation notes:** +- `SpecViewer` 把 `content` 与 `keywordColors` 作为同一 `specContent` 快照更新,并改用 `SpecDocumentRenderer`。 +- 为 `initialContent` 保持现有兼容;测试可增加可选 `initialKeywordColors`,默认空映射,不虚构额外全局状态。 +- `specContentError`、loading、标题和 scope 请求行为不在本任务改变。 + +**Verification:** +- Focused: `rtk pnpm test -- test/webview/components/specViewer.test.tsx test/webview/app.test.tsx` +- Expected: canonical initial content 输出 Requirement details;非 canonical 内容完整回退;消息类型仍路由到 Spec 页面。 + +**Risks / edge cases:** +- 新 specId 或新消息必须同时替换内容和颜色,不能短暂复用上一 Spec 的映射。 +- cached/fresh 连续消息可重新渲染两次,但 disclosure 默认状态以最后 fresh 内容为准。 + +- [ ] **Step 1 (RED, 2–5 min):** 写 canonical、fallback 和颜色快照的 SpecViewer 测试,运行 focused 命令确认仍使用普通 Markdown 失败。 +- [ ] **Step 2 (GREEN, 2–5 min):** 用最小 state 形状保存内容/颜色并替换成共享 renderer。 +- [ ] **Step 3 (GREEN, 2–5 min):** 保留 loading 与 initialContent 行为,处理缺失颜色字段仅限测试/兼容入口的空映射。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS,检查主 Spec 的普通 Purpose 仍存在。 + +--- + +### Task 4.2: 将共享 renderer 接入 delta spec 并完成主题化与可访问样式 + +**Spec coverage:** `artifact-viewing` / `Artifact Content Rendering` / `Render proposal`, `Render structured delta spec`, `Restore disclosure defaults`, `Render design`, `Render tasks` + +**Dependencies / order:** 依赖 Task 4.1;完成后进入完整回归与真实界面验收。 + +**Files:** +- Create: None +- Modify: `src/webview/components/ArtifactViewer.tsx`, `src/webview/components/ChangeDetail.tsx`, `src/webview/index.css` +- Test: `test/webview/components/artifactViewer.test.tsx` + +**Implementation notes:** +- `ChangeDetail` 将 delta 内容和颜色表一起缓存/更新;`ArtifactViewer` 仅当所选 output 的 `kind === 'specs'` 时使用 `SpecDocumentRenderer`,普通 Markdown/任务路径保持原 renderer。 +- 用 `--vscode-panel-border`、`--vscode-foreground`、`--vscode-descriptionForeground` 和现有 focus token 完成层级线、summary、间距、wrap 与 `:focus-visible`;不写截图中的固定色值。 +- 原生 `<summary>` 提供 pointer、Enter/Space 与语义;不要添加只依赖颜色的展开状态提示。 + +**Verification:** +- Focused: `rtk pnpm test -- test/webview/components/artifactViewer.test.tsx test/webview/components/specDocumentRenderer.test.tsx` +- Expected: `kind=specs` 输出嵌套 details,`kind=markdown` 仍为普通 artifact HTML;Requirement 默认 open、Scenario 默认 closed。 + +**Risks / edge cases:** +- 多 output 切换时必须按 `selectedOutputPath` 判断当前 kind,不能因 artifact group 名称误判。 +- 窄 sidebar 和编辑器宽面板都必须换行;summary focus ring 不能被 overflow 或自定义 marker 隐藏。 + +- [ ] **Step 1 (RED, 2–5 min):** 增加 specs/markdown output 分流和颜色 prop 测试,运行 focused 命令确认 specs 仍走普通 renderer。 +- [ ] **Step 2 (GREEN, 2–5 min):** 在 ChangeDetail/ArtifactViewer 传递最小 spec 快照并按当前 output kind 分流。 +- [ ] **Step 3 (GREEN, 2–5 min):** 加入主题化 disclosure CSS、窄宽度换行和 focus-visible 样式。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 重跑 focused 命令并确认全部 PASS;静态 HTML 中普通 proposal/design/tasks 路径保持不变。 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/task-details/05-verification.md b/openspec/changes/add-configurable-structured-spec-viewer/task-details/05-verification.md new file mode 100644 index 0000000..1aecf39 --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/task-details/05-verification.md @@ -0,0 +1,66 @@ +# Task 5. 回归与真实界面验收 + +<!-- covers: Task 5.1, Task 5.2 --> + +### Task 5.1: 通过 focused tests、完整单测、构建与源代码 lint + +**Spec coverage:** `artifact-viewing` / `Artifact Content Rendering` 和 `Configurable Spec keyword highlighting` 的全部场景回归。 + +**Dependencies / order:** 依赖 Tasks 1–4 全部完成;任何失败先回到拥有该逻辑的任务做最小修复,再从 focused gate 重跑。 + +**Files:** +- Create: None +- Modify: None;若 gate 暴露缺陷,只修改 Tasks 1–4 已列出的所属源文件或测试文件 +- Test: `test/extension/services/specKeywordColors.test.ts`, `test/extension/packageConfiguration.test.ts`, `test/extension/providers/webviewMessageHandler.test.ts`, `test/webview/components/specDocumentRenderer.test.tsx`, `test/webview/components/markdownRenderer.test.tsx`, `test/webview/components/specViewer.test.tsx`, `test/webview/components/artifactViewer.test.tsx` + +**Implementation notes:** +- 本任务不新增功能;RED/GREEN 已由 Tasks 1–4 完成,只收集当前 checkout 的新鲜门禁证据。 +- ESLint 仅运行 `src/`;若只出现 AGENTS.md 已记录的 pre-existing global `no-undef`,必须明确区分,但本次改动引入的 lint 错误仍须修复。 + +**Verification:** +- Focused: `rtk pnpm test -- test/extension/services/specKeywordColors.test.ts test/extension/packageConfiguration.test.ts test/extension/providers/webviewMessageHandler.test.ts test/webview/components/specDocumentRenderer.test.tsx test/webview/components/markdownRenderer.test.tsx test/webview/components/specViewer.test.tsx test/webview/components/artifactViewer.test.tsx` +- Full: `rtk pnpm test` +- Build: `rtk pnpm run build` +- Lint: `rtk pnpm exec eslint src/` +- Expected: focused/full/build exit 0;lint 对本 change 的源文件无新增 error。 + +**Risks / edge cases:** +- 不能复用任务过程中的旧输出;每条 gate 都需在最终 diff 上重跑。 +- build 成功不替代交互验收;lint 的既有配置噪音也不能掩盖新增问题。 + +- [ ] **Step 1 (VERIFY, 2–5 min):** 在最终 diff 上运行 focused gate,确认全部 PASS。 +- [ ] **Step 2 (VERIFY, 2–5 min):** 运行完整 `rtk pnpm test`,确认无跨模块回归。 +- [ ] **Step 3 (VERIFY, 2–5 min):** 运行 `rtk pnpm run build`,确认 extension 与 webview 均成功产出。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 运行 `rtk pnpm exec eslint src/`,修复本 change 新增错误并记录任何可复现的既有噪音。 + +--- + +### Task 5.2: 在 Extension Development Host 中完成主 Spec 与 delta spec 视觉交互验收 + +**Spec coverage:** `artifact-viewing` / `Artifact Content Rendering` / `Render structured main spec`, `Render structured delta spec`, `Restore disclosure defaults`, `Fall back for a non-canonical spec`; `Configurable Spec keyword highlighting` 的全部场景。 + +**Dependencies / order:** 最终任务;依赖 Task 5.1 的测试、构建和 lint 门禁完成。 + +**Files:** +- Create: None +- Modify: None;真实验收发现缺陷时回到对应 Tasks 1–4 的源文件并重跑 Task 5.1 +- Test: 主 Spec `openspec/specs/artifact-viewing/spec.md`、delta spec `openspec/changes/add-configurable-structured-spec-viewer/specs/artifact-viewing/spec.md` + +**Implementation notes:** +- 使用用户提供的深色高保真参考作为层级基线:规范正文立即可见,Scenario 明细默认隐藏;实际颜色和 focus 必须跟随 VS Code theme。 +- 自定义验收值使用 `WHEN: #C586C0`、`MAY: vscode:editorWarning.foreground`;验证修改设置后当前视图不热更新,刷新/重开后生效。 +- 验收必须同时覆盖主 Spec、delta spec、键盘操作、内联代码/链接不着色,以及切回 proposal/design/tasks 无视觉回归。 + +**Verification:** +- Launch: `rtk code --extensionDevelopmentPath=/Users/randy/workspace/projects/github/openspec-ext /Users/randy/workspace/projects/github/openspec-ext` +- Expected: Extension Development Host 中两类 Spec 满足默认折叠与颜色契约,控制台无新增 React/Mermaid/error 日志,普通 artifacts 保持原渲染。 + +**Risks / edge cases:** +- 仅看静态截图不足以证明键盘、刷新和 theme token;必须实际操作 summary 与 Settings。 +- 若启动命令复用已有 VS Code 窗口,应明确进入 Extension Development Host,避免误测已安装版本。 + +- [ ] **Step 1 (VERIFY, 2–5 min):** 启动 Extension Development Host,打开 `artifact-viewing` 主 Spec,核对 Requirement open、Scenario closed 与正文层级。 +- [ ] **Step 2 (VERIFY, 2–5 min):** 用 Tab 聚焦 summary 并用 Enter/Space 展开、收起,刷新后确认默认态恢复。 +- [ ] **Step 3 (VERIFY, 2–5 min):** 打开本 change 的 delta spec,核对与主 Spec 相同结构及 delta operation heading 保留。 +- [ ] **Step 4 (VERIFY, 2–5 min):** 设置自定义 WHEN/MAY 颜色,验证当前视图不热更新而刷新/重开生效,非法项不破坏默认色。 +- [ ] **Step 5 (VERIFY, 2–5 min):** 检查完整单词、加粗、inline code、link、Mermaid/代码块和普通 artifact 回归,并确认 Webview 控制台无新增错误。 diff --git a/openspec/changes/add-configurable-structured-spec-viewer/tasks.md b/openspec/changes/add-configurable-structured-spec-viewer/tasks.md new file mode 100644 index 0000000..0520782 --- /dev/null +++ b/openspec/changes/add-configurable-structured-spec-viewer/tasks.md @@ -0,0 +1,31 @@ +<!-- Implementation details are in openspec/changes/add-configurable-structured-spec-viewer/task-details/ (generated by aihelp-writing-task skill). Each ## Task N group references one task group file; Task N.M ids correspond 1-to-1 with ### Task blocks across files. --> + +## Task 1. 配置与消息契约 +<!-- details: task-details/01-config-and-messages.md --> + +- [ ] Task 1.1 声明关键字颜色设置并实现带默认值的安全归一化 +- [ ] Task 1.2 在主 Spec 与 delta spec 内容消息中传递生效颜色映射 + +## Task 2. Spec 结构解析与折叠 +<!-- details: task-details/02-structure-and-disclosure.md --> + +- [ ] Task 2.1 实现 canonical Spec 分段与完整 Markdown 回退 +- [ ] Task 2.2 渲染默认展开的 Requirement 与默认收起的 Scenario + +## Task 3. 安全关键字上色 +<!-- details: task-details/03-keyword-highlighting.md --> + +- [ ] Task 3.1 为 Markdown 文本节点实现完整单词关键字上色 +- [ ] Task 3.2 覆盖自定义颜色、Markdown 强调与非语义节点跳过行为 + +## Task 4. 主 Spec 与 delta spec 集成 +<!-- details: task-details/04-spec-integration.md --> + +- [ ] Task 4.1 将共享结构化 renderer 接入主 Spec 预览 +- [ ] Task 4.2 将共享 renderer 接入 delta spec 并完成主题化与可访问样式 + +## Task 5. 回归与真实界面验收 +<!-- details: task-details/05-verification.md --> + +- [ ] Task 5.1 通过 focused tests、完整单测、构建与源代码 lint +- [ ] Task 5.2 在 Extension Development Host 中完成主 Spec 与 delta spec 视觉交互验收 diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/.openspec.yaml b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/.openspec.yaml new file mode 100644 index 0000000..61502f4 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: aihelp-dev +created: 2026-09-01 diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-create.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-create.jpg new file mode 100644 index 0000000..0cc8ce4 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-create.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-detail.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-detail.jpg new file mode 100644 index 0000000..2db00c2 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-detail.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-list.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-list.jpg new file mode 100644 index 0000000..6edf96a Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-dark-list.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-create.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-create.jpg new file mode 100644 index 0000000..3c7b051 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-create.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-detail.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-detail.jpg new file mode 100644 index 0000000..36065b5 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-detail.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-list.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-list.jpg new file mode 100644 index 0000000..ea360ad Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-hc-list.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-create.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-create.jpg new file mode 100644 index 0000000..81000f1 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-create.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-detail.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-detail.jpg new file mode 100644 index 0000000..9237e89 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-detail.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-list.jpg b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-list.jpg new file mode 100644 index 0000000..45c61d5 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/real-host-light-list.jpg differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/workset-create-high-fidelity.png b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/workset-create-high-fidelity.png new file mode 100644 index 0000000..5154893 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/workset-create-high-fidelity.png differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/worksets-list-detail-high-fidelity.png b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/worksets-list-detail-high-fidelity.png new file mode 100644 index 0000000..603db20 Binary files /dev/null and b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/assets/worksets-list-detail-high-fidelity.png differ diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/design.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/design.md new file mode 100644 index 0000000..61fc6a2 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/design.md @@ -0,0 +1,254 @@ +## Context + +The selected product direction and rejected alternatives are recorded in [explore.md](explore.md). The proposal defines the MVP and the four capability deltas under [specs/](specs/). + +The change crosses Webview state, Webview/Host messages, native VS Code folder selection, OpenSpec CLI execution, Project binding, and Store-member validation. It therefore needs one explicit flow across the existing modules rather than an isolated component-only change. + +Current reusable structure: + +- `Dashboard.tsx` selects the Project-first `changes | specs | worksets` view and receives binding-scoped snapshots. +- `WorksetProjectPicker.tsx` already renders trusted Project/Store member roles and sends Project-switch/open actions. +- `ProjectDataGateway` already obtains selector-free Workset/Store inventories, canonicalizes paths, fail-closes when Store identity is unavailable, and resolves bindings with an optional explicit Store id. +- `dashboardViewProvider` owns current Project identity, accepted Project binding, refresh generations, and binding-safe snapshot publication. +- `DataManager` and `OpenSpecCliService` already distinguish ordinary CLI output from JSON output. + +### Approved visual targets + +The following images are implementation inputs, not inspirational moodboards. List and detail are two states of the same narrow Sidebar flow. + +![Approved Worksets list and detail](assets/worksets-list-detail-high-fidelity.png) + +![Approved Create Workset](assets/workset-create-high-fidelity.png) + +The VS Code Activity Bar and outer window chrome in the images belong to the host. The extension implements only its existing Webview area. Existing Header content may replace a duplicated Project/Planning-root card, but the same hierarchy and semantic separation must remain visible. + +## Goals / Non-Goals + +**Goals:** + +- Implement list, detail, and create as focused Project-first Sidebar states. +- Use official CLI commands for every Workset read/write/open operation. +- Keep Current Project identity and Planning root independently visible and safely switchable. +- Revalidate Project and Store members before accepting Webview navigation hints. +- Preserve ordinary Workset-open output and support one-time opener override. +- Match the approved images at the current narrow Sidebar width using VS Code theme tokens, existing typography, and accessible controls. + +**Non-Goals:** + +- Editing an existing Workset's members or saved tool. +- Reading or mutating private OpenSpec Workset registry files. +- Git clone, pull, push, or sync. +- A full editor-area Worksets workspace. +- A new router, state library, design system, or runtime dependency. +- Automatic activation when no existing OpenSpec View activation condition is met. + +## Decisions + +### 1. Keep Worksets navigation as local component state + +`WorksetProjectPicker` will coordinate three local states: + +```text +WorksetsViewState + +-- list + +-- detail(name) + +-- create(draft) +``` + +No route or persisted selection is added. Switching away from the Worksets tab or changing Project identity resets to `list`. A normal snapshot refresh preserves `detail(name)` only when the named Workset still exists and preserves `create(draft)` while the current Project identity is unchanged. + +Alternative considered: storing the state in global `AppContext`. Rejected because no other surface consumes the navigation state and persistence would create stale-name/draft recovery work without user value. + +### 2. Reuse trusted navigation data for list and detail + +The list and detail both render from `ProjectSidebarData.worksetNavigation`, not the broader legacy `DashboardData.worksets` inventory. This retains the existing guarantee that every displayed Workset contains the current canonical Project and every member role comes from fresh Workset plus Store inventories. + +```text +OpenSpec CLI + workset list --json store list --json + \ / + \ / + ProjectDataGateway + - realpath members + - classify project/store + - filter by current Project + | + v + ProjectWorksetNavigationData + | + list / detail UI +``` + +The legacy `WorksetsPage` remains a machine-level management surface. It may reuse row styling, but it does not gain Project or Planning-root actions without a Project-bound navigation payload. + +Alternative considered: classify members again inside React from `storeRootPaths`. Rejected because role classification is a trust boundary already implemented in the Host. + +### 3. Preserve the high-fidelity hierarchy without duplicating the Header + +Visual implementation rules: + +- The list row is one grouped surface with lightweight separators, not one card per row. +- Row body enters detail; the inline `Open` control is independently focusable and stops event propagation. +- Detail uses one grouped member surface. Project and Store identity use both label and icon; color alone is insufficient. +- `Current` and `Current root` are states, not disabled buttons. +- Create is one scrollable form; it is not a wizard or modal. +- Primary and destructive actions use existing VS Code button/error tokens. +- Focus uses `--vscode-focusBorder`; all icon-only controls have tooltip and `aria-label`. +- At approximately 430 px Sidebar width, names truncate before action labels overlap. No horizontal scrolling is introduced. +- Motion is limited to 120–160 ms color/opacity transitions and honors `prefers-reduced-motion`. + +The implementation does not copy hard-coded colors, gradients, typography, or outer chrome from the PNGs. It maps their hierarchy to existing VS Code variables so light, dark, and high-contrast themes remain usable. + +### 4. Add the minimum Webview/Host messages + +```text +Webview -> Host + pickWorksetMembers + createWorkset { name, members, tool? } + openWorkset { name, tool? } (extends existing) + selectWorksetStore { worksetName, memberPath } + selectProjectDefaultRoot + +Host -> Webview + worksetMembersPicked { paths } + worksetCreateResult { success, name, message? } + setContext { view: sidebar, data } (existing snapshot path) + error { message } (existing failure path) +``` + +Folder-picker responses need no new request registry because only one Create form can exist. The Webview ignores `worksetMembersPicked` unless the current state is `create`; leaving the form makes a late response harmless. + +Message payloads are untrusted. The Host checks primitive types, trims names/tool ids, canonicalizes paths, and delegates official Workset validation to the CLI or fresh inventory reads. + +Alternative considered: running the complete creation interaction through VS Code QuickPick/InputBox. Rejected because it would not match the approved single-screen form or keep validation feedback adjacent to the fields. + +### 5. Create through the official selector-free JSON command + +The create sequence is: + +```text +Create form + | + | pickWorksetMembers + v +vscode.window.showOpenDialog(canSelectFolders, canSelectMany) + | + | worksetMembersPicked + v +Draft: name + ordered unique canonical members + optional tool + | + | createWorkset + v +DataManager.createWorkset + | + | runJson([ + | 'workset', 'create', name, + | '--member', primary, + | '--member', other, + | '--tool', tool?, '--json' + | ]) + v +reload Project Sidebar snapshot -> worksetCreateResult -> detail(new name) +``` + +`DataManager.createWorkset()` builds the repeated arguments directly and never calls a shell. Primary is represented only by array order; no parallel Primary field is persisted. The current Project is required in the Project-first form but may appear after another selected Primary in the submitted list. + +No `--store` flag is appended. Workset creation is machine-global even when the current Project binding resolves to a Store. + +On success, `dashboardViewProvider` reloads the Project Sidebar through official data sources. The Webview enters detail only after the refreshed navigation contains the created name. On failure, the draft remains and the result carries a recoverable message. + +### 6. Extend open with a one-time tool only + +`openWorkset(name, tool?)` remains an ordinary command path: + +```text +tool absent -> runCommand(['workset', 'open', name]) +tool present -> runCommand(['workset', 'open', name, '--tool', tool]) +``` + +It never requests JSON and never modifies the saved `WorksetView.tool`. The detail shows the saved tool as information. `Open with another tool` reveals an editable combobox with `code` and `cursor` shortcuts and allows a configured custom opener id because OpenSpec does not expose an opener-enumeration command. + +Alternative considered: parsing the global OpenSpec `openers` configuration. Rejected because it would duplicate private configuration semantics and create an unsupported write/read dependency. + +### 7. Treat Workset Store selection as a Project-binding operation + +The Workset detail action does not call the legacy `DataManager.selectScope()` path. It is a distinct Project-first operation: + +```text +Use as planning root(worksetName, memberPath) + | + v +ProjectDataGateway.resolveWorksetStore(...) + - re-read workset list + - re-read store list + - canonicalize memberPath + - require named Workset + Store role + - return validated storeId + | + v +ProjectDataGateway.resolveBinding(currentProject, storeId) + - CLI context --store <storeId> + - verify projectId / commandCwd / root / storeId + | + v +dashboardViewProvider accepts new binding + - explicitProjectStoreId = storeId + - current Project unchanged + - reload Project Sidebar for that binding +``` + +`ProjectDataGateway.loadProjectSidebarData(project, explicitStoreId?)` and its binding-scoped readers are extended to accept the validated selector. `dashboardViewProvider` owns an ephemeral `explicitProjectStoreId`; it passes that value when refreshing or switching among members of the same trusted Workset. The selector never leaks into Workset list/create/open/remove commands. + +`selectProjectDefaultRoot` clears the ephemeral selector only after a fresh selector-free binding for the same Project resolves and validates successfully. This provides an explicit escape from a selected Store. + +If membership or binding validation fails, the previous Project, binding, watcher, explicit selector, and visible snapshot remain untouched. + +Alternative considered: converting the Store id to `store:<id>` in the Webview and sending the existing `selectScope` message. Rejected because it trusts Webview topology, updates the legacy scope rather than the Project-first binding, and cannot guarantee that Project-bound data follows the displayed Planning root. + +### 8. Preserve refresh and race safety + +Existing `projectRequestGeneration` remains the authority for rejecting stale snapshot loads. Store selection, Project selection, and create-success refresh all increment that generation through the existing reload path. + +- A detail name missing from the accepted snapshot resets the local view to list. +- A Project identity change resets list/detail/create state. +- A binding-only change keeps the selected Workset detail when it still exists. +- A failed mutation or binding resolution does not publish optimistic data. + +### 9. Keep component extraction demand-driven + +The first implementation changes `WorksetProjectPicker.tsx` in place and reuses small pure helpers for selected Workset lookup, member deduplication, Primary ordering, and view-state reconciliation. `WorksetListView`, `WorksetDetailView`, or `WorksetCreateForm` are extracted only if the resulting file becomes difficult to test or review. + +No dependency or generic Workset framework is added. + +## Risks / Trade-offs + +- **[CLI beta shape changes]** → Keep capability gating, exact argv tests, defensive JSON parsing, and no private-file fallback. +- **[Configured opener ids cannot be enumerated]** → Use an editable combobox and let the official CLI return the actionable validation error. +- **[High-fidelity images show one dark theme and outer VS Code chrome]** → Treat hierarchy and spacing as the contract; use theme tokens and verify dark, light, and high-contrast states in the real Extension Host. +- **[Explicit Store selection could diverge from current Project data]** → Re-resolve one binding for the same Project with the validated Store id and publish only a binding-matching snapshot. +- **[Late folder-picker or refresh response could overwrite another state]** → Gate picker responses on Create state and retain generation-based snapshot rejection. +- **[Workset removal/edit outside the extension invalidates detail]** → Reconcile detail name against every accepted snapshot and return to list when absent. +- **[Single component may grow]** → Extract state views only when tests demonstrate that in-file boundaries are no longer clear. + +## Migration Plan + +No stored-data migration is required. Existing Workset files remain owned by OpenSpec. + +1. Add failing tests for CLI argv, Host validation, message contracts, and view transitions. +2. Implement list/detail using existing navigation data without changing mutation commands. +3. Add creation and folder-picker messages, then one-time open override. +4. Add validated Store-binding selection and `Use project default` recovery. +5. Add i18n/accessibility states and visually compare real Sidebar captures with both approved images. +6. Run focused tests, full tests, build, lint scope, and Extension Development Host acceptance. + +Rollback removes the new messages and local states while leaving existing saved Worksets untouched. The old fully expanded Project picker remains recoverable from version control; no registry conversion is needed. + +## Open Questions + +None. The optional custom opener id intentionally remains free-form because the CLI has no official opener inventory endpoint. + +## Spec Amendments + +- [x] `openspec-scope-management`: clarified that a Workset Planning Store re-resolves and replaces the binding for the same Project instead of mutating the legacy selected scope. +- [x] `workset-project-navigation`: added an explicit `Use project default` recovery path when a Workset Store is the current Planning root. +- [x] `workset-cli-open`: retained the management-page open and action-label scenarios because a MODIFIED requirement replaces the complete existing block. diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/explore.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/explore.md new file mode 100644 index 0000000..c7c6dae --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/explore.md @@ -0,0 +1,94 @@ +<!-- Exploration output for this change — input for proposal, not the contract. --> + +## Clarified requirements and constraints + +### Problem + +The extension already lists, opens, removes, and navigates project members from OpenSpec Worksets, but the Project-first Sidebar expands every Workset and its members at once. Users cannot scan Worksets first and inspect one topology second. The extension also lacks UI for `workset create` and the one-time `workset open --tool` override supported by OpenSpec 1.8.0. + +### Required outcomes + +- Present Worksets as a list that drills into a detail view. +- Keep Create Workset as a third state reached from the list, not as a competing workspace. +- Make Current Project and Planning root visibly distinct. +- Let users switch to an eligible Project member and use a registered Store member as Planning root only after Host-side revalidation. +- Support selector-free Workset creation and one-time tool override through official CLI commands. +- Use the existing React, Tailwind, VS Code theme-token, Webview messaging, and `ProjectDataGateway` patterns. +- Preserve keyboard access, focus states, i18n, capability gating, and narrow-sidebar behavior. +- Treat the approved high-fidelity images as the visual implementation baseline: + - [Worksets list and detail](assets/worksets-list-detail-high-fidelity.png) + - [Create Workset](assets/workset-create-high-fidelity.png) + +### Boundaries + +- Workset remains machine-local multi-folder state; Store remains the OpenSpec Planning root. +- Opening a Workset does not switch Planning root. +- All Workset CLI commands remain selector-free, including when the active Planning root is a Store. +- No Git clone, pull, push, or synchronization controls. +- No direct reads or writes of OpenSpec private Workset registry files. +- No member editing until OpenSpec provides an official update command. +- No synthetic `Reference` Workset member role; current roles remain `project | store`. +- No router, new state library, or generalized component framework. +- No automatic extension activation outside the existing explicit View entrypoint. + +## Agreed design direction + +### Approaches considered + +| Approach | Benefits | Costs | Decision | +|---|---|---|---| +| Keep the current fully expanded Workset/member list | Smallest code change | Poor scanning, weak object hierarchy, creation and detail actions remain crowded | Rejected | +| List → detail drill-down, with Create as a third local state | Fits the narrow Sidebar, matches existing navigation, keeps each state focused, needs no router | Requires explicit local view state and a small message extension | Selected | +| Open a full editor-area Worksets workspace | More horizontal room and richer future management | Duplicates Sidebar navigation, creates a new surface, and over-scopes the MVP | Rejected | + +### Final interaction model + +```text +Worksets tab + | + v ++-----------+ select row +----------------+ +| List | --------------------> | Detail | +| | <-------------------- | | ++-----------+ back +----------------+ + | + | Create Workset + v ++-----------+ save success +| Create | --------------------> Detail(new name) ++-----------+ +``` + +The list shows only Worksets that contain the current Project, using the existing Project-first navigation data. Clicking the row opens details; the row-level `Open` action launches immediately without navigating. + +The detail view shows the saved tool, `Open all`, one-time tool override, members, and destructive removal. Project and Store actions are derived from trusted roles. Current Project and current Planning Store display state instead of disabled actions. + +The Create view is one compact form. Current Project is included and cannot be removed in this Project-first flow. The user can add folders, choose which selected member is Primary, and optionally enter an opener id. The Primary path is sent as the first repeated `--member` argument. Successful creation refreshes Project Sidebar data and opens the new detail view. + +## Key decisions + +### Reuse before expansion + +- Keep the existing `Worksets` tab and Project-first data load. +- Evolve `WorksetProjectPicker` into the local list/detail/create coordinator before extracting more components. +- Reuse `selectWorksetProject`, `removeWorkset`, capability diagnostics, Store classification, path canonicalization, and VS Code confirmation patterns. +- Add only the messages required for folder picking, creation, one-time tool override, and trusted Store-member selection. + +### Trust boundary + +Webview-provided names, tool ids, and paths are hints. Host-side code validates their types, then relies on the official CLI and freshly read Workset/Store inventories. `selectWorksetStore(worksetName, memberPath)` mirrors the existing Project-member revalidation rather than accepting a Webview-supplied scope id. + +### CLI behavior + +- Create uses `openspec workset create <name> --member <primary> --member <other>... [--tool <id>] --json`. +- Normal open uses `openspec workset open <name>` through the ordinary runner. +- One-time override appends `--tool <id>` and does not mutate the saved Workset. +- No Workset command receives `--store`. + +### Visual fidelity + +The two approved images define hierarchy, density, grouping, labels, and action placement. Implementation may reuse the existing Header instead of duplicating its Project/Planning-root card, but it must preserve the same semantic separation and compact VS Code-native appearance. Visual acceptance compares the running Sidebar states against both images at the same narrow viewport. + +### Deferred work + +Member editing, Git management, automatic activation, and a full editor-area workspace remain outside this change. They require either an official CLI capability or separate user evidence and design work. diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/proposal.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/proposal.md new file mode 100644 index 0000000..e7e8b6c --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/proposal.md @@ -0,0 +1,47 @@ +<!-- Distilled from explore.md for this change. --> + +## Why + +The current Project-first Worksets experience expands topology before users have selected a Workset and omits official CLI capabilities for creating Worksets and overriding the opener. A focused list → detail → create flow makes multi-folder work discoverable and safe without blurring Project, Planning Store, or Git responsibilities. + +## What Changes + +### MVP + +- Replace the fully expanded Project-first Workset scene with a compact Workset list that drills into one Workset detail view. +- Add a single-screen Create Workset flow with Current Project membership, Primary-member ordering, native folder selection, optional opener id, validation, and explicit success/error states. +- Add saved-tool open and one-time `--tool` override actions while preserving ordinary non-JSON Workset open behavior. +- Let a trusted Store member become the explicit Planning root only after the Extension Host re-reads and validates official Workset and Store inventories. +- Keep Current Project and Planning root visibly and behaviorally independent. +- Match the approved implementation references: + - [Worksets list and detail](assets/worksets-list-detail-high-fidelity.png) + - [Create Workset](assets/workset-create-high-fidelity.png) +- Add Simplified Chinese and English strings, keyboard/focus behavior, capability gating, and narrow-sidebar visual acceptance. + +### Deferred / Phase 2+ + +- Workset member editing remains deferred until OpenSpec exposes an official update command. +- Git clone, pull, push, and synchronization remain external to OpenSpec Worksets. +- A full editor-area Worksets workspace and automatic extension activation are not part of this change. + +No breaking API or stored-data changes are intended. + +## Capabilities + +### New Capabilities + +- `workset-creation`: Create a machine-local Workset from the Project-first Sidebar through the official selector-free CLI, including trusted folder selection, Primary ordering, optional opener id, refresh, and error recovery. + +### Modified Capabilities + +- `workset-project-navigation`: Present containing Worksets as list and detail states, and allow a fresh-validated Store member to become Planning root without making it a Project target. +- `workset-cli-open`: Add list/detail whole-Workset actions and a one-time opener override while keeping ordinary CLI output and saved Workset state unchanged. +- `openspec-scope-management`: Support explicit Planning Store selection from a validated Workset Store member while preserving the current Project identity and immutable Project binding. + +## Impact + +- **Webview**: `Dashboard`, `WorksetProjectPicker`, `WorksetsPage`, message types, i18n strings, keyboard/focus behavior, and tests. +- **Extension Host**: Workset create/open service methods, native folder picker handling, Store-member validation, Project Sidebar refresh, and message-handler tests. +- **CLI integration**: Uses OpenSpec 1.8.0 `workset create --json` and ordinary `workset open [--tool]`; every Workset command remains selector-free. +- **Security and data**: No direct access to private Workset registry files, no Git operations, and no new shared state. +- **Dependencies**: No new runtime or UI dependency is required. diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/openspec-scope-management/spec.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/openspec-scope-management/spec.md new file mode 100644 index 0000000..c29e911 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/openspec-scope-management/spec.md @@ -0,0 +1,54 @@ +## MODIFIED Requirements + +### Requirement: Workset Project and Planning Store boundaries + +OpenSpec scope management SHALL treat Workset Project members as navigable Project candidates and registered Store members as explicit Planning Store candidates, while preserving Project identity and accepting a replacement Project binding only after official CLI root resolution. + +#### Scenario: Current Project membership is derived from CLI + +- **WHEN** official `workset list --json` reports the current canonical Project path as a member +- **THEN** the extension MUST expose that Workset as navigation context +- **AND** the extension MUST NOT create a persisted membership record or Project registry entry + +#### Scenario: Registered Store member is encountered + +- **WHEN** a Workset member path canonicalizes to a root returned by official `store list --json` +- **THEN** the member MUST be classified as Planning Store +- **AND** it MUST NOT become a selectable Project or silently change the current Project binding + +#### Scenario: User explicitly selects a Workset Planning Store + +- **WHEN** the user activates `Use as planning root` for a Store member that remains present in freshly read official Workset and Store inventories +- **THEN** the Host MUST resolve a new binding for the current Project with the validated Store id as the explicit selector +- **AND** it MUST accept the binding only when its Project id, command cwd, canonical root, and Store id match the requested Project/Store context +- **AND** it MUST preserve the current Project identity while replacing and refreshing Project-bound data for the accepted binding + +#### Scenario: Workset Planning Store request is stale or forged + +- **WHEN** the submitted Workset name/path no longer identifies a registered Store member in fresh official inventories +- **THEN** the Host MUST reject the request without changing the selected scope +- **AND** it MUST preserve the current Project binding, watcher target, and visible Project data + +#### Scenario: Current Planning Store is displayed + +- **WHEN** the selected Planning root matches a Store member in Workset detail +- **THEN** the UI MUST present that member as `Current root` +- **AND** it MUST NOT expose a redundant selection action + +#### Scenario: User returns to the Project-resolved Planning root + +- **WHEN** the user activates `Use project default` while an explicit Workset Store selector is active +- **THEN** the Host MUST resolve a fresh selector-free binding from the current Project command cwd +- **AND** it MUST preserve the Project identity and replace visible data only after the returned binding is validated + +#### Scenario: Project binding is refreshed after navigation + +- **WHEN** a Project member is selected or the user returns to the original Project +- **THEN** the extension MUST resolve the CLI root from that Project's command cwd using the currently explicit Planning Store selector when one remains active +- **AND** every Project-bound operation MUST use the resulting binding identity until another validated Project or Planning-root selection occurs + +#### Scenario: Workset metadata is unavailable + +- **WHEN** Workset or Store list probing fails +- **THEN** the extension MUST preserve the current Project binding, selected Planning root, and local Project content +- **AND** it MUST not fall back to a guessed path, another selected Store scope, or stale membership as a navigation or Planning-root target diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-cli-open/spec.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-cli-open/spec.md new file mode 100644 index 0000000..c5a07fc --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-cli-open/spec.md @@ -0,0 +1,75 @@ +## MODIFIED Requirements + +### Requirement: Official Workset open action + +The Workset UI SHALL open a complete saved Workset through the official non-JSON `openspec workset open <name> [--tool <id>]` command and SHALL preserve the CLI's tool, member, and error semantics. + +#### Scenario: Open a saved Workset + +- **WHEN** the user activates Open Workset for a saved Workset in the management page +- **THEN** the Host MUST invoke `openspec workset open <name>` without `--json` +- **AND** the official CLI MUST remain responsible for opener selection, member filtering, and generated workspace files + +#### Scenario: Open a saved Workset with its configured tool + +- **WHEN** the user activates whole-Workset Open without a one-time override +- **THEN** the Host MUST invoke `openspec workset open <name>` without `--json` +- **AND** the official CLI MUST remain responsible for saved opener selection, member filtering, and generated workspace files + +#### Scenario: Open a saved Workset with a one-time tool override + +- **WHEN** the user submits a non-empty opener id through `Open with another tool` +- **THEN** the Host MUST invoke `openspec workset open <name> --tool <id>` without `--json` +- **AND** the override MUST apply only to that invocation and MUST NOT mutate the saved Workset tool + +#### Scenario: Workset open reports an error + +- **WHEN** the official Workset open command reports an unavailable tool, missing member, or launch failure +- **THEN** the Host MUST surface a recoverable error or CLI diagnostic +- **AND** it MUST preserve the non-zero exit and MUST NOT treat ordinary CLI output as a JSON parse failure + +#### Scenario: Project picker selects a member + +- **WHEN** the user activates a selectable Project member inside Workset detail +- **THEN** the action MUST switch the current Project binding within the Sidebar +- **AND** it MUST NOT invoke `openspec workset open` or open the whole Workset + +### Requirement: Unambiguous Workset action labels + +The UI SHALL distinguish local Workset navigation, detail navigation, whole-Workset opening, one-time opener override, Project switching, and Planning Store selection in labels, focus order, and message routing. + +#### Scenario: Worksets launcher + +- **WHEN** Worksets is enabled in the Project action grid +- **THEN** its accessible name MUST describe browsing Worksets for the current Project +- **AND** its message MUST only change the local Sidebar view + +#### Scenario: Workset list row + +- **WHEN** a saved Workset is shown in the Project-first Worksets list +- **THEN** the row body MUST enter Workset detail without opening an external tool +- **AND** its separate `Open` action MUST target the Workset name and open the whole Workset + +#### Scenario: Workset management card + +- **WHEN** a saved Workset is shown in the Worksets management page +- **THEN** exactly one primary action MUST be labeled as opening the whole Workset +- **AND** the action MUST target the Workset name rather than an arbitrary member + +#### Scenario: Workset detail actions + +- **WHEN** Workset detail is visible +- **THEN** `Open all` MUST use the saved/default opener path and `Open with another tool` MUST request a one-time opener id +- **AND** neither action MUST switch the current Project or Planning root + +#### Scenario: Project-first member row + +- **WHEN** a selectable Project member is shown in Workset detail +- **THEN** its action MUST be labeled as switching to that Project within the current Sidebar +- **AND** Store members and invalid members MUST remain unavailable as Project targets + +#### Scenario: Planning Store member row + +- **WHEN** a validated non-current Store member is shown in Workset detail +- **THEN** its action MUST be labeled as using that Store as Planning root +- **AND** it MUST NOT be labeled or routed as Project switching or whole-Workset opening diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-creation/spec.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-creation/spec.md new file mode 100644 index 0000000..ef98615 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-creation/spec.md @@ -0,0 +1,91 @@ +> Visual reference (non-normative): [Create Workset high-fidelity design](../../assets/workset-create-high-fidelity.png). + +## ADDED Requirements + +### Requirement: Project-first Workset creation form + +The Project-first Sidebar SHALL provide a single-screen form for creating a machine-local Workset from the current Project context. + +#### Scenario: Open the creation form + +- **WHEN** Workset capability is available and the user activates `Create Workset` from the Worksets list +- **THEN** the Sidebar MUST show fields for name, Primary member, members, and optional preferred tool +- **AND** the current Project MUST be included as a member and MUST NOT be removable in this Project-first flow + +#### Scenario: Choose a different Primary member + +- **WHEN** the user selects another included member as Primary +- **THEN** that member MUST remain in the members list +- **AND** the submitted members sequence MUST place its canonical path first + +#### Scenario: Cancel creation + +- **WHEN** the user cancels the creation form +- **THEN** the Sidebar MUST return to the Worksets list +- **AND** it MUST NOT invoke any Workset mutation command + +### Requirement: Trusted Workset member selection + +The extension SHALL collect Workset members through the VS Code folder picker and SHALL validate and canonicalize selected paths before submitting them to OpenSpec. + +#### Scenario: Add multiple folders + +- **WHEN** the user requests to add members and selects one or more folders +- **THEN** the Extension Host MUST return absolute selected folder paths to the active creation form +- **AND** the form MUST preserve one entry per canonical path + +#### Scenario: Folder selection is cancelled + +- **WHEN** the native folder picker is dismissed without a selection +- **THEN** the existing creation draft MUST remain unchanged +- **AND** the UI MUST NOT report a creation error + +#### Scenario: Duplicate or invalid member is returned + +- **WHEN** a selected member duplicates an existing canonical path or cannot be resolved as an eligible absolute folder +- **THEN** the form MUST NOT add a duplicate or invalid member +- **AND** the user MUST receive a recoverable explanation for an invalid selection + +### Requirement: Selector-free official Workset creation + +The Extension Host SHALL create Worksets exclusively through the official JSON CLI command and SHALL keep Workset mutation independent from the current Planning root. + +#### Scenario: Create a Workset without a preferred tool + +- **WHEN** the user submits a valid name and ordered members without a preferred tool +- **THEN** the Host MUST invoke `openspec workset create <name> --member <primary> --member <other>... --json` +- **AND** the command MUST NOT include `--store` or another Planning-root selector + +#### Scenario: Create a Workset with a preferred tool + +- **WHEN** the user submits a valid optional opener id +- **THEN** the Host MUST append `--tool <id>` before `--json` +- **AND** the CLI MUST remain responsible for validating whether the opener is configured and usable + +#### Scenario: Webview submits malformed creation input + +- **WHEN** the submitted name, tool id, or members have invalid types, the trimmed name is empty, or the members list is empty +- **THEN** the Host MUST reject the request before invoking the CLI +- **AND** it MUST NOT read or write OpenSpec private Workset registry files + +### Requirement: Workset creation result and refresh + +The creation flow SHALL report an explicit result and SHALL refresh the Project-first Workset snapshot only after the official command succeeds. + +#### Scenario: Workset creation succeeds + +- **WHEN** the official create command succeeds for a Workset containing the current Project +- **THEN** the Host MUST reload the current Project Sidebar data from official sources +- **AND** the Sidebar MUST enter the new Workset detail view after the refreshed snapshot contains that Workset + +#### Scenario: Workset creation fails + +- **WHEN** the CLI rejects a duplicate name, invalid opener, missing member, or another creation error +- **THEN** the Sidebar MUST preserve the user's draft and show a recoverable error +- **AND** it MUST NOT display the Workset as created or clear the draft optimistically + +#### Scenario: Workset capability is unavailable + +- **WHEN** the resolved OpenSpec runtime explicitly reports Workset capability as unavailable +- **THEN** the Sidebar MUST hide or disable the creation action with the existing upgrade explanation +- **AND** it MUST keep Changes and Specs usable diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-project-navigation/spec.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-project-navigation/spec.md new file mode 100644 index 0000000..bf2c18a --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/specs/workset-project-navigation/spec.md @@ -0,0 +1,75 @@ +> Visual reference (non-normative): [Worksets list and detail high-fidelity design](../../assets/worksets-list-detail-high-fidelity.png). + +## ADDED Requirements + +### Requirement: Workset list and detail navigation + +The Project-first Worksets surface SHALL present containing Worksets as a compact list and SHALL reveal members and topology actions only after one Workset is selected. + +#### Scenario: Render containing Worksets as a list + +- **WHEN** trusted Workset navigation contains one or more Worksets for the current Project +- **THEN** the Worksets surface MUST show one row per containing Workset with name, member count, and saved or default tool label +- **AND** member rows MUST remain collapsed until the user selects a Workset + +#### Scenario: Open Workset detail + +- **WHEN** the user activates a Workset list row without activating its whole-Workset `Open` control +- **THEN** the Sidebar MUST enter detail for that Workset without launching an external opener +- **AND** the detail MUST show the Workset name, opener information, members, and role-appropriate actions + +#### Scenario: Return from detail + +- **WHEN** the user activates Back in Workset detail +- **THEN** the Sidebar MUST return to the containing Worksets list +- **AND** it MUST preserve the current Project and Planning root + +#### Scenario: Selected Workset disappears after refresh + +- **WHEN** the selected Workset is absent from a fresh official navigation snapshot +- **THEN** the Sidebar MUST return to the list and expose a recoverable stale-item explanation +- **AND** it MUST NOT retain actions from the removed Workset + +#### Scenario: Narrow Sidebar keyboard navigation + +- **WHEN** the list or detail is rendered in a narrow Sidebar +- **THEN** row navigation, whole-Workset open, Back, Project switch, and Planning Store selection MUST be independently keyboard operable with visible focus +- **AND** names and role labels MUST remain bounded without overlapping actions + +## MODIFIED Requirements + +### Requirement: Project-only Workset selection + +The Workset detail SHALL distinguish selectable Project members from registered Store members, SHALL never make a Store member a Project selection target, and SHALL expose a Store member as a Planning-root action only after fresh Host-side validation. + +#### Scenario: Workset contains Project and Store members + +- **WHEN** a Workset includes canonical paths matching registered Store roots and other Project folders +- **THEN** Store members MUST be labeled `Planning Store` and MUST NOT have Project selection controls +- **AND** only non-Store Project members MUST have Project selection controls +- **AND** a non-current Store member MAY expose `Use as planning root` only through a Host-validated Workset Store action + +#### Scenario: Current Planning Store is a Workset member + +- **WHEN** a Store member identifies the current Planning root +- **THEN** the detail MUST label it `Current root` +- **AND** it MUST NOT expose a redundant or disabled Store-selection action +- **AND** the surrounding Planning-root context MUST provide an explicit `Use project default` recovery action + +#### Scenario: Workset contains a same-repository Git worktree + +- **WHEN** a selectable member is a Git worktree of the same repository as another member +- **THEN** the Workset detail MUST show best-effort repository identity and branch metadata +- **AND** the member MUST remain a distinct selectable canonical Project path + +#### Scenario: Workset has no selectable Project members + +- **WHEN** all other members are registered Stores or invalid/unresolvable paths +- **THEN** the Workset detail MUST show a clear no-other-Projects state +- **AND** it MUST not offer a Store or invalid path as a Project action + +#### Scenario: Store action uses stale or forged membership + +- **WHEN** the submitted Workset name/path is absent from a fresh official Workset and Store response, is not a Store, or cannot be canonicalized +- **THEN** the Host MUST reject the Planning-root request +- **AND** it MUST preserve the current Project, Planning root, binding, watcher, and visible data diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/01-cli-message-contracts.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/01-cli-message-contracts.md new file mode 100644 index 0000000..fb9db5c --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/01-cli-message-contracts.md @@ -0,0 +1,49 @@ +# Task 1. CLI 与消息契约 + +<!-- covers: Task 1.1, Task 1.2 --> + +### Task 1.1: 定义创建、选成员、结果回传与单次工具覆盖消息契约 + +**Spec coverage:** `workset-creation` / Project-first Workset creation form / Open the creation form;Trusted Workset member selection / Add multiple folders;Workset creation result and refresh / Workset creation succeeds、Workset creation fails;`workset-cli-open` / Official Workset open action / Open a saved Workset with a one-time tool override;Unambiguous Workset action labels / Worksets launcher、Workset list row、Workset detail actions、Planning Store member row。 + +**Dependencies / order:** 首个实现任务;先固定跨 Webview/Host 边界的判别联合类型和发送器,再让后续 Host 与 UI 任务消费同一契约。 + +**Files:** +- Modify: `src/webview/types/messages.ts` +- Create: `test/webview/types/messages.test.ts` + +**Implementation notes:** 在现有 `WebviewMessage`、`ExtensionMessage` 与 `sendMessage` 中直接加入 `pickWorksetMembers`、`createWorkset { name, members, tool? }`、带可选 `tool` 的 `openWorkset`、`selectWorksetStore { worksetName, memberPath }`、`selectProjectDefaultRoot`、`worksetMembersPicked { paths }`、`worksetCreateResult { success, name, message? }`。不增加第二套消息总线、request registry 或持久状态。 + +**Verification:** `pnpm exec vitest run test/webview/types/messages.test.ts`;预期新增发送器产生精确 payload,省略可选值时不产生空字段,测试退出码为 0。 + +**Risks / edge cases:** 可选工具必须保持一次性值而非保存配置;数组仍是不可信输入,Host 校验留给 Task 4.2;不要改变既有消息的字段名或 `setContext` 路径。 + +- [ ] **Step 1(5 分钟):** 新建消息测试,断言六类 Webview 消息、两个 Host 结果消息以及 `openWorkset(name, tool?)` 的精确对象结构。 +- [ ] **Step 2(2 分钟):** 运行 `pnpm exec vitest run test/webview/types/messages.test.ts`,确认因类型或发送器尚不存在而 FAIL。 +- [ ] **Step 3(5 分钟):** 只扩展现有判别联合与 `sendMessage`,不引入新的消息抽象。 +- [ ] **Step 4(3 分钟):** 重跑聚焦测试,确认全部 PASS 且可选字段按是否提供决定是否出现。 +- [ ] **Step 5(3 分钟):** 运行 `pnpm run build:webview`,确认消息联合在 React 构建中无类型分支遗漏。 + +--- + +### Task 1.2: 实现 selector-free 创建与普通 Workset 打开命令 + +**Spec coverage:** `workset-creation` / Selector-free official Workset creation / Create a Workset without a preferred tool、Create a Workset with a preferred tool;`workset-cli-open` / Official Workset open action / Open a saved Workset、Open a saved Workset with its configured tool、Open a saved Workset with a one-time tool override、Workset open reports an error。 + +**Dependencies / order:** 依赖 Task 1.1 的 `openWorkset` 工具参数契约;在创建 Host 流程前先锁定 CLI argv 与普通/JSON runner 边界。 + +**Files:** +- Modify: `src/extension/services/dataManager.ts` +- Modify: `test/extension/services/dataManager.test.ts` + +**Implementation notes:** 将 `openWorkset(name, tool?)` 保持在 `runCommand`,仅在 trim 后工具非空时追加 `--tool`;新增 `createWorkset(name, members, tool?)`,按输入顺序构造重复 `--member`,可选追加 `--tool`,最后追加 `--json` 并调用 `runJson`。任何 Workset argv 都不得加入 `--store`,也不得通过 shell 拼接字符串。 + +**Verification:** `pnpm exec vitest run test/extension/services/dataManager.test.ts`;预期覆盖有/无工具的 create/open 精确 argv、普通 runner 与 JSON runner 分流、CLI reject 原样向上传播。 + +**Risks / edge cases:** Primary 只由成员数组首项表达;不要在服务层重排或去重;创建 payload 的信任边界由 Task 4.2 先校验;普通打开输出不能经过 JSON 解析。 + +- [ ] **Step 1(5 分钟):** 在现有 DataManager 测试中加入四个精确 argv 断言,并加入 `runCommand` reject 不被吞掉的用例。 +- [ ] **Step 2(2 分钟):** 运行 `pnpm exec vitest run test/extension/services/dataManager.test.ts`,确认新用例因缺少 create/override 行为而 FAIL。 +- [ ] **Step 3(5 分钟):** 最小修改 `openWorkset` 并新增 `createWorkset`,直接构造字符串数组调用现有 CLI service。 +- [ ] **Step 4(3 分钟):** 重跑聚焦测试,确认 PASS,且断言所有 Workset argv 均不含 `--store`。 +- [ ] **Step 5(3 分钟):** 运行 `pnpm run compile`,确认 Extension Host 构建通过。 diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/02-trusted-store-binding.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/02-trusted-store-binding.md new file mode 100644 index 0000000..905b804 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/02-trusted-store-binding.md @@ -0,0 +1,51 @@ +# Task 2. 可信 Planning Store 绑定 + +<!-- covers: Task 2.1, Task 2.2 --> + +### Task 2.1: 校验 Workset Store 成员并按显式 Store 加载 Project 数据 + +**Spec coverage:** `openspec-scope-management` / Workset Project and Planning Store boundaries / Current Project membership is derived from CLI、Registered Store member is encountered、User explicitly selects a Workset Planning Store、Workset Planning Store request is stale or forged、Workset metadata is unavailable;`workset-project-navigation` / Project-only Workset selection / Store action uses stale or forged membership。 + +**Dependencies / order:** 可与 Task 1.2 之后开始;必须先完成 Gateway 的 fresh-inventory 校验和显式 binding 读取,Provider 才能安全接入选择动作。 + +**Files:** +- Modify: `src/extension/services/projectDataGateway.ts` +- Modify: `src/extension/services/types.ts` +- Modify: `test/extension/services/projectDataGateway.test.ts` + +**Implementation notes:** 在 `ProjectDataGateway` 复用现有 Workset/Store 列表、realpath 与成员角色分类,新增 `resolveWorksetStore(project, worksetName, memberPath)` 返回 fresh-validated `storeId`;将 `loadProjectSidebarData(project, explicitStoreId?)` 传入既有 binding readers。校验失败抛 `ProjectDataAccessError`,不猜路径、不接受 Webview 提供的 store id,也不回退到 legacy selected scope。 + +**Verification:** `pnpm exec vitest run test/extension/services/projectDataGateway.test.ts`;预期有效 Store 成员返回官方 id 并以该 id 解析全部 Sidebar readers,伪造、过期、Project 角色、不可 realpath 和列表失败均拒绝。 + +**Risks / edge cases:** 同一路径的符号链接必须在 canonicalize 后比较;Workset 名称与成员路径都来自不可信 Webview;元数据探测失败必须 fail-closed,不能污染已接受 binding。 + +- [ ] **Step 1(5 分钟):** 添加有效 Store、伪造路径、过期成员、非 Store 成员和 inventory failure 的 Gateway 失败测试。 +- [ ] **Step 2(2 分钟):** 运行 `pnpm exec vitest run test/extension/services/projectDataGateway.test.ts`,确认缺少 Store 解析接口或显式 selector 透传而 FAIL。 +- [ ] **Step 3(5 分钟):** 用现有 canonical path 与官方 inventory helper 实现 `resolveWorksetStore`,只返回已注册 Store id。 +- [ ] **Step 4(5 分钟):** 扩展 `loadProjectSidebarData(project, explicitStoreId?)`,让 changes、specs、引用 Store specs 与 Workset navigation 共享同一已验证 binding。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认 PASS 且所有拒绝路径没有 selector-free 或 legacy scope fallback。 + +--- + +### Task 2.2: 原子切换 Workset Store、Project 默认根与 Project 成员 + +**Spec coverage:** `openspec-scope-management` / Workset Project and Planning Store boundaries / User explicitly selects a Workset Planning Store、Current Planning Store is displayed、User returns to the Project-resolved Planning root、Project binding is refreshed after navigation、Workset metadata is unavailable;`workset-project-navigation` / Project-only Workset selection / Current Planning Store is a Workset member、Store action uses stale or forged membership。 + +**Dependencies / order:** 依赖 Task 1.1 的消息类型和 Task 2.1 的 Gateway 接口;完成后 UI 才可显示真实的 `Current root` 与 `Use project default` 状态。 + +**Files:** +- Modify: `src/extension/providers/dashboardViewProvider.ts` +- Modify: `test/extension/providers/dashboardViewProvider.test.ts` + +**Implementation notes:** Provider 增加仅驻留当前进程的 `explicitProjectStoreId`;处理 `selectWorksetStore` 时先 fresh-validate Store,再为同一 Project resolve/load,只有 generation、Project id、command cwd、canonical root 与 store id 全匹配才一次性替换 selector、binding、watcher 和 snapshot。`selectProjectDefaultRoot` 先成功解析 selector-free binding 再清空 selector;现有 `selectWorksetProject` 在 selector 活跃时继续携带它。失败保持旧状态与可见数据。 + +**Verification:** `pnpm exec vitest run test/extension/providers/dashboardViewProvider.test.ts`;预期覆盖成功 Store 切换、默认根恢复、携 selector 的 Project 切换、过期响应丢弃和失败后旧 snapshot 原样保留。 + +**Risks / edge cases:** 不得在 await 前乐观赋值 selector;连续点击可能乱序,必须沿用 `projectRequestGeneration`;Project 切换与 Planning root 切换不能复用 legacy `selectScope`。 + +- [ ] **Step 1(5 分钟):** 添加 Provider 测试,先断言 selector 只在完整验证后生效,失败和 stale generation 均不发布新 `setContext`。 +- [ ] **Step 2(2 分钟):** 运行 `pnpm exec vitest run test/extension/providers/dashboardViewProvider.test.ts`,确认新消息尚未处理而 FAIL。 +- [ ] **Step 3(5 分钟):** 让 reload 路径接收当前 `explicitProjectStoreId`,复用现有 snapshot 发布与 generation 防护。 +- [ ] **Step 4(5 分钟):** 增加 Store 选择和 Project 默认根恢复处理,并让 Project 成员切换保留已接受 selector。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认成功路径一次发布、所有失败路径零状态变更。 + diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/03-list-detail-navigation.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/03-list-detail-navigation.md new file mode 100644 index 0000000..ed4e448 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/03-list-detail-navigation.md @@ -0,0 +1,54 @@ +# Task 3. Worksets 列表与详情 + +<!-- covers: Task 3.1, Task 3.2 --> + +### Task 3.1: 实现列表、详情与刷新后的本地状态协调 + +**Spec coverage:** `workset-project-navigation` / Workset list and detail navigation / Render containing Worksets as a list、Open Workset detail、Return from detail、Selected Workset disappears after refresh;`workset-creation` / Workset creation result and refresh / Workset capability is unavailable。 + +**Dependencies / order:** 依赖 Task 1.1 的消息契约;只消费 `ProjectSidebarData.worksetNavigation`,不依赖创建与 Store mutation 已完成。 + +**Files:** +- Modify: `src/webview/components/WorksetProjectPicker.tsx` +- Modify: `src/webview/components/Dashboard.tsx` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` +- Modify: `test/webview/components/dashboard.test.tsx` + +**Implementation notes:** 在现有 `WorksetProjectPicker` 内保留最小 `list | detail(name) | create(draft)` 状态,不引入 router 或全局 store。列表仅渲染当前 Project 的 trusted navigation;row body 进入详情,Back 回列表;Project identity 变化重置列表,普通 snapshot refresh 仅在 Workset 仍存在时保留详情,否则回列表并显示可恢复提示。能力不可用时沿用现有升级说明并隐藏或禁用 Create。 + +**Verification:** `pnpm exec vitest run test/webview/components/worksetProjectPicker.test.tsx test/webview/components/dashboard.test.tsx`;预期列表初始折叠成员、详情不触发外部打开、Back 保持 Project/binding、删除后的 stale detail 回列表。 + +**Risks / edge cases:** Workset 同名只以官方 name 标识;binding-only refresh 不应误清 detail;Project 变化不得保留旧 Workset action;不要从 legacy `DashboardData.worksets` 重建角色。 + +- [ ] **Step 1(5 分钟):** 添加列表初态、row drill-down、Back、同 Project refresh、Project 变化和 Workset 消失的组件测试。 +- [ ] **Step 2(2 分钟):** 运行聚焦 Vitest,确认当前全展开实现使新断言 FAIL。 +- [ ] **Step 3(5 分钟):** 在现有组件中加入局部状态和 selected-name 协调逻辑,先保持渲染结构最小。 +- [ ] **Step 4(5 分钟):** 将 Dashboard 的 Worksets tab 保持为同一组件入口,并传入 capability/binding 所需数据。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认 PASS 且未增加 router、状态库或持久化字段。 + +--- + +### Task 3.2: 接通独立的打开、Project 切换与 Planning root 操作 + +**Spec coverage:** `workset-project-navigation` / Workset list and detail navigation / Narrow Sidebar keyboard navigation;Project-only Workset selection / Workset contains Project and Store members、Current Planning Store is a Workset member、Workset contains a same-repository Git worktree、Workset has no selectable Project members;`workset-cli-open` / Official Workset open action / Project picker selects a member;Unambiguous Workset action labels / Workset list row、Workset management card、Workset detail actions、Project-first member row、Planning Store member row。 + +**Dependencies / order:** 依赖 Task 1.1、1.2、2.2 和 Task 3.1;使用已经验证的角色与 binding,不在 React 中重新分类路径。 + +**Files:** +- Modify: `src/webview/components/WorksetProjectPicker.tsx` +- Modify: `src/webview/components/Dashboard.tsx` +- Modify: `src/extension/providers/webviewMessageHandler.ts` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` +- Modify: `test/extension/providers/webviewMessageHandler.test.ts` + +**Implementation notes:** 列表 row 与内联 `Open` 分离并阻止事件冒泡;详情提供 `Open all`、可编辑的 one-time opener、Project switch、validated Planning root 和 `Use project default`。Project、Store、invalid 与 current 状态只消费 Host 返回字段。共享 handler 将可选工具传给 `DataManager.openWorkset`,错误继续走现有 recoverable `error` 消息。 + +**Verification:** `pnpm exec vitest run test/webview/components/worksetProjectPicker.test.tsx test/extension/providers/webviewMessageHandler.test.ts`;预期每个动作产生唯一消息,键盘激活与点击等价,Store 不会发送 Project 选择,普通 CLI 错误可恢复显示。 + +**Risks / edge cases:** 嵌套交互元素会造成双触发,必须用独立 button 和 propagation 防护;current 项是状态文本而非 disabled action;custom opener id 允许输入但不在 UI 假装已验证。 + +- [ ] **Step 1(5 分钟):** 添加 row/Open 分流、键盘激活、角色动作、current 状态、无其他 Project 和 one-time tool 的失败测试。 +- [ ] **Step 2(2 分钟):** 运行聚焦 Vitest,确认消息路由与详情动作缺失而 FAIL。 +- [ ] **Step 3(5 分钟):** 在详情中复用 trusted member 字段渲染 Project、Store、git metadata 与无可选 Project 状态。 +- [ ] **Step 4(5 分钟):** 接通 Dashboard callbacks 和 shared Host handler,使每个动作只发送或执行一个明确命令。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认 PASS,尤其不存在 Store→Project、detail→open 或 open→detail 的串扰。 diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/04-create-flow.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/04-create-flow.md new file mode 100644 index 0000000..0d3d077 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/04-create-flow.md @@ -0,0 +1,55 @@ +# Task 4. Workset 创建流程 + +<!-- covers: Task 4.1, Task 4.2 --> + +### Task 4.1: 实现可恢复的创建草稿与原生文件夹选择 + +**Spec coverage:** `workset-creation` / Project-first Workset creation form / Open the creation form、Choose a different Primary member、Cancel creation;Trusted Workset member selection / Add multiple folders、Folder selection is cancelled、Duplicate or invalid member is returned。 + +**Dependencies / order:** 依赖 Task 1.1 与 Task 3.1;创建提交暂由 Task 4.2 接通,本任务先固定草稿、成员顺序和 picker 回传语义。 + +**Files:** +- Modify: `src/webview/components/WorksetProjectPicker.tsx` +- Modify: `src/extension/providers/dashboardViewProvider.ts` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` +- Modify: `test/extension/providers/dashboardViewProvider.test.ts` + +**Implementation notes:** Create 是同一组件内单屏 form;current Project 默认加入且不可删除。`pickWorksetMembers` 只调用 `vscode.window.showOpenDialog({ canSelectFolders: true, canSelectMany: true })`,Host 返回绝对路径;Webview 仅在 create state 接受回传,按 canonical path 去重,并用成员数组首项表达 Primary。取消 picker 不发送错误、不改草稿;invalid path 返回可恢复说明。 + +**Verification:** `pnpm exec vitest run test/webview/components/worksetProjectPicker.test.tsx test/extension/providers/dashboardViewProvider.test.ts`;预期 current Project 锁定、Primary 重排、取消保持、晚到响应忽略、重复/非法成员不加入。 + +**Risks / edge cases:** folder picker 结果可能晚于离开表单;跨平台路径比较必须由 Host canonicalize 后返回;不要增加并行 Primary 字段或持久 draft。 + +- [ ] **Step 1(5 分钟):** 添加草稿初始值、Primary 重排、取消、picker cancel、重复路径、非法路径和 late response 测试。 +- [ ] **Step 2(2 分钟):** 运行聚焦 Vitest,确认 Create state 与 picker message 尚不存在而 FAIL。 +- [ ] **Step 3(5 分钟):** 最小实现 Create form 状态、成员列表和首项 Primary 排序。 +- [ ] **Step 4(5 分钟):** 在 Provider 接入原生 folder picker、绝对路径 canonicalization 与可恢复错误回传。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认 PASS 且取消/晚到响应零副作用。 + +--- + +### Task 4.2: 接通创建校验、结果刷新与成功后详情跳转 + +**Spec coverage:** `workset-creation` / Selector-free official Workset creation / Create a Workset without a preferred tool、Create a Workset with a preferred tool、Webview submits malformed creation input;Workset creation result and refresh / Workset creation succeeds、Workset creation fails、Workset capability is unavailable。 + +**Dependencies / order:** 依赖 Task 1.2 的 DataManager、Task 3.1 的详情协调和 Task 4.1 的草稿;成功路径必须在官方刷新后才改变 UI。 + +**Files:** +- Modify: `src/extension/providers/dashboardViewProvider.ts` +- Modify: `src/webview/components/Dashboard.tsx` +- Modify: `src/webview/components/WorksetProjectPicker.tsx` +- Modify: `test/extension/providers/dashboardViewProvider.test.ts` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` + +**Implementation notes:** Provider 在调用 CLI 前验证 message 类型、trim 后 name、非空字符串 members、绝对 canonical paths 与可选 tool;随后调用 `createWorkset`,成功后走既有 Project Sidebar reload。只在 fresh navigation 包含新 name 后回传 success 并进入详情;CLI 或 refresh 失败回传 failure,保留 draft,不发布 optimistic Workset。Workset capability unavailable 时拒绝 Host 调用。 + +**Verification:** `pnpm exec vitest run test/extension/providers/dashboardViewProvider.test.ts test/webview/components/worksetProjectPicker.test.tsx`;预期 malformed 输入零 CLI 调用,成功等待 fresh snapshot,duplicate/invalid opener/refresh failure 均保留 draft 并显示消息。 + +**Risks / edge cases:** CLI 成功但新 snapshot 尚未包含当前 Project 时不得伪造详情;连续 submit 必须防止重复创建;错误文案不得清空用户输入或泄漏私有 registry 路径。 + +- [ ] **Step 1(5 分钟):** 添加 malformed payload、能力不可用、CLI failure、refresh missing-name 与完整 success 的失败测试。 +- [ ] **Step 2(2 分钟):** 运行聚焦 Vitest,确认 Host 尚未处理 create result 而 FAIL。 +- [ ] **Step 3(5 分钟):** 实现 Host 输入守卫和单次提交锁,调用 Task 1.2 的 selector-free DataManager 方法。 +- [ ] **Step 4(5 分钟):** 在 fresh snapshot 后发送 result,并让 Webview 仅在 success 且 name 存在时从 draft 进入 detail。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认 PASS,失败草稿完整且无 optimistic snapshot。 + diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/05-visual-accessibility.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/05-visual-accessibility.md new file mode 100644 index 0000000..3af5aeb --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/05-visual-accessibility.md @@ -0,0 +1,56 @@ +# Task 5. 视觉、文案与可访问性 + +<!-- covers: Task 5.1, Task 5.2 --> + +### Task 5.1: 补齐双语文案、键盘焦点与窄 Sidebar 主题样式 + +**Spec coverage:** `workset-project-navigation` / Workset list and detail navigation / Render containing Worksets as a list、Narrow Sidebar keyboard navigation;Project-only Workset selection / Workset contains Project and Store members、Current Planning Store is a Workset member;`workset-cli-open` / Unambiguous Workset action labels / Worksets launcher、Workset list row、Workset detail actions、Project-first member row、Planning Store member row;`workset-creation` / Project-first Workset creation form / Open the creation form。 + +**Dependencies / order:** 依赖 Task 3 与 Task 4 的最终交互结构;只补齐现有组件、locale 与主题 token,不增加设计系统或 CSS 依赖。 + +**Files:** +- Modify: `src/webview/components/WorksetProjectPicker.tsx` +- Modify: `src/i18n/locales/en.json` +- Modify: `src/i18n/locales/zh-cn.json` +- Modify: `test/i18n/i18n.test.ts` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` + +**Implementation notes:** 所有可见与 accessible name 文案使用 `t()`;列表是一组轻分隔 surface,详情成员是一组 surface,Create 是单一可滚动 form。使用 `--vscode-focusBorder`、button/error/foreground/panel tokens;Project/Store 同时用图标和文本,不只靠颜色。约 430 px 时名称先截断、动作不重叠、无横向滚动;icon-only 控件有 tooltip 与 `aria-label`;动画限 120–160 ms 并尊重 reduced motion。 + +**Verification:** `pnpm exec vitest run test/i18n/i18n.test.ts test/webview/components/worksetProjectPicker.test.tsx`;预期中英文 key 对齐、独立控件可按 Tab 访问、visible focus 与 role label 存在,窄宽度无非预期固定最小宽度。 + +**Risks / edge cases:** 不复制 PNG 的硬编码颜色、字体或外层 VS Code chrome;`Current`/`Current root` 是状态而非 disabled button;英文和中文长度都必须在窄栏可截断。 + +- [ ] **Step 1(5 分钟):** 添加 locale key 对齐、accessible name、focusable action、role 文本和 reduced-motion class 的失败测试。 +- [ ] **Step 2(2 分钟):** 运行聚焦 Vitest,确认缺失文案或语义使测试 FAIL。 +- [ ] **Step 3(5 分钟):** 补齐 en/zh-cn 文案并将组件内新增可见字符串全部替换为 `t()`。 +- [ ] **Step 4(5 分钟):** 用现有 Tailwind utility 与 VS Code tokens 完成分组、focus、overflow、主题和 reduced-motion 样式。 +- [ ] **Step 5(3 分钟):** 重跑聚焦测试,确认 PASS 且两份 locale 无缺 key。 + +--- + +### Task 5.2: 在真实 Extension Host 中对照两张高保真设计稿验收 + +**Spec coverage:** `workset-project-navigation` / Workset list and detail navigation / Render containing Worksets as a list、Open Workset detail、Narrow Sidebar keyboard navigation;`workset-creation` / Project-first Workset creation form / Open the creation form;`workset-cli-open` / Unambiguous Workset action labels / Workset detail actions。 + +**Dependencies / order:** 依赖 Task 5.1 和此前所有功能任务;这是视觉验收与最小修正,不引入新交互。 + +**Files:** +- Modify: `src/webview/components/WorksetProjectPicker.tsx` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` +- Reference: [Worksets 列表与详情高保真稿](../assets/worksets-list-detail-high-fidelity.png) +- Reference: [Create Workset 高保真稿](../assets/workset-create-high-fidelity.png) + +**Implementation notes:** 在真实 VS Code Extension Development Host 的同一约 430 px Sidebar 宽度分别捕获 list、detail、create。逐项比较信息层级、分组密度、间距、动作位置、截断、focus 与滚动;外层 Activity Bar/window chrome 不属于 Webview。对每个发现的语义性偏差先增加组件回归断言,再做最小 class/markup 修正;不做易受主题影响的像素级 golden test。 + +**Verification:** 先运行 `pnpm run build`,再启动 `code --extensionDevelopmentPath=/Users/randy/workspace/projects/github/openspec-ext /Users/randy/workspace/projects/github/openspec-ext`。预期 list/detail/create 在 dark、light、high-contrast 主题均可用,在窄栏无重叠或横向滚动,键盘可达;验收后关闭 Development Host。 + +**Risks / edge cases:** 设计稿只有一种暗色视觉且含宿主 chrome,比较目标是层级与布局而非硬编码像素;若 fixture 缺少多个 Project/Store 角色,先用现有测试数据覆盖语义,不伪造生产 registry 文件。 + +- [ ] **Step 1(3 分钟):** 运行 `pnpm run build` 并确认 Extension Host 使用的是最新 `dist`。 +- [ ] **Step 2(5 分钟):** 在约 430 px 宽度捕获 list 与 detail,逐项对照列表/详情高保真稿并记录可复现偏差。 +- [ ] **Step 3(5 分钟):** 打开 Create state,对照创建高保真稿检查字段顺序、Primary、成员组、工具输入、按钮与滚动。 +- [ ] **Step 4(5 分钟):** 对每个语义偏差先在 `worksetProjectPicker.test.tsx` 写失败断言,再做最小 markup/class 修正并确认 PASS。 +- [ ] **Step 5(5 分钟):** 切换 dark、light、high-contrast,完整走一次 Tab/Enter/Escape 与窄栏截断验收。 +- [ ] **Step 6(2 分钟):** 关闭本任务启动的 Extension Development Host,确认未遗留占用进程。 + diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/06-regression-verification.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/06-regression-verification.md new file mode 100644 index 0000000..744f074 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/task-details/06-regression-verification.md @@ -0,0 +1,31 @@ +# Task 6. 回归验证 + +<!-- covers: Task 6.1 --> + +### Task 6.1: 通过聚焦测试、全量测试、构建与源码 lint 门禁 + +**Spec coverage:** `workset-creation`、`workset-project-navigation`、`workset-cli-open` 与 `openspec-scope-management` 的全部 requirements/scenarios;本任务验证前五组产生的实现证据,不替代各任务内的 RED/GREEN 步骤。 + +**Dependencies / order:** 最后执行;Task 1–5 全部通过各自聚焦测试与真实 Extension Host 验收后才开始。 + +**Files:** +- Modify: `test/extension/services/dataManager.test.ts` +- Modify: `test/extension/services/projectDataGateway.test.ts` +- Modify: `test/extension/providers/dashboardViewProvider.test.ts` +- Modify: `test/extension/providers/webviewMessageHandler.test.ts` +- Modify: `test/webview/components/worksetProjectPicker.test.tsx` +- Modify: `test/webview/components/dashboard.test.tsx` +- Modify: `test/webview/types/messages.test.ts` + +**Implementation notes:** 先运行覆盖本 change 的聚焦套件,再运行全量 Vitest、完整 build 与本 change 触及的 `src/` 文件 lint。任何失败先在所属现有测试文件增加最小回归断言,确认该断言稳定 FAIL 后只修根因并重跑;不顺手清理仓库既有无关 lint 问题,不增加依赖。 + +**Verification:** 聚焦 Vitest、`pnpm test`、`pnpm run build` 和触及文件的 `pnpm exec eslint` 均退出 0;`git diff --check` 无 whitespace error;`git status --short` 只包含本 change 与用户原有无关改动。 + +**Risks / edge cases:** 不复用旧或中断的测试结果;全量 lint 的既有全局声明问题仅作为诊断,不得用批量无关改动掩盖;不得提交、push、archive 或删除用户文件。 + +- [ ] **Step 1(5 分钟):** 运行 `pnpm exec vitest run test/extension/services/dataManager.test.ts test/extension/services/projectDataGateway.test.ts test/extension/providers/dashboardViewProvider.test.ts test/extension/providers/webviewMessageHandler.test.ts test/webview/components/worksetProjectPicker.test.tsx test/webview/components/dashboard.test.tsx test/webview/types/messages.test.ts`,预期全部 PASS。 +- [ ] **Step 2(5 分钟):** 运行 `pnpm test`,预期全量 Vitest 退出 0;若失败,先添加或收紧所属最小回归断言并确认 RED,再修根因至 GREEN。 +- [ ] **Step 3(5 分钟):** 运行 `pnpm run build`,预期 Extension Host 与 Webview 构建均退出 0。 +- [ ] **Step 4(5 分钟):** 对本 change 触及的 `src/extension/services/dataManager.ts src/extension/services/projectDataGateway.ts src/extension/providers/dashboardViewProvider.ts src/extension/providers/webviewMessageHandler.ts src/webview/types/messages.ts src/webview/components/WorksetProjectPicker.tsx src/webview/components/Dashboard.tsx` 运行 `pnpm exec eslint`,预期退出 0。 +- [ ] **Step 5(3 分钟):** 运行 `git diff --check` 与 `git status --short`,确认无格式错误、无意外生成文件且未触碰用户原有 `articles/`。 + diff --git a/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/tasks.md b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/tasks.md new file mode 100644 index 0000000..6777c50 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-worksets-list-detail-create-flow/tasks.md @@ -0,0 +1,36 @@ +<!-- Implementation details are in openspec/changes/<change>/task-details/ (generated by openspec-writing-task skill). Each ## Task N group references one task group file; Task N.M ids correspond 1-to-1 with ### Task blocks across files. --> + +## Task 1. CLI 与消息契约 +<!-- details: task-details/01-cli-message-contracts.md --> + +- [x] Task 1.1 定义创建、选成员、结果回传与单次工具覆盖消息契约 +- [x] Task 1.2 实现 selector-free 创建与普通 Workset 打开命令 + +## Task 2. 可信 Planning Store 绑定 +<!-- details: task-details/02-trusted-store-binding.md --> + +- [x] Task 2.1 校验 Workset Store 成员并按显式 Store 加载 Project 数据 +- [x] Task 2.2 原子切换 Workset Store、Project 默认根与 Project 成员 + +## Task 3. Worksets 列表与详情 +<!-- details: task-details/03-list-detail-navigation.md --> + +- [x] Task 3.1 实现列表、详情与刷新后的本地状态协调 +- [x] Task 3.2 接通独立的打开、Project 切换与 Planning root 操作 + +## Task 4. Workset 创建流程 +<!-- details: task-details/04-create-flow.md --> + +- [x] Task 4.1 实现可恢复的创建草稿与原生文件夹选择 +- [x] Task 4.2 接通创建校验、结果刷新与成功后详情跳转 + +## Task 5. 视觉、文案与可访问性 +<!-- details: task-details/05-visual-accessibility.md --> + +- [x] Task 5.1 补齐双语文案、键盘焦点与窄 Sidebar 主题样式 +- [x] Task 5.2 在真实 Extension Host 中对照两张高保真设计稿验收 + +## Task 6. 回归验证 +<!-- details: task-details/06-regression-verification.md --> + +- [x] Task 6.1 通过聚焦测试、全量测试、构建与源码 lint 门禁 diff --git a/openspec/specs/openspec-scope-management/spec.md b/openspec/specs/openspec-scope-management/spec.md index 1fff414..f6752d0 100644 --- a/openspec/specs/openspec-scope-management/spec.md +++ b/openspec/specs/openspec-scope-management/spec.md @@ -154,7 +154,7 @@ The extension SHALL preserve OpenSpec's distinction between writable scopes, rea ### Requirement: Workset Project and Planning Store boundaries -OpenSpec scope management SHALL treat Workset Project members as navigable Project candidates and registered Store members as read-only Planning Store context, while preserving immutable binding and single-selected-Project semantics. +OpenSpec scope management SHALL treat Workset Project members as navigable Project candidates and registered Store members as explicit Planning Store candidates, while preserving Project identity and accepting a replacement Project binding only after official CLI root resolution. #### Scenario: Current Project membership is derived from CLI @@ -166,16 +166,41 @@ OpenSpec scope management SHALL treat Workset Project members as navigable Proje - **WHEN** a Workset member path canonicalizes to a root returned by official `store list --json` - **THEN** the member MUST be classified as Planning Store -- **AND** it MUST NOT become a selectable Project or change the selected writable Project scope +- **AND** it MUST NOT become a selectable Project or silently change the current Project binding + +#### Scenario: User explicitly selects a Workset Planning Store + +- **WHEN** the user activates `Use as planning root` for a Store member that remains present in freshly read official Workset and Store inventories +- **THEN** the Host MUST resolve a new binding for the current Project with the validated Store id as the explicit selector +- **AND** it MUST accept the binding only when its Project id, command cwd, canonical root, and Store id match the requested Project/Store context +- **AND** it MUST preserve the current Project identity while replacing and refreshing Project-bound data for the accepted binding + +#### Scenario: Workset Planning Store request is stale or forged + +- **WHEN** the submitted Workset name/path no longer identifies a registered Store member in fresh official inventories +- **THEN** the Host MUST reject the request without changing the selected scope +- **AND** it MUST preserve the current Project binding, watcher target, and visible Project data + +#### Scenario: Current Planning Store is displayed + +- **WHEN** the selected Planning root matches a Store member in Workset detail +- **THEN** the UI MUST present that member as `Current root` +- **AND** it MUST NOT expose a redundant selection action + +#### Scenario: User returns to the Project-resolved Planning root + +- **WHEN** the user activates `Use project default` while an explicit Workset Store selector is active +- **THEN** the Host MUST resolve a fresh selector-free binding from the current Project command cwd +- **AND** it MUST preserve the Project identity and replace visible data only after the returned binding is validated #### Scenario: Project binding is refreshed after navigation - **WHEN** a Project member is selected or the user returns to the original Project -- **THEN** the extension MUST resolve the CLI root from that Project's command cwd -- **AND** every Project-bound operation MUST use the resulting binding identity until another validated selection occurs +- **THEN** the extension MUST resolve the CLI root from that Project's command cwd using the currently explicit Planning Store selector when one remains active +- **AND** every Project-bound operation MUST use the resulting binding identity until another validated Project or Planning-root selection occurs #### Scenario: Workset metadata is unavailable -- **WHEN** workset or Store list probing fails -- **THEN** the extension MUST preserve the current Project binding and local Project content -- **AND** it MUST not fall back to a guessed path, selected Store scope, or stale membership as a navigation target +- **WHEN** Workset or Store list probing fails +- **THEN** the extension MUST preserve the current Project binding, selected Planning root, and local Project content +- **AND** it MUST not fall back to a guessed path, another selected Store scope, or stale membership as a navigation or Planning-root target diff --git a/openspec/specs/workset-cli-open/spec.md b/openspec/specs/workset-cli-open/spec.md index a46f839..046c794 100644 --- a/openspec/specs/workset-cli-open/spec.md +++ b/openspec/specs/workset-cli-open/spec.md @@ -22,37 +22,75 @@ The Project-first action grid SHALL expose Workset Project navigation only from - **AND** the extension MUST NOT infer a Workset from repository layout or cached data for another Project ### Requirement: Official Workset open action -The Workset management UI SHALL open a complete saved Workset through the official non-JSON `openspec workset open <name>` command and SHALL preserve the CLI's tool, member, and error semantics. + +The Workset UI SHALL open a complete saved Workset through the official non-JSON `openspec workset open <name> [--tool <id>]` command and SHALL preserve the CLI's tool, member, and error semantics. #### Scenario: Open a saved Workset + - **WHEN** the user activates Open Workset for a saved Workset in the management page - **THEN** the Host MUST invoke `openspec workset open <name>` without `--json` - **AND** the official CLI MUST remain responsible for opener selection, member filtering, and generated workspace files +#### Scenario: Open a saved Workset with its configured tool + +- **WHEN** the user activates whole-Workset Open without a one-time override +- **THEN** the Host MUST invoke `openspec workset open <name>` without `--json` +- **AND** the official CLI MUST remain responsible for saved opener selection, member filtering, and generated workspace files + +#### Scenario: Open a saved Workset with a one-time tool override + +- **WHEN** the user submits a non-empty opener id through `Open with another tool` +- **THEN** the Host MUST invoke `openspec workset open <name> --tool <id>` without `--json` +- **AND** the override MUST apply only to that invocation and MUST NOT mutate the saved Workset tool + #### Scenario: Workset open reports an error + - **WHEN** the official Workset open command reports an unavailable tool, missing member, or launch failure - **THEN** the Host MUST surface a recoverable error or CLI diagnostic - **AND** it MUST preserve the non-zero exit and MUST NOT treat ordinary CLI output as a JSON parse failure #### Scenario: Project picker selects a member -- **WHEN** the user activates a selectable Project member inside the Project-first Workset picker + +- **WHEN** the user activates a selectable Project member inside Workset detail - **THEN** the action MUST switch the current Project binding within the Sidebar - **AND** it MUST NOT invoke `openspec workset open` or open the whole Workset ### Requirement: Unambiguous Workset action labels -The UI SHALL distinguish local Workset mode, whole-Workset opening, and Project switching in labels, focus order, and message routing. + +The UI SHALL distinguish local Workset navigation, detail navigation, whole-Workset opening, one-time opener override, Project switching, and Planning Store selection in labels, focus order, and message routing. #### Scenario: Worksets launcher + - **WHEN** Worksets is enabled in the Project action grid -- **THEN** its accessible name MUST describe browsing Workset Projects +- **THEN** its accessible name MUST describe browsing Worksets for the current Project - **AND** its message MUST only change the local Sidebar view +#### Scenario: Workset list row + +- **WHEN** a saved Workset is shown in the Project-first Worksets list +- **THEN** the row body MUST enter Workset detail without opening an external tool +- **AND** its separate `Open` action MUST target the Workset name and open the whole Workset + #### Scenario: Workset management card + - **WHEN** a saved Workset is shown in the Worksets management page - **THEN** exactly one primary action MUST be labeled as opening the whole Workset - **AND** the action MUST target the Workset name rather than an arbitrary member +#### Scenario: Workset detail actions + +- **WHEN** Workset detail is visible +- **THEN** `Open all` MUST use the saved/default opener path and `Open with another tool` MUST request a one-time opener id +- **AND** neither action MUST switch the current Project or Planning root + #### Scenario: Project-first member row -- **WHEN** a selectable Project member is shown in the Project-first picker + +- **WHEN** a selectable Project member is shown in Workset detail - **THEN** its action MUST be labeled as switching to that Project within the current Sidebar -- **AND** Store members and invalid members MUST remain non-selectable +- **AND** Store members and invalid members MUST remain unavailable as Project targets + +#### Scenario: Planning Store member row + +- **WHEN** a validated non-current Store member is shown in Workset detail +- **THEN** its action MUST be labeled as using that Store as Planning root +- **AND** it MUST NOT be labeled or routed as Project switching or whole-Workset opening diff --git a/openspec/specs/workset-creation/spec.md b/openspec/specs/workset-creation/spec.md new file mode 100644 index 0000000..9275f77 --- /dev/null +++ b/openspec/specs/workset-creation/spec.md @@ -0,0 +1,95 @@ +# Workset Creation Specification + +## Purpose + +为 Project-first Sidebar 提供从当前 Project 上下文创建本机 Workset 的单屏表单,通过官方 JSON CLI 完成 selector-free 创建,并在成功后刷新导航快照。 + +## Requirements + +### Requirement: Project-first Workset creation form + +The Project-first Sidebar SHALL provide a single-screen form for creating a machine-local Workset from the current Project context. + +#### Scenario: Open the creation form + +- **WHEN** Workset capability is available and the user activates `Create Workset` from the Worksets list +- **THEN** the Sidebar MUST show fields for name, Primary member, members, and optional preferred tool +- **AND** the current Project MUST be included as a member and MUST NOT be removable in this Project-first flow + +#### Scenario: Choose a different Primary member + +- **WHEN** the user selects another included member as Primary +- **THEN** that member MUST remain in the members list +- **AND** the submitted members sequence MUST place its canonical path first + +#### Scenario: Cancel creation + +- **WHEN** the user cancels the creation form +- **THEN** the Sidebar MUST return to the Worksets list +- **AND** it MUST NOT invoke any Workset mutation command + +### Requirement: Trusted Workset member selection + +The extension SHALL collect Workset members through the VS Code folder picker and SHALL validate and canonicalize selected paths before submitting them to OpenSpec. + +#### Scenario: Add multiple folders + +- **WHEN** the user requests to add members and selects one or more folders +- **THEN** the Extension Host MUST return absolute selected folder paths to the active creation form +- **AND** the form MUST preserve one entry per canonical path + +#### Scenario: Folder selection is cancelled + +- **WHEN** the native folder picker is dismissed without a selection +- **THEN** the existing creation draft MUST remain unchanged +- **AND** the UI MUST NOT report a creation error + +#### Scenario: Duplicate or invalid member is returned + +- **WHEN** a selected member duplicates an existing canonical path or cannot be resolved as an eligible absolute folder +- **THEN** the form MUST NOT add a duplicate or invalid member +- **AND** the user MUST receive a recoverable explanation for an invalid selection + +### Requirement: Selector-free official Workset creation + +The Extension Host SHALL create Worksets exclusively through the official JSON CLI command and SHALL keep Workset mutation independent from the current Planning root. + +#### Scenario: Create a Workset without a preferred tool + +- **WHEN** the user submits a valid name and ordered members without a preferred tool +- **THEN** the Host MUST invoke `openspec workset create <name> --member <primary> --member <other>... --json` +- **AND** the command MUST NOT include `--store` or another Planning-root selector + +#### Scenario: Create a Workset with a preferred tool + +- **WHEN** the user submits a valid optional opener id +- **THEN** the Host MUST append `--tool <id>` before `--json` +- **AND** the CLI MUST remain responsible for validating whether the opener is configured and usable + +#### Scenario: Webview submits malformed creation input + +- **WHEN** the submitted name, tool id, or members have invalid types, the trimmed name is empty, or the members list is empty +- **THEN** the Host MUST reject the request before invoking the CLI +- **AND** it MUST NOT read or write OpenSpec private Workset registry files + +### Requirement: Workset creation result and refresh + +The creation flow SHALL report an explicit result and SHALL refresh the Project-first Workset snapshot only after the official command succeeds. + +#### Scenario: Workset creation succeeds + +- **WHEN** the official create command succeeds for a Workset containing the current Project +- **THEN** the Host MUST reload the current Project Sidebar data from official sources +- **AND** the Sidebar MUST enter the new Workset detail view after the refreshed snapshot contains that Workset + +#### Scenario: Workset creation fails + +- **WHEN** the CLI rejects a duplicate name, invalid opener, missing member, or another creation error +- **THEN** the Sidebar MUST preserve the user's draft and show a recoverable error +- **AND** it MUST NOT display the Workset as created or clear the draft optimistically + +#### Scenario: Workset capability is unavailable + +- **WHEN** the resolved OpenSpec runtime explicitly reports Workset capability as unavailable +- **THEN** the Sidebar MUST hide or disable the creation action with the existing upgrade explanation +- **AND** it MUST keep Changes and Specs usable diff --git a/openspec/specs/workset-project-navigation/spec.md b/openspec/specs/workset-project-navigation/spec.md index 5b5f5f8..7f61803 100644 --- a/openspec/specs/workset-project-navigation/spec.md +++ b/openspec/specs/workset-project-navigation/spec.md @@ -28,28 +28,76 @@ Extension SHALL derive Project-first Workset navigation only from the official C - **THEN** the Project-first Sidebar MUST keep Changes/Specs usable - **AND** it MUST hide Workset navigation rather than infer membership from local files or cached registry data +### Requirement: Workset list and detail navigation + +The Project-first Worksets surface SHALL present containing Worksets as a compact list and SHALL reveal members and topology actions only after one Workset is selected. + +#### Scenario: Render containing Worksets as a list + +- **WHEN** trusted Workset navigation contains one or more Worksets for the current Project +- **THEN** the Worksets surface MUST show one row per containing Workset with name, member count, and saved or default tool label +- **AND** member rows MUST remain collapsed until the user selects a Workset + +#### Scenario: Open Workset detail + +- **WHEN** the user activates a Workset list row without activating its whole-Workset `Open` control +- **THEN** the Sidebar MUST enter detail for that Workset without launching an external opener +- **AND** the detail MUST show the Workset name, opener information, members, and role-appropriate actions + +#### Scenario: Return from detail + +- **WHEN** the user activates Back in Workset detail +- **THEN** the Sidebar MUST return to the containing Worksets list +- **AND** it MUST preserve the current Project and Planning root + +#### Scenario: Selected Workset disappears after refresh + +- **WHEN** the selected Workset is absent from a fresh official navigation snapshot +- **THEN** the Sidebar MUST return to the list and expose a recoverable stale-item explanation +- **AND** it MUST NOT retain actions from the removed Workset + +#### Scenario: Narrow Sidebar keyboard navigation + +- **WHEN** the list or detail is rendered in a narrow Sidebar +- **THEN** row navigation, whole-Workset open, Back, Project switch, and Planning Store selection MUST be independently keyboard operable with visible focus +- **AND** names and role labels MUST remain bounded without overlapping actions + ### Requirement: Project-only Workset selection -The Workset navigation scene SHALL distinguish selectable Project members from registered Store members, and SHALL never make a Store member a Project selection target. +The Workset detail SHALL distinguish selectable Project members from registered Store members, SHALL never make a Store member a Project selection target, and SHALL expose a Store member as a Planning-root action only after fresh Host-side validation. #### Scenario: Workset contains Project and Store members - **WHEN** a Workset includes canonical paths matching registered Store roots and other Project folders -- **THEN** Store members MUST be labeled `Planning Store` and rendered read-only -- **AND** only non-Store Project members MUST have selection controls +- **THEN** Store members MUST be labeled `Planning Store` and MUST NOT have Project selection controls +- **AND** only non-Store Project members MUST have Project selection controls +- **AND** a non-current Store member MAY expose `Use as planning root` only through a Host-validated Workset Store action + +#### Scenario: Current Planning Store is a Workset member + +- **WHEN** a Store member identifies the current Planning root +- **THEN** the detail MUST label it `Current root` +- **AND** it MUST NOT expose a redundant or disabled Store-selection action +- **AND** the surrounding Planning-root context MUST provide an explicit `Use project default` recovery action #### Scenario: Workset contains a same-repository Git worktree - **WHEN** a selectable member is a Git worktree of the same repository as another member -- **THEN** the Workset scene MUST show best-effort repository identity and branch metadata +- **THEN** the Workset detail MUST show best-effort repository identity and branch metadata - **AND** the member MUST remain a distinct selectable canonical Project path #### Scenario: Workset has no selectable Project members - **WHEN** all other members are registered Stores or invalid/unresolvable paths -- **THEN** the Workset scene MUST show a clear empty state +- **THEN** the Workset detail MUST show a clear no-other-Projects state - **AND** it MUST not offer a Store or invalid path as a Project action +#### Scenario: Store action uses stale or forged membership + +- **WHEN** the submitted Workset name/path is absent from a fresh official Workset and Store response, is not a Store, or cannot be canonicalized +- **THEN** the Host MUST reject the Planning-root request +- **AND** it MUST preserve the current Project, Planning root, binding, watcher, and visible data + ### Requirement: Host-validated Project switching Selecting a Workset Project SHALL be validated by the Extension Host against a fresh official Workset payload and a fresh CLI root resolution before the new Project is displayed. diff --git a/package.json b/package.json index 1f7829a..07df2b5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "openspec-workflow", "displayName": "OpenSpec", - "description": "Project-first OpenSpec dashboard for changes, specs, worksets, and safe workflow actions", - "version": "0.2.0", + "description": "Project-first OpenSpec dashboard for changes, specs, stores, worksets, and safe workflow actions", + "version": "0.2.1", "publisher": "randysss", "license": "MIT", "engines": { @@ -16,7 +16,9 @@ "spec-driven", "workflow", "dashboard", - "task management" + "task management", + "worksets", + "stores" ], "repository": { "type": "git", diff --git a/src/extension/providers/dashboardViewProvider.ts b/src/extension/providers/dashboardViewProvider.ts index bdca77e..b95fe71 100644 --- a/src/extension/providers/dashboardViewProvider.ts +++ b/src/extension/providers/dashboardViewProvider.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; import * as path from 'path'; +import { realpath } from 'fs/promises'; import { logger } from '../utils/logger'; +import { t } from '../../i18n'; import { DataManager, type CachedDashboardData, type DashboardData } from '../services/dataManager'; import { ProjectDataGateway } from '../services/projectDataGateway'; import type { OpenSpecCacheService, ProjectPageCacheKey } from '../services/openSpecCacheService'; @@ -28,10 +30,36 @@ type ProjectPageCache = Pick<OpenSpecCacheService, 'readProjectPage' | 'writePro type PendingExplorerContext = { message: ExtensionMessage; sent: boolean }; type ProjectSurface = 'sidebar' | 'dashboard'; +/** + * Terminal outcome of a Project Sidebar reload. `superseded` means a newer + * generation's reload took ownership: this reload published nothing and the + * caller must consult the newer reload's snapshot before judging results. + */ +type ProjectReloadOutcome = 'published' | 'superseded' | 'failed'; + +/** Reload-path options; `suppressFailurePosts` keeps failure reporting to the + * owning flow (e.g. Workset creation posts its own single recoverable result). */ +type ProjectReloadOptions = { suppressFailurePosts?: boolean }; + +/** + * A Project Planning-root selection in flight. `requestedStoreId` is the + * selector for this request only (`undefined` forces a selector-free load); + * `expectedCanonicalRoot` is the canonical root of the freshly validated Store + * member the accepted binding must match; `onAccepted` runs after every + * acceptance check passes and before any Provider state is replaced. + */ +type ProjectStoreSelectionRequest = { + readonly requestedStoreId?: string; + readonly expectedCanonicalRoot?: string; + readonly onAccepted?: (binding: OpenSpecRootBinding) => void; +}; + export class DashboardViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'openspec.dashboard'; private static readonly initialDataPostDelayMs = 100; private static readonly scopedPanelKeySeparator = '\u0000'; + /** Bound on following superseded reloads so continuous refreshes cannot loop forever. */ + private static readonly maxSupersededReloadFollows = 20; private _view?: vscode.WebviewView; private dashboardPanel?: vscode.WebviewPanel; private specPanels = new Map<string, vscode.WebviewPanel>(); @@ -43,7 +71,13 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { private cachedProjectSidebarData?: ProjectSidebarData; private readonly projectPageCache?: ProjectPageCache; private projectRequestGeneration = 0; + /** Newest in-flight (or settled) Project Sidebar reload; lets a superseded caller follow the chain. */ + private latestProjectReload?: Promise<ProjectReloadOutcome>; private skipNextProjectRefreshCallback = false; + /** Ephemeral, process-local explicit Planning Store selector for the current Project. */ + private explicitProjectStoreId?: string; + /** Single-flight lock: at most one Workset creation may run at a time. */ + private worksetCreateInFlight = false; constructor( private dataManager: DataManager, @@ -299,24 +333,70 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { : 'Project data load failed'; } + /** + * One recoverable `error` message for a failed Planning-root selection, on + * the same channel project-load and `openWorkset` failures use. State stays + * fail-closed: no snapshot is published and nothing else is replaced. + */ + private postSelectionError(targetWebview: vscode.Webview | undefined, error: unknown): void { + if (!targetWebview) return; + targetWebview.postMessage({ + type: 'error', + message: (error as Error | undefined)?.message + || t('worksetNavigation.planningRootSelectFailed'), + }); + } + + /** + * Reload and publish Project Sidebar data. The returned outcome tells the + * caller what happened: `superseded` reloads published nothing (a newer + * generation owns the result), `failed` reloads posted their own failure (or + * suppressed it via options), `published` refreshed the cached snapshot. + */ private async reloadProjectSidebarData( targetWebview?: vscode.Webview, targetSurface: ProjectSurface = 'sidebar', publishDashboard = true, - ): Promise<void> { - if (!this.projectContext || !this.projectDataGateway) return; + storeSelection?: ProjectStoreSelectionRequest, + options?: ProjectReloadOptions, + ): Promise<ProjectReloadOutcome> { + const reload = this.runProjectSidebarReload( + targetWebview, + targetSurface, + publishDashboard, + storeSelection, + options, + ); + // Recorded synchronously (the body's generation bump runs synchronously + // too, with no await in between) so superseded callers can always find the + // newest reload promise. + this.latestProjectReload = reload; + return reload; + } + + private async runProjectSidebarReload( + targetWebview: vscode.Webview | undefined, + targetSurface: ProjectSurface, + publishDashboard: boolean, + storeSelection: ProjectStoreSelectionRequest | undefined, + options: ProjectReloadOptions | undefined, + ): Promise<ProjectReloadOutcome> { + if (!this.projectContext || !this.projectDataGateway) return 'failed'; const generation = ++this.projectRequestGeneration; + // Capture the selector at request time; a selection-driven reload may + // override it, including forcing a selector-free load. + const requestedStoreId = storeSelection ? storeSelection.requestedStoreId : this.explicitProjectStoreId; try { const project = this.projectContext; const sidebarLoader = this.projectDataGateway.loadProjectSidebarData; let result: Awaited<ReturnType<ProjectDataGateway['loadProjectSidebarData']>>; let worksetNavigation: Awaited<ReturnType<ProjectDataGateway['loadWorksetNavigation']>> | undefined; if (typeof sidebarLoader === 'function') { - result = await sidebarLoader.call(this.projectDataGateway, project); + result = await sidebarLoader.call(this.projectDataGateway, project, requestedStoreId); } else { const navigationLoader = this.projectDataGateway.loadWorksetNavigation; const legacyResult = await Promise.all([ - this.projectDataGateway.loadChanges(project), + this.projectDataGateway.loadChanges(project, requestedStoreId), typeof navigationLoader === 'function' ? navigationLoader.call(this.projectDataGateway, project).catch((error: unknown) => { logger.warn('Failed to load Project Workset navigation', error as Error); @@ -331,26 +411,51 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { generation !== this.projectRequestGeneration || !this.sameProject(result.project, project) || result.binding.projectId !== project.id + || result.binding.commandCwd !== project.projectPath + || (requestedStoreId !== undefined && result.binding.storeId !== requestedStoreId) + || (storeSelection?.expectedCanonicalRoot !== undefined + && result.binding.rootPath !== storeSelection.expectedCanonicalRoot) ) { - return; + // A dropped refresh is permanently silent in the UI: log the expected + // versus actual context so the mismatch is diagnosable afterwards. + logger.warn( + `Project Sidebar refresh dropped by the acceptance gate: ` + + `generation ${generation} (current ${this.projectRequestGeneration}), ` + + `project ${result.project.id}@${result.binding.commandCwd} ` + + `(expected ${project.id}@${project.projectPath}), ` + + `storeId ${result.binding.storeId ?? 'none'} (requested ${requestedStoreId ?? 'none'}), ` + + `root ${result.binding.rootPath} ` + + `(expected ${storeSelection?.expectedCanonicalRoot ?? 'any'})` + ); + return 'superseded'; } const changes = result.changes.filter((change) => ( (change as { lifecycleStatus?: string }).lifecycleStatus !== 'archived' && !change.name.startsWith('archive:') )); + // Authoritative Workset-capability fact from the DataManager's CLI + // feature detection — the Gateway's navigation can be present-but-empty + // when the runtime lacks the capability, so emptiness is not a signal. + const worksetCapabilityAvailable = this.dataManager.getCapabilities?.()?.worksets === true; + const navigation = result.worksetNavigation ?? worksetNavigation; const data: ProjectSidebarData = { project: result.project, binding: result.binding, + // Authoritative explicit-selector fact from the Gateway; the fallback + // covers legacy gateway shapes that only receive the selector argument. + explicitStoreSelector: result.explicitStoreSelector ?? requestedStoreId !== undefined, changes, archivedChanges: result.archivedChanges ?? [], projectSpecs: result.projectSpecs ?? [], referencedStoreSpecs: result.referencedStoreSpecs ?? [], - ...(result.worksetNavigation?.worksets.length - ? { worksetNavigation: result.worksetNavigation } - : worksetNavigation?.worksets.length ? { worksetNavigation } : {}), + // Navigation publishes even when empty: an empty list with the Create + // entry is the primary first-Workset creation surface. + ...(navigation ? { worksetNavigation: navigation } : {}), + worksetCapabilityAvailable, workflowLaunchConfig: getWorkflowLaunchConfigMessage().config, lastRefresh: Date.now(), }; + storeSelection?.onAccepted?.(result.binding); this.currentProjectBinding = result.binding; this.cachedProjectSidebarData = data; this.publishProjectSnapshot( @@ -361,12 +466,29 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { publishDashboard, ); await this.writeProjectSidebarCache(data); + return 'published'; } catch (error) { - if (generation !== this.projectRequestGeneration) return; + if (generation !== this.projectRequestGeneration) return 'superseded'; + if (options?.suppressFailurePosts) { + // The owning flow (Workset creation) reports this failure itself as + // its single recoverable result; no generic error is posted here. + logger.warn('Suppressed Project Sidebar reload failure post', error as Error); + return 'failed'; + } + if (storeSelection) { + // A rejected Planning-root selection is fail-closed: the previous + // Project, binding, watcher, explicit selector, and visible snapshot + // stay untouched and no snapshot is published — but the user still + // gets one recoverable error instead of a silent no-op. + logger.warn('Rejected Project Planning-root selection', error as Error); + this.postSelectionError(targetWebview ?? this._view?.webview, error); + return 'failed'; + } const webview = targetWebview ?? this._view?.webview; - if (!webview) return; + if (!webview) return 'failed'; logger.error('Failed to load current Project Sidebar data', error as Error); this.postProjectLoadFailure(webview, error, targetSurface, publishDashboard); + return 'failed'; } } @@ -418,7 +540,10 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { let binding = this.currentProjectBinding; if (!binding) { try { - binding = await this.projectDataGateway.resolveBinding(this.projectContext); + binding = await this.projectDataGateway.resolveBinding( + this.projectContext, + this.explicitProjectStoreId + ); } catch { return undefined; } @@ -630,10 +755,26 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { await this.selectWorksetProject(message.worksetName, message.memberPath, webview); return; } + if (message.type === 'selectWorksetStore' && this.isProjectFirst()) { + await this.selectWorksetStore(message.worksetName, message.memberPath, webview); + return; + } + if (message.type === 'selectProjectDefaultRoot' && this.isProjectFirst()) { + await this.selectProjectDefaultRoot(webview); + return; + } if (message.type === 'selectCurrentProject' && this.isProjectFirst()) { await this.selectCurrentProject(webview); return; } + if (message.type === 'pickWorksetMembers' && this.isProjectFirst()) { + await this.pickWorksetMembers(webview); + return; + } + if (message.type === 'createWorkset' && this.isProjectFirst()) { + await this.submitWorksetCreation(message, webview); + return; + } if (message.type === 'openProjectDashboard' && this.isProjectFirst()) { this.openInEditor(); return; @@ -860,6 +1001,9 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { const resolveMember = this.projectDataGateway.resolveWorksetProject; if (typeof resolveMember !== 'function') return; + // Capture the request generation before any await: a superseded selection + // must not replace state or start a reload after a newer request committed. + const selectionGeneration = this.projectRequestGeneration; try { const currentProject = this.projectContext; const nextProject = await resolveMember.call( @@ -868,14 +1012,33 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { worksetName, memberPath, ); + if (selectionGeneration !== this.projectRequestGeneration) { + logger.warn('Discarded superseded Workset Project selection'); + return; + } if (!nextProject || this.sameProject(nextProject, currentProject)) return; - const nextBinding = await this.projectDataGateway.resolveBinding(nextProject); + // An explicit Planning Store selector stays active across Project members. + const nextBinding = await this.projectDataGateway.resolveBinding( + nextProject, + this.explicitProjectStoreId + ); + if (selectionGeneration !== this.projectRequestGeneration) { + logger.warn('Discarded superseded Workset Project selection'); + return; + } if ( nextBinding.projectId !== nextProject.id || nextBinding.commandCwd !== nextProject.projectPath + || (this.explicitProjectStoreId !== undefined + && nextBinding.storeId !== this.explicitProjectStoreId) ) return; - this.dataManager.setWatchedProjectRoot?.(nextProject.projectPath); + // While an explicit Store selector stays active, the displayed data comes + // from the store binding root: the watcher must follow that root, not the + // Project path, or store-root edits would never auto-refresh. + this.dataManager.setWatchedProjectRoot?.( + nextBinding.storeId !== undefined ? nextBinding.rootPath : nextProject.projectPath + ); this.projectContext = nextProject; this.currentProjectBinding = nextBinding; this.cachedProjectSidebarData = undefined; @@ -885,16 +1048,359 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { } } + /** + * Activate a Workset Planning Store as the explicit Planning root of the + * current Project. The submitted Workset name and member path are untrusted: + * the Gateway must re-validate them against fresh official inventories, and + * the selector, binding, and snapshot are replaced in one shot only when the + * returned binding matches the requested Project/Store context. + */ + private async selectWorksetStore( + worksetName: unknown, + memberPath: unknown, + targetWebview: vscode.Webview + ): Promise<void> { + if (!this.projectContext || !this.projectDataGateway + || typeof worksetName !== 'string' || typeof memberPath !== 'string') return; + const resolveStore = this.projectDataGateway.resolveWorksetStore; + if (typeof resolveStore !== 'function') return; + + // Capture the request generation BEFORE the fresh-inventory resolve: a + // slow resolve must not outrun a newer user action into the acceptance + // gate, otherwise an earlier click would override a later committed one. + const selectionGeneration = this.projectRequestGeneration; + try { + const validated = await resolveStore.call( + this.projectDataGateway, + this.projectContext, + worksetName, + memberPath, + ); + if (selectionGeneration !== this.projectRequestGeneration) { + logger.warn('Discarded superseded Workset Planning Store selection'); + return; + } + await this.reloadProjectSidebarData(targetWebview, 'sidebar', true, { + requestedStoreId: validated.storeId, + expectedCanonicalRoot: validated.canonicalRoot, + onAccepted: (binding) => { + this.explicitProjectStoreId = validated.storeId; + // The watcher follows the accepted Planning root in the same + // one-shot commit as the selector, binding, and snapshot. + this.dataManager.setWatchedProjectRoot?.(binding.rootPath); + }, + }); + } catch (error) { + // Fail-closed (no state replaced) but never silent: the row keeps its + // `Use as planning root` action, so the rejection needs a visible reason. + logger.warn('Rejected Workset Planning Store selection', error as Error); + // A resolve that rejects only after a newer request superseded it must + // stay silent: the newer request already owns the outcome, and a late + // error here would be spurious (though its text would be accurate). + if (selectionGeneration !== this.projectRequestGeneration) return; + this.postSelectionError(targetWebview, error); + } + } + + /** + * Open the native VS Code folder picker for the active creation form. The + * Host owns cross-platform canonicalization: every returned entry is + * realpath-resolved to its canonical absolute form (collapsing symlinks and + * duplicate picks of the same canonical root) before it is handed back to + * the webview. A dismissed picker stays silent. Entries whose realpath fails + * are reported in `droppedPaths` so the create form can explain the missing + * members recoverably — including when every pick was unresolvable. + */ + private async pickWorksetMembers(targetWebview: vscode.Webview): Promise<void> { + let picks: readonly vscode.Uri[] | undefined; + try { + picks = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectMany: true, + }); + } catch (error) { + // showOpenDialog returns a Throwing Thenable: a rejected picker must + // surface as one recoverable error, never a silent no-op. + logger.warn('Native folder picker failed', error as Error); + targetWebview.postMessage({ type: 'error', message: t('worksetCreate.pickFailed') }); + return; + } + if (!picks || picks.length === 0) return; + const canonicalPaths: string[] = []; + const droppedPaths: string[] = []; + for (const pick of picks) { + const rawPath = pick && typeof pick.fsPath === 'string' ? pick.fsPath : ''; + if (!rawPath) continue; + const canonicalPath = await this.canonicalizeWorksetMemberPath(rawPath); + if (canonicalPath) { + if (!canonicalPaths.includes(canonicalPath)) canonicalPaths.push(canonicalPath); + } else { + droppedPaths.push(rawPath); + } + } + if (canonicalPaths.length === 0 && droppedPaths.length === 0) return; + targetWebview.postMessage({ + type: 'worksetMembersPicked', + paths: canonicalPaths, + ...(droppedPaths.length ? { droppedPaths } : {}), + }); + } + + /** + * Canonicalize a folder path with the Node realpath idiom already used by + * the ProjectDataGateway: resolves symlinks and normalizes casing/segment + * separators per platform. Unresolvable paths return undefined so callers + * can reject them without ever guessing at an alternative location. + */ + private async canonicalizeWorksetMemberPath(rawPath: string): Promise<string | undefined> { + try { + const canonicalPath = await realpath(rawPath); + return canonicalPath || undefined; + } catch { + return undefined; + } + } + + /** + * Handle a `createWorkset` message. The payload is untrusted: primitive + * types, a non-empty trimmed name, absolute members, and a string tool are + * all verified BEFORE any CLI call, and flag-shaped values (`--…`) are + * rejected so nothing can smuggle extra argv into the official command. The + * Workset capability is verified against the DataManager's CLI feature + * detection (a present-but-empty navigation is NOT a capability signal). + * The Workset is created exclusively through the selector-free DataManager + * method; success changes the UI only after a generation-guarded Project + * Sidebar reload produced a fresh navigation that contains the new name — + * following newer in-flight reloads when this one was superseded, so a + * successful creation is never misreported. Every failure keeps the webview + * draft (nothing optimistic is published) and returns exactly one + * recoverable result message per submitted request. + */ + private async submitWorksetCreation(message: unknown, targetWebview: vscode.Webview): Promise<void> { + const request = (message ?? {}) as Record<string, unknown>; + const echoName = typeof request.name === 'string' ? request.name.trim() : ''; + + // Capability gate: the authoritative signal is the DataManager's cached + // CLI feature detection. The Gateway's navigation may be present-but-empty + // when the runtime lacks the capability (CLI errors are swallowed into an + // empty list), so navigation shape alone cannot gate creation. A missing + // trusted navigation still fails closed the same way. + const worksetsSupported = this.dataManager.getCapabilities?.()?.worksets === true; + if (!worksetsSupported || !this.cachedProjectSidebarData?.worksetNavigation) { + logger.warn('Rejected Workset creation: Workset capability is unavailable'); + targetWebview.postMessage({ + type: 'worksetCreateResult', + success: false, + name: echoName, + message: t('worksetCreate.capabilityUnavailable'), + }); + return; + } + + // Single-flight lock: check and set without an await in between, so a + // duplicate submission racing this handler can never create twice. The + // duplicate still receives exactly one recoverable result. + if (this.worksetCreateInFlight) { + logger.warn('Rejected duplicate Workset creation while one is already in flight'); + targetWebview.postMessage({ + type: 'worksetCreateResult', + success: false, + name: echoName, + message: t('worksetCreate.inProgress'), + }); + return; + } + this.worksetCreateInFlight = true; + try { + const name = typeof request.name === 'string' ? request.name.trim() : ''; + if (!name || name.startsWith('-')) { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + if (!Array.isArray(request.members) || request.members.length === 0) { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + let tool: string | undefined; + if (request.tool !== undefined) { + if (typeof request.tool !== 'string') { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + tool = request.tool.trim(); + if (tool.startsWith('-')) { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + // Empty after trim: treated as absent. + if (!tool) tool = undefined; + } + + // Members must be absolute paths; canonicalization happens host-side + // right before submission so the CLI receives canonical paths in the + // submitted (Primary-first) order, one entry per canonical path. + const canonicalMembers: string[] = []; + for (const rawMember of request.members) { + if (typeof rawMember !== 'string' || !path.isAbsolute(rawMember)) { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + const canonicalMember = await this.canonicalizeWorksetMemberPath(rawMember); + if (!canonicalMember) { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + if (!canonicalMembers.includes(canonicalMember)) canonicalMembers.push(canonicalMember); + } + if (canonicalMembers.length === 0) { + this.postWorksetCreateRejection(targetWebview, echoName); + return; + } + + await this.dataManager.createWorkset(name, canonicalMembers, tool); + + // Official refresh through the existing generation-guarded reload path. + // Its failure posts are suppressed: this flow reports the reload failure + // itself as the single recoverable createResult. + const surface = targetWebview === this.dashboardPanel?.webview ? 'dashboard' : 'sidebar'; + let reloadOutcome = await this.reloadProjectSidebarData( + targetWebview, + surface, + true, + undefined, + { suppressFailurePosts: true }, + ); + // A superseded reload published nothing (e.g. the user hit Refresh or + // another surface reloaded). Follow the newest in-flight/queued reload — + // bounded, chained while that one is superseded too — and judge the name + // against the finally-published snapshot instead of the stale cache. An + // exhausted chain means no snapshot was ever confirmed for this create: + // fail recoverably instead of guessing on a stale cache. + let follows = 0; + while ( + reloadOutcome === 'superseded' + && follows < DashboardViewProvider.maxSupersededReloadFollows + ) { + follows += 1; + const latest = this.latestProjectReload; + reloadOutcome = latest ? await latest : 'failed'; + } + if (reloadOutcome !== 'published') { + logger.warn(`Created Workset ${name} could not be confirmed: the Project refresh failed`); + targetWebview.postMessage({ + type: 'worksetCreateResult', + success: false, + name, + message: t('worksetCreate.refreshFailed', { name }), + }); + return; + } + + const fresh = this.cachedProjectSidebarData; + const listed = fresh?.worksetNavigation?.worksets.some( + (workset) => workset.name === name, + ); + if (fresh && listed) { + targetWebview.postMessage({ type: 'worksetCreateResult', success: true, name }); + return; + } + // The CLI succeeded but the fresh snapshot does not contain the new + // Workset (e.g. the current Project is not one of its members): never + // fabricate a detail view. The draft stays with a recoverable message. + logger.warn(`Created Workset ${name} is missing from the refreshed Project navigation`); + targetWebview.postMessage({ + type: 'worksetCreateResult', + success: false, + name, + message: t('worksetCreate.refreshMissing', { name }), + }); + } catch (error) { + logger.warn('Workset creation failed', error as Error); + targetWebview.postMessage({ + type: 'worksetCreateResult', + success: false, + name: echoName, + message: this.worksetCreateFailureMessage(error), + }); + } finally { + this.worksetCreateInFlight = false; + } + } + + /** + * CLI failure copy: the error message plus a sanitized single-line excerpt + * of the official CLI stderr (OpenSpecCliError carries it). Whitespace is + * collapsed and the excerpt truncated (~200 chars) so a multi-line CLI dump + * cannot flood the recoverable notice; only CLI-printed text is echoed. + */ + private worksetCreateFailureMessage(error: unknown): string { + const base = this.errorMessage(error) || t('worksetCreate.createFailedDefault'); + const stderr = typeof error === 'object' && error !== null + ? (error as { stderr?: unknown }).stderr + : undefined; + if (typeof stderr !== 'string') return base; + const excerpt = stderr.replace(/\s+/g, ' ').trim().slice(0, 200); + return excerpt ? `${base}: ${excerpt}` : base; + } + + private postWorksetCreateRejection(targetWebview: vscode.Webview, echoName: string): void { + targetWebview.postMessage({ + type: 'worksetCreateResult', + success: false, + name: echoName, + message: t('worksetCreate.rejectedInput'), + }); + } + + /** + * Return to the Project-resolved Planning root. The ephemeral selector is + * cleared only after a fresh selector-free binding for the same Project + * resolves and validates; failures keep the previous root and data. + */ + private async selectProjectDefaultRoot(targetWebview: vscode.Webview): Promise<void> { + if (!this.projectContext || !this.projectDataGateway) return; + if (this.explicitProjectStoreId === undefined) return; + const project = this.projectContext; + await this.reloadProjectSidebarData(targetWebview, 'sidebar', true, { + requestedStoreId: undefined, + onAccepted: (binding) => { + this.explicitProjectStoreId = undefined; + // Same selector rule as the other selections: when the selector-free + // default still resolves to a Store root (CLI root.store_id), the + // watcher must follow the restored binding root, not the Project path. + this.dataManager.setWatchedProjectRoot?.( + binding.storeId !== undefined ? binding.rootPath : project.projectPath + ); + }, + }); + } + private async selectCurrentProject(targetWebview: vscode.Webview): Promise<void> { if (!this.projectContext || !this.projectDataGateway || !this.originProjectContext) return; if (this.sameProject(this.projectContext, this.originProjectContext)) return; + // Capture the request generation before the await so a superseded restore + // cannot replace state or start a reload after a newer request committed. + const selectionGeneration = this.projectRequestGeneration; try { - const nextBinding = await this.projectDataGateway.resolveBinding(this.originProjectContext); + const nextBinding = await this.projectDataGateway.resolveBinding( + this.originProjectContext, + this.explicitProjectStoreId + ); + if (selectionGeneration !== this.projectRequestGeneration) { + logger.warn('Discarded superseded Current Project restore'); + return; + } if ( nextBinding.projectId !== this.originProjectContext.id || nextBinding.commandCwd !== this.originProjectContext.projectPath + || (this.explicitProjectStoreId !== undefined + && nextBinding.storeId !== this.explicitProjectStoreId) ) return; - this.dataManager.setWatchedProjectRoot?.(this.originProjectContext.projectPath); + // Same selector rule as Workset Project selection: with an explicit Store + // selector active, the watcher follows the accepted binding root. + this.dataManager.setWatchedProjectRoot?.( + nextBinding.storeId !== undefined ? nextBinding.rootPath : this.originProjectContext.projectPath + ); this.projectContext = this.originProjectContext; this.currentProjectBinding = nextBinding; this.cachedProjectSidebarData = undefined; diff --git a/src/extension/providers/webviewMessageHandler.ts b/src/extension/providers/webviewMessageHandler.ts index ac05a7a..ebf4323 100644 --- a/src/extension/providers/webviewMessageHandler.ts +++ b/src/extension/providers/webviewMessageHandler.ts @@ -1364,7 +1364,16 @@ export async function handleWebviewMessage( case 'openWorkset': { try { - await dataManager.openWorkset(message.name); + // One-time opener override: forwarded as an explicit trimmed tool id; + // absent/blank values fall back to the saved/default Workset tool. + const tool = typeof message.tool === 'string' && message.tool.trim() + ? message.tool.trim() + : undefined; + if (tool) { + await dataManager.openWorkset(message.name, tool); + } else { + await dataManager.openWorkset(message.name); + } } catch (error) { logger.error('openWorkset message failed', error as Error); postError(error, t('worksetsPage.openFailed', { name: message.name })); diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index 6650633..b06c008 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -335,8 +335,42 @@ export class DataManager { return executor; } - async openWorkset(name: string): Promise<void> { - await this.cliService.runCommand(['workset', 'open', name]); + async openWorkset(name: string, tool?: string): Promise<void> { + // One-time tool override: appended only when non-empty after trim. The saved + // Workset tool is never mutated by an override. Ordinary open output stays + // on the plain runner — it must never pass JSON parsing. + const trimmedTool = tool?.trim(); + const args = trimmedTool + ? ['workset', 'open', name, '--tool', trimmedTool] + : ['workset', 'open', name]; + await this.cliService.runCommand(args); + } + + /** + * Create a Workset through the official JSON CLI command. Members are passed + * verbatim in input order as repeated --member flags (the first entry is the + * primary member); the service never reorders or dedupes them. The optional + * tool is a one-time value for this invocation only. Workset argv never + * includes --store: Workset mutation is independent from the current root. + * + * Input validation boundary: this layer performs no payload validation — an + * empty `members` array is passed through verbatim (zero --member flags) and + * non-string entries are not checked. Payload validation is the caller's + * responsibility (the webview host-side guard). + */ + async createWorkset(name: string, members: string[], tool?: string): Promise<unknown> { + const args: string[] = ['workset', 'create', name]; + for (const member of members) { + args.push('--member', member); + } + const trimmedTool = tool?.trim(); + if (trimmedTool) { + args.push('--tool', trimmedTool); + } + args.push('--json'); + // Mutation command: deterministic failures (e.g. duplicate Workset name) + // must surface immediately instead of burning the default retry backoff. + return await this.cliService.runJson(args, { retries: 1 }); } /** diff --git a/src/extension/services/openspecCli.ts b/src/extension/services/openspecCli.ts index 6c50f61..7c13d1d 100644 --- a/src/extension/services/openspecCli.ts +++ b/src/extension/services/openspecCli.ts @@ -1,4 +1,6 @@ import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; import * as vscode from 'vscode'; import { logger } from '../utils/logger'; import { t } from '../../i18n'; @@ -15,7 +17,7 @@ import { OpenSpecStoreListResult, OpenSpecWorksetListResult, } from './types'; -import { OpenSpecCliResolver, OpenSpecCliResolutionError } from './openspecCliResolver'; +import { OpenSpecCliResolver, OpenSpecCliResolutionError, type ResolvedOpenSpecRuntime } from './openspecCliResolver'; import type { OpenSpecScope } from './openspecScope'; import { buildCliActivationDiagnostic, @@ -33,16 +35,63 @@ import { const MINIMUM_OPENSPEC_VERSION = '1.0.0'; +/** + * Launcher extensions CreateProcess can execute with `shell: false` on Windows. + * npm global directories also emit an extension-less bash shim and a `.ps1` + * launcher alongside `openspec.cmd`; neither can be spawned without a shell. + */ +const WINDOWS_SPAWNABLE_EXTENSIONS = ['.exe', '.cmd', '.bat']; + export interface ScopeOption { /** When set, root-resolving commands append `--store <storeId>`. */ storeId?: string; } +/** Shell-free spawn target for the Windows non-local-source branch. */ +interface WindowsSpawnTarget { + command: string; + argsPrefix: string[]; +} + +/** + * Extract the Node.js entry script from an npm-style cmd-shim (`.cmd`/`.bat`). + * + * npm launchers end with an invocation line like + * `node "%~dp0\node_modules\<pkg>\bin\entry.js" %*` — older shims inline `node` + * or `"%~dp0\node.exe"` as the interpreter, npm >= 9 shims use a `"%_prog%"` + * variable that resolves to one of those. The entry is the quoted + * `%~dp0`-rooted token ending in .js/.mjs/.cjs on a line that forwards + * arguments via `%*`. Returns the entry path relative to the shim directory + * (to be joined with the shim's own directory), or undefined when no known + * invocation shape matches — callers must fail closed in that case rather + * than hand the launcher to cmd.exe, whose parsing would corrupt verbatim + * arguments (notably `%VAR%` sequences, which cannot be escaped at all). + * + * Known limitation (accepted): any line echoing a quoted `%~dp0`-rooted .js + * path together with `%*` matches, not only the real invocation line. Real + * npm cmd-shims never emit such decoy lines, and a decoy would still resolve + * inside the shim's own package directory. + */ +export function parseWindowsCmdShimEntry(content: string): string | undefined { + for (const line of content.split(/\r?\n/)) { + if (!line.includes('%*')) continue; + const tokenPattern = /"(?:%~dp0|%dp0%)(?:[\\/])([^"]+?)"/gi; + let match: RegExpExecArray | null; + while ((match = tokenPattern.exec(line)) !== null) { + const relative = match[1]; + if (/\.(?:js|mjs|cjs)$/i.test(relative)) return relative; + } + } + return undefined; +} + export class OpenSpecCliService { private workspaceRoot: string; private resolver: OpenSpecCliResolver; private cliActivationDiagnostic: CliActivationDiagnostic | null = null; private shownCliDiagnosticKeys = new Set<string>(); + /** Resolved shell-free spawn targets for Windows launcher commands. */ + private windowsSpawnTargets = new Map<string, WindowsSpawnTarget>(); constructor(workspaceRoot: string, resolver?: OpenSpecCliResolver) { this.workspaceRoot = workspaceRoot; @@ -528,14 +577,14 @@ export class OpenSpecCliService { * Execute an OpenSpec CLI command expecting JSON output. * Resolves the runtime, prepends argsPrefix, runs with retry logic, and parses the result as JSON. */ - async runJson(args: string[]): Promise<unknown> { - const output = await this.execOpenSpec(args); + async runJson(args: string[], options: { retries?: number } = {}): Promise<unknown> { + const output = await this.execOpenSpec(args, options.retries ?? 3); return JSON.parse(output); } /** Execute a command whose stdout is intentionally ordinary text. */ - async runCommand(args: string[]): Promise<string> { - return await this.execOpenSpec(args); + async runCommand(args: string[], options: { retries?: number } = {}): Promise<string> { + return await this.execOpenSpec(args, options.retries ?? 3); } /** @@ -609,18 +658,32 @@ export class OpenSpecCliService { */ private async execOpenSpecOnce(args: string[], timeoutMs: number): Promise<string> { const runtime = await this.resolver.resolveRuntime(); - const fullArgs = [...runtime.argsPrefix, ...args]; const isLocalSource = runtime.source === 'localSource'; + // Windows + non-local-source: never route through cmd.exe. Shell parsing would + // corrupt arguments that must reach the CLI verbatim — a workset member path + // like C:\work\%USERNAME%\repo must not be %-expanded, and `%VAR%` cannot be + // escaped under `cmd /c` at all. .cmd/.bat launchers are instead resolved to + // their underlying `node <entry.js>` invocation, and every case spawns + // shell-free so all argv elements (spaces, metacharacters, `%VAR%`, unicode) + // pass through byte-identical. Unresolvable launchers fail closed. + const windowsShellFree = !isLocalSource && process.platform === 'win32'; + + // Non-Windows and local-source modes stay byte-identical (no transformation). + let command = runtime.command; + let fullArgs = [...runtime.argsPrefix, ...args]; + + if (windowsShellFree) { + const target = await this.resolveWindowsSpawnTarget(runtime); + command = target.command; + fullArgs = [...target.argsPrefix, ...runtime.argsPrefix, ...args]; + } return new Promise((resolve, reject) => { - const proc = spawn(runtime.command, fullArgs, { + const proc = spawn(command, fullArgs, { cwd: this.workspaceRoot, env: runtime.env, - // Windows: npm global installs `openspec.cmd`; `spawn` without shell often fails with - // ENOENT in Electron/Cursor when PATH is resolved differently than in a terminal. - // For local source mode (node + bin/openspec.js), never use shell. - shell: !isLocalSource && process.platform === 'win32', - windowsHide: !isLocalSource && process.platform === 'win32', + shell: false, + windowsHide: windowsShellFree, }); let stdout = ''; @@ -650,6 +713,7 @@ export class OpenSpecCliService { proc.on('error', (error) => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { this.resolver.clearCache(); + this.windowsSpawnTargets.clear(); } reject(new Error(`Failed to spawn openspec: ${error.message}`)); }); @@ -665,6 +729,157 @@ export class OpenSpecCliService { }); } + /** + * Resolve how to spawn the resolved CLI command on Windows without cmd.exe. + * + * Fail-closed preference order (no silent fallback to shell parsing): + * 1. `.cmd`/`.bat` launcher → read the npm-style shim and resolve it to + * `<node> <shim-dir-resolved entry.js>`; unresolvable shims throw. + * 2. `.exe` (or extensionless fallback) → spawn directly; Windows + * CreateProcess resolves PATH itself and performs no shell parsing. + * A bare extensionless command is first located via `where.exe`, because a + * shell would match it against PATHEXT (finding e.g. `openspec.cmd`) while + * CreateProcess only probes `<name>.exe`. Only `.exe`/`.cmd`/`.bat` hits are + * accepted — npm global directories also list an extension-less bash shim + * and a `.ps1` launcher, which CreateProcess cannot execute without a + * shell — and resolution fails closed when no where.exe line qualifies. + * PowerShell launchers cannot be spawned without a shell and are rejected + * explicitly. + * + * `path.*` calls are host-native — identical to `path.win32` on Windows + * hosts — so extension/path handling uses real Windows semantics wherever + * this branch can execute. + */ + private async resolveWindowsSpawnTarget(runtime: ResolvedOpenSpecRuntime): Promise<WindowsSpawnTarget> { + const cacheKey = runtime.command; + const cached = this.windowsSpawnTargets.get(cacheKey); + if (cached) return cached; + + let command = runtime.command; + if (path.extname(command) === '') { + const located = await this.whereFirstSpawnableMatch(command); + if (!located) { + throw new OpenSpecCliResolutionError( + `No spawnable OpenSpec CLI launcher (${WINDOWS_SPAWNABLE_EXTENSIONS.join('/')}) was found on PATH for "${runtime.command}". npm installs also emit extension-less and PowerShell launchers, which cannot be spawned without a shell. Point openspec.cliPath at the .cmd launcher or a direct executable.`, + [...runtime.diagnostics] + ); + } + command = located; + } + + const ext = path.extname(command).toLowerCase(); + let target: WindowsSpawnTarget; + if (ext === '.cmd' || ext === '.bat') { + target = await this.resolveWindowsShimTarget(command, runtime); + } else if (ext === '.ps1') { + throw new OpenSpecCliResolutionError( + `OpenSpec CLI PowerShell launchers cannot be spawned without a shell: ${command}. Point openspec.cliPath at the .cmd launcher or a direct executable.`, + [...runtime.diagnostics] + ); + } else { + target = { command, argsPrefix: [] }; + } + + this.windowsSpawnTargets.set(cacheKey, target); + return target; + } + + /** Read an npm-style .cmd/.bat launcher and resolve its node invocation. */ + private async resolveWindowsShimTarget( + shimPath: string, + runtime: ResolvedOpenSpecRuntime + ): Promise<WindowsSpawnTarget> { + const diagnostics = [...runtime.diagnostics]; + + let content: string; + try { + content = await fs.promises.readFile(shimPath, 'utf8'); + } catch (error) { + throw new OpenSpecCliResolutionError( + `OpenSpec CLI launcher could not be read: ${shimPath} (${(error as Error).message})`, + diagnostics + ); + } + + const entryRelative = parseWindowsCmdShimEntry(content); + if (!entryRelative) { + throw new OpenSpecCliResolutionError( + `OpenSpec CLI launcher could not be resolved to a Node.js invocation (unsupported .cmd/.bat format): ${shimPath}`, + diagnostics + ); + } + + const shimDir = path.dirname(shimPath); + const nodeCommand = this.resolveWindowsNodeCommand(shimDir); + return { + command: nodeCommand, + argsPrefix: [path.join(shimDir, entryRelative)], + }; + } + + /** + * Locate the node executable the shim itself would have invoked: npm shims + * prefer a `node.exe` bundled next to them, and that direct `.exe` path is + * safe to spawn. Otherwise the bare command `node` is returned — CreateProcess + * then resolves `node.exe` via PATH with correct Unicode semantics. + * `where.exe` is deliberately NOT consulted here: its first hit may be a + * `node.cmd`/`node.bat` wrapper (unspawnable with shell:false), and its pipe + * output is OEM-codepage decoded, so non-ASCII install paths (e.g. zh-cn + * usernames) mojibake into ENOENT at spawn time. + */ + private resolveWindowsNodeCommand(shimDir: string): string { + const bundled = path.join(shimDir, 'node.exe'); + if (fs.existsSync(bundled)) return bundled; + return 'node'; + } + + /** + * First `where.exe <name>` hit (PATH + PATHEXT resolution) whose extension + * CreateProcess can execute without a shell (`.exe`/`.cmd`/`.bat`), or + * undefined when the lookup fails or offers no spawnable line. Skipped + * entries include npm's extension-less bash shim and `.ps1` launchers. + */ + private whereFirstSpawnableMatch(name: string): Promise<string | undefined> { + return new Promise((resolve) => { + let stdout = ''; + let settled = false; + let timeout: NodeJS.Timeout | undefined; + const finish = (value: string | undefined) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + resolve(value); + }; + // Match resolver/main-spawn semantics: probe PATH from the workspace + // root, not the extension-host process cwd. + const proc = spawn('where.exe', [name], { + shell: false, + windowsHide: true, + cwd: this.workspaceRoot, + }); + timeout = setTimeout(() => { + proc.kill(); + finish(undefined); + }, 5000); + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + proc.on('error', () => finish(undefined)); + proc.on('close', (code) => { + if (code !== 0) { + finish(undefined); + return; + } + const first = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .find((line) => WINDOWS_SPAWNABLE_EXTENSIONS.includes(path.win32.extname(line).toLowerCase())); + finish(first); + }); + }); + } + /** Normalize CLI artifact list (unknown[]) to ArtifactStatus[]; CLI may use 'complete' for done. */ private normalizeArtifactStatuses(raw: unknown[]): ArtifactStatus[] { return this.normalizeArtifactInfos(raw) as ArtifactStatus[]; diff --git a/src/extension/services/projectDataGateway.ts b/src/extension/services/projectDataGateway.ts index cd7f685..2bee221 100644 --- a/src/extension/services/projectDataGateway.ts +++ b/src/extension/services/projectDataGateway.ts @@ -23,6 +23,7 @@ import { type WorksetGitMetadata, type WorksetNavigationEntry, type WorksetNavigationMember, + type WorksetStoreResolution, } from './types'; type ProjectCli = Pick<OpenSpecCliService, 'getContext'> & @@ -34,17 +35,22 @@ type BoundReaders = { readonly cli: ProjectCli; readonly contentAccess: BoundContentAccess; readonly scope?: ScopeOption; + /** True only when an explicit Store selector was actually passed for this binding. */ + readonly explicitStoreSelector: boolean; }; type BoundContentAccess = Pick<FileManagerService, 'listArchivedChanges'> & Partial<Pick<FileManagerService, 'readArtifact'>>; -function safeReferencedStoreSpecsError(storeId: string): string { - const safeStoreId = storeId +function safeToken(value: string): string { + return value .replace(/[\r\n\t]/g, ' ') .replace(/[^a-zA-Z0-9._:@-]/g, '_') .slice(0, 80); - return `Unable to load Specs for referenced Store "${safeStoreId}".`; +} + +function safeReferencedStoreSpecsError(storeId: string): string { + return `Unable to load Specs for referenced Store "${safeToken(storeId)}".`; } export interface ProjectDataGatewayOptions { @@ -106,18 +112,23 @@ export class ProjectDataGateway { private readonly readGitMetadata: (projectPath: string) => Promise<WorksetGitMetadata>; async loadWorksetNavigation(project: ProjectContext): Promise<ProjectWorksetNavigationData> { - const empty: ProjectWorksetNavigationData = { project, worksets: [] }; - return this.loadWorksetNavigationFromCli(project, this.createCli(project.projectPath), empty); + return this.loadWorksetNavigationFromCli(project, this.createCli(project.projectPath)); } private async loadWorksetNavigationFromCli( project: ProjectContext, cli: ProjectCli, - empty: ProjectWorksetNavigationData = { project, worksets: [] }, ): Promise<ProjectWorksetNavigationData> { const currentPath = await this.canonicalizeMemberPath(project.projectPath); - if (!currentPath) return empty; - + if (!currentPath) return { project, worksets: [] }; + // The navigation's Project identity must be canonical: the webview locks + // the current Project member in the creation form by this path, and the + // folder picker returns canonical paths — a non-canonical alias here would + // show two rows for one folder and break current-member detection. + const canonicalProject: ProjectContext = project.projectPath === currentPath && project.id === currentPath + ? project + : { ...project, id: currentPath, projectPath: currentPath }; + const empty: ProjectWorksetNavigationData = { project: canonicalProject, worksets: [] }; if (!cli.listWorksets) return empty; let worksetPayload: unknown; @@ -159,7 +170,7 @@ export class ProjectDataGateway { }); } - return { project, worksets }; + return { project: canonicalProject, worksets }; } /** @@ -180,6 +191,88 @@ export class ProjectDataGateway { return member?.role === 'project' && member.selectable ? member.project : undefined; } + /** + * Re-read official Workset and Store inventories before accepting a Planning + * root selection. The Webview-submitted Workset name and member path are + * hints only: the member must still be a registered Store member of the named + * Workset in fresh inventories, canonicalized before comparison. Store ids + * supplied by the Webview are never trusted and no path is guessed; every + * rejection fails closed with a resolve-phase ProjectDataAccessError. + */ + async resolveWorksetStore( + project: ProjectContext, + worksetName: string, + memberPath: string + ): Promise<WorksetStoreResolution> { + if (typeof worksetName !== 'string' || !worksetName.trim()) { + throw this.resolveError(project, 'Workset name is required to select a Planning Store member'); + } + if (typeof memberPath !== 'string') { + throw this.resolveError(project, 'Workset Store member path must be a string'); + } + const canonicalMemberPath = await this.canonicalizeMemberPath(memberPath); + if (!canonicalMemberPath) { + throw this.resolveError(project, 'Workset Store member path cannot be canonicalized'); + } + + const cli = this.createCli(project.projectPath); + const storeRoots = await this.loadCanonicalStoreRoots(cli); + if (!storeRoots) { + throw this.resolveError(project, 'Planning Store inventory is unavailable'); + } + const storeId = storeRoots.get(canonicalMemberPath); + if (!storeId) { + throw this.resolveError( + project, + `Workset member is not a registered Planning Store: ${safeToken(memberPath)}` + ); + } + + if (!cli.listWorksets) { + throw this.resolveError(project, 'Workset inventory is unavailable'); + } + let worksetPayload: unknown; + try { + worksetPayload = await cli.listWorksets(); + } catch (cause) { + throw new ProjectDataAccessError( + 'Workset inventory is unavailable', + project.id, + 'resolve', + undefined, + cause + ); + } + const rawWorksets = this.asArray(worksetPayload, 'worksets'); + let memberConfirmed = false; + for (const rawWorkset of rawWorksets) { + if (!rawWorkset || typeof rawWorkset !== 'object') continue; + const worksetRecord = rawWorkset as Record<string, unknown>; + if (worksetRecord.name !== worksetName) continue; + const rawMembers = Array.isArray(worksetRecord.members) ? worksetRecord.members : []; + for (const rawMember of rawMembers) { + if (!rawMember || typeof rawMember !== 'object') continue; + const memberRecord = rawMember as Record<string, unknown>; + const rawMemberPath = typeof memberRecord.path === 'string' ? memberRecord.path.trim() : ''; + if (!rawMemberPath) continue; + const canonicalPath = await this.canonicalizeMemberPath(rawMemberPath); + if (canonicalPath === canonicalMemberPath) { + memberConfirmed = true; + break; + } + } + break; + } + if (!memberConfirmed) { + throw this.resolveError( + project, + `Workset ${safeToken(worksetName)} does not include the submitted Planning Store member` + ); + } + + return { storeId, canonicalRoot: canonicalMemberPath }; + } + private async loadCanonicalStoreRoots(cli: ProjectCli): Promise<Map<string, string> | undefined> { if (!cli.listStores) return undefined; let storePayload: unknown; @@ -370,10 +463,13 @@ export class ProjectDataGateway { } } - async loadProjectSidebarData(project: ProjectContext): Promise<ProjectSidebarWorkspaceData> { + async loadProjectSidebarData( + project: ProjectContext, + explicitStoreId?: string + ): Promise<ProjectSidebarWorkspaceData> { let binding: OpenSpecRootBinding | undefined; try { - const readers = await this.bind(project); + const readers = await this.bind(project, explicitStoreId); binding = readers.binding; if (!readers.cli.listChanges || !readers.cli.listSpecs) { throw new Error('Bound CLI does not support Project Sidebar data'); @@ -390,6 +486,7 @@ export class ProjectDataGateway { return { project, binding, + explicitStoreSelector: readers.explicitStoreSelector, changes: await this.enrichChangesWithProposalWhy( this.bindWorkflowSnapshots(rawChanges, binding), readers.contentAccess @@ -477,11 +574,19 @@ export class ProjectDataGateway { private async resolveBindingContext( project: ProjectContext, explicitStoreId?: string - ): Promise<{ binding: OpenSpecRootBinding; context: OpenSpecContextResult }> { + ): Promise<{ + binding: OpenSpecRootBinding; + context: OpenSpecContextResult; + explicitStoreSelector: boolean; + }> { if (explicitStoreId !== undefined && typeof explicitStoreId !== 'string') { throw this.resolveError(project, 'Explicit Store selector must be a string'); } const storeId = explicitStoreId?.trim() ? explicitStoreId : undefined; + // Authoritative "explicit selector active" fact: a binding may carry a + // storeId inherited from the CLI's root.store_id (the project default root + // IS a Store root) without any explicit selector having been passed. + const explicitStoreSelector = storeId !== undefined; const scope: ScopeOption | undefined = storeId ? { storeId } : undefined; let context: OpenSpecContextResult; @@ -561,7 +666,7 @@ export class ProjectDataGateway { throw this.resolveError(project, 'OpenSpec content path escapes the resolved root', binding); } - return { binding, context }; + return { binding, context, explicitStoreSelector }; } private async bind(project: ProjectContext, explicitStoreId?: string): Promise<BoundReaders> { @@ -570,7 +675,14 @@ export class ProjectDataGateway { const scope = binding.storeId ? { storeId: binding.storeId } : undefined; const cli = this.createCli(binding.commandCwd); const contentAccess = this.createContentAccess(path.join(binding.rootPath, 'openspec')); - return { binding, context: resolved.context, cli, contentAccess, scope }; + return { + binding, + context: resolved.context, + cli, + contentAccess, + scope, + explicitStoreSelector: resolved.explicitStoreSelector, + }; } private async enrichChangesWithProposalWhy( diff --git a/src/extension/services/types.ts b/src/extension/services/types.ts index 29ccd12..b58651d 100644 --- a/src/extension/services/types.ts +++ b/src/extension/services/types.ts @@ -99,6 +99,16 @@ export interface ProjectWorksetNavigationData { readonly worksets: readonly WorksetNavigationEntry[]; } +/** + * Fresh-validated Workset Planning Store identity. `storeId` is the id from a + * freshly read official Store inventory; `canonicalRoot` is the canonicalized + * registered root of that Store member. Webview-submitted ids are never trusted. + */ +export interface WorksetStoreResolution { + readonly storeId: string; + readonly canonicalRoot: string; +} + export interface ProjectContext { readonly id: string; readonly label: string; @@ -171,11 +181,25 @@ export interface ProjectReferencedStoreSpecsData { export interface ProjectSidebarWorkspaceData { readonly project: ProjectContext; readonly binding: OpenSpecRootBinding; + /** + * True only when an explicit Planning Store selector drove this binding. + * A selector-free default binding may still carry `binding.storeId` when the + * CLI's `root.store_id` is set (the project default root IS a Store root); + * the webview must gate selector-dependent recovery on this flag, never on + * `binding.storeId`. + */ + readonly explicitStoreSelector?: boolean; readonly changes: readonly ChangeInfo[]; readonly archivedChanges?: readonly ArchivedChangeInfo[]; readonly projectSpecs?: readonly SpecInfo[]; readonly referencedStoreSpecs?: readonly ReferencedStoreSpecGroup[]; readonly worksetNavigation?: ProjectWorksetNavigationData; + /** + * Host-resolved Workset capability fact (from DataManager.getCapabilities()). + * `true`/`false` gate Workset creation affordances; `undefined` only appears + * in legacy cached payloads and is treated as available by the webview. + */ + readonly worksetCapabilityAvailable?: boolean; } export interface ArtifactStatus { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 68a19c5..3885005 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -284,6 +284,47 @@ "worksetNavigation.current": "Current", "worksetNavigation.unavailable": "Unavailable", "worksetNavigation.noSelectableProjects": "No selectable Projects in this Workset.", + "worksetNavigation.openDetail": "View Workset {name}", + "worksetNavigation.backToList": "Back to Worksets", + "worksetNavigation.savedTool": "Saved tool: {tool}", + "worksetNavigation.defaultTool": "Default opener", + "worksetNavigation.staleDetailNotice": "Workset {name} is no longer available (it may have been removed). Showing the Workset list instead.", + "worksetNavigation.openAll": "Open all", + "worksetNavigation.openWithTool": "Open with another tool", + "worksetNavigation.oneTimeOpenerLabel": "Custom opener id", + "worksetNavigation.oneTimeOpenerSubmit": "Open with this tool", + "worksetNavigation.toolShortcut": "Use {tool} opener", + "worksetNavigation.useAsPlanningRoot": "Use as planning root", + "worksetNavigation.useAsPlanningRootAria": "Use as planning root: {name}", + "worksetNavigation.currentRoot": "Current root", + "worksetNavigation.useProjectDefault": "Use project default", + "worksetNavigation.planningRootSelectFailed": "Failed to switch the planning root. The previous root is unchanged.", + "worksetNavigation.membersSection": "Members", + "worksetCreate.formLabel": "Create Workset form", + "worksetCreate.createAction": "Create Workset", + "worksetCreate.nameLabel": "Workset name", + "worksetCreate.toolLabel": "Preferred opener id (optional)", + "worksetCreate.toolHint": "Can be overridden when opening.", + "worksetCreate.membersLabel": "Members", + "worksetCreate.primaryHint": "First member is the Primary.", + "worksetCreate.addMembers": "Add folders", + "worksetCreate.currentProjectMember": "Current project", + "worksetCreate.primaryBadge": "Primary", + "worksetCreate.makePrimary": "Make primary", + "worksetCreate.makePrimaryAria": "Make primary: {name}", + "worksetCreate.removeMember": "Remove", + "worksetCreate.removeMemberAria": "Remove member {name}", + "worksetCreate.cancel": "Cancel", + "worksetCreate.submit": "Create Workset", + "worksetCreate.invalidMemberNotice": "Some selected folders were not added. A member must be an absolute folder path and cannot duplicate an existing member.", + "worksetCreate.createFailedDefault": "Workset creation failed. Your draft is unchanged.", + "worksetCreate.rejectedInput": "Workset creation was rejected: the submitted name, members, or opener id is invalid.", + "worksetCreate.capabilityUnavailable": "Worksets are not supported by the resolved OpenSpec runtime. Upgrade the OpenSpec CLI to create Worksets.", + "worksetCreate.refreshMissing": "Workset {name} was created but is not listed for this Project yet. Your draft is preserved.", + "worksetCreate.refreshFailed": "Workset {name} was created, but refreshing the Project data failed. Your draft is preserved — try refreshing.", + "worksetCreate.inProgress": "Workset creation is already in progress. Please wait for it to finish.", + "worksetCreate.pickFailed": "The folder picker could not be opened. No members were added.", + "worksetCreate.emptyList": "No Worksets yet. Create your first Workset to group related Projects and Stores.", "confirm.taskToggleTitle": "Confirm task change", "confirm.taskToggleMessage": "Update task \"{task}\"?", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 0df09b2..4994037 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -286,6 +286,47 @@ "worksetNavigation.current": "当前", "worksetNavigation.unavailable": "不可用", "worksetNavigation.noSelectableProjects": "此工作集中没有可选择的项目。", + "worksetNavigation.openDetail": "查看工作集 {name}", + "worksetNavigation.backToList": "返回工作集列表", + "worksetNavigation.savedTool": "已保存工具:{tool}", + "worksetNavigation.defaultTool": "默认打开工具", + "worksetNavigation.staleDetailNotice": "工作集 {name} 已不可用(可能已被移除),已返回工作集列表。", + "worksetNavigation.openAll": "打开全部", + "worksetNavigation.openWithTool": "使用其他工具打开", + "worksetNavigation.oneTimeOpenerLabel": "自定义打开工具 id", + "worksetNavigation.oneTimeOpenerSubmit": "使用该工具打开", + "worksetNavigation.toolShortcut": "使用 {tool} 打开", + "worksetNavigation.useAsPlanningRoot": "设为规划根", + "worksetNavigation.useAsPlanningRootAria": "设为规划根:{name}", + "worksetNavigation.currentRoot": "当前根", + "worksetNavigation.useProjectDefault": "使用项目默认根", + "worksetNavigation.planningRootSelectFailed": "切换规划根失败,已保留原规划根。", + "worksetNavigation.membersSection": "成员", + "worksetCreate.formLabel": "创建工作集表单", + "worksetCreate.createAction": "创建工作集", + "worksetCreate.nameLabel": "工作集名称", + "worksetCreate.toolLabel": "首选打开工具 id(可选)", + "worksetCreate.toolHint": "打开工作集时可临时覆盖。", + "worksetCreate.membersLabel": "成员", + "worksetCreate.primaryHint": "首个成员即主成员。", + "worksetCreate.addMembers": "添加文件夹", + "worksetCreate.currentProjectMember": "当前项目", + "worksetCreate.primaryBadge": "主成员", + "worksetCreate.makePrimary": "设为主成员", + "worksetCreate.makePrimaryAria": "设为主成员:{name}", + "worksetCreate.removeMember": "移除", + "worksetCreate.removeMemberAria": "移除成员 {name}", + "worksetCreate.cancel": "取消", + "worksetCreate.submit": "创建工作集", + "worksetCreate.invalidMemberNotice": "部分所选文件夹未加入。成员必须是绝对路径的文件夹,且不能与现有成员重复。", + "worksetCreate.createFailedDefault": "工作集创建失败,草稿已保留。", + "worksetCreate.rejectedInput": "工作集创建请求被拒绝:提交的名称、成员或打开工具 id 无效。", + "worksetCreate.capabilityUnavailable": "当前 OpenSpec 运行时不支持工作集。请升级 OpenSpec CLI 后再创建工作集。", + "worksetCreate.refreshMissing": "工作集 {name} 已创建,但尚未出现在此项目的工作集列表中。草稿已保留。", + "worksetCreate.refreshFailed": "工作集 {name} 已创建,但刷新项目数据失败。草稿已保留,请尝试刷新。", + "worksetCreate.inProgress": "工作集创建正在进行中,请等待其完成。", + "worksetCreate.pickFailed": "无法打开文件夹选择器,未添加任何成员。", + "worksetCreate.emptyList": "还没有工作集。创建第一个工作集,把相关项目与 Store 组织到一起。", "header.refresh": "刷新", "header.loading": "加载中...", diff --git a/src/webview/components/Dashboard.tsx b/src/webview/components/Dashboard.tsx index 6917e68..106036d 100644 --- a/src/webview/components/Dashboard.tsx +++ b/src/webview/components/Dashboard.tsx @@ -168,6 +168,118 @@ export function returnToCurrentProject( postMessage(sendMessage.selectCurrentProject()); } +/** + * Active Planning-root Store id for the Worksets surface, derived only from + * the trusted Host binding (binding.storeId). `undefined` means the Project + * default root is active; the webview never re-classifies member paths. + */ +export function getWorksetPickerActiveStoreId( + projectSidebar: ProjectSidebarData | null | undefined, +): string | undefined { + return projectSidebar?.binding?.storeId; +} + +/** + * Authoritative "explicit Planning Store selector active" fact for the + * Worksets surface, read only from the Host-returned flag. A selector-free + * default binding may still carry `binding.storeId` when the CLI's + * root.store_id is set, so selector-dependent recovery must key on this flag. + */ +export function getWorksetPickerExplicitStoreSelector( + projectSidebar: ProjectSidebarData | null | undefined, +): boolean { + return projectSidebar?.explicitStoreSelector === true; +} + +/** + * Whether the Worksets tab is reachable: a trusted navigation must exist AND + * the Host-resolved Workset capability must not be explicitly unavailable. + * Zero worksets is NOT a blocker — the empty list with its Create entry is the + * primary first-creation surface. `undefined` capability (legacy cached + * payloads) keeps the tab available. + */ +export function getWorksetsTabAvailable( + projectSidebar: ProjectSidebarData | null | undefined, +): boolean { + return projectSidebar?.worksetNavigation !== undefined + && projectSidebar?.worksetCapabilityAvailable !== false; +} + +/** + * Whether the create flow may be offered inside the Worksets surface. Derived + * only from the Host capability flag; `undefined` capability (legacy payloads) + * is treated as available, `false` hides/disables Create with the upgrade + * explanation. Without a sidebar at all, nothing is available. + */ +export function getWorksetCreateAvailable( + projectSidebar: ProjectSidebarData | null | undefined, +): boolean { + return projectSidebar != null && projectSidebar.worksetCapabilityAvailable !== false; +} + +/** + * Untrusted `worksetMembersPicked` payload → picker-response state. Paths + * default to an empty add; only a string-array `droppedPaths` is forwarded. + */ +export function toWorksetPickedMembersState( + message: { paths?: unknown; droppedPaths?: unknown }, +): { paths: unknown[]; droppedPaths?: string[] } { + const droppedPaths = Array.isArray(message.droppedPaths) + && message.droppedPaths.every((value) => typeof value === 'string') + ? message.droppedPaths as string[] + : undefined; + return { + paths: Array.isArray(message.paths) ? message.paths : [], + ...(droppedPaths && droppedPaths.length ? { droppedPaths } : {}), + }; +} + +/** + * Untrusted `worksetCreateResult` payload → picker-response state. `success` + * is strictly boolean, the echoed name defaults to empty, and only string + * messages survive. + */ +export function toWorksetCreateResultState( + message: { success?: unknown; name?: unknown; message?: unknown }, +): { success: boolean; name: string; message?: string } { + return { + success: message.success === true, + name: typeof message.name === 'string' ? message.name : '', + ...(typeof message.message === 'string' ? { message: message.message } : {}), + }; +} + +/** + * Single-purpose Workset picker actions: each callback posts exactly one + * explicit message. Project switching never invokes `workset open`, Store + * members are never routed as Project selections, and the one-time opener is + * forwarded as an explicit optional tool. Creation requests carry the draft + * verbatim (ordered unique members, optional trimmed tool) — validation and + * canonicalization are Host responsibilities. + */ +export function createWorksetPickerHandlers(postMessage: DashboardPostMessage) { + return { + onSelectProject: (worksetName: string, memberPath: string) => { + postMessage(sendMessage.selectWorksetProject(worksetName, memberPath)); + }, + onSelectWorksetStore: (worksetName: string, memberPath: string) => { + postMessage(sendMessage.selectWorksetStore(worksetName, memberPath)); + }, + onSelectProjectDefaultRoot: () => { + postMessage(sendMessage.selectProjectDefaultRoot()); + }, + onOpenWorkset: (name: string, tool?: string) => { + postMessage(sendMessage.openWorkset(name, tool)); + }, + onPickMembers: () => { + postMessage(sendMessage.pickWorksetMembers()); + }, + onCreateWorkset: (name: string, members: string[], tool?: string) => { + postMessage(sendMessage.createWorkset(name, members, tool)); + }, + }; +} + export function createScopeSelectHandler( dispatch: DashboardDispatch, postMessage: DashboardPostMessage, @@ -241,6 +353,22 @@ export const Dashboard: React.FC = () => { // effect run) can read the latest scope without closing over stale state. const scopeIdRef = useRef<string | undefined>(undefined); const [workflowLaunchConfig, setWorkflowLaunchConfig] = useState<WorkflowLaunchConfigView | null>(null); + // Sequence-stamped Host responses for the Workset create flow. The sequence + // lets the picker apply each Host message at most once, and ignore responses + // that arrive after the form was left. The payloads stay untrusted: the + // picker re-checks member eligibility before merging. + const worksetResponseSeqRef = useRef(0); + const [worksetPickedMembers, setWorksetPickedMembers] = useState<{ + seq: number; + paths: unknown[]; + droppedPaths?: string[]; + } | null>(null); + const [worksetCreateResult, setWorksetCreateResult] = useState<{ + seq: number; + success: boolean; + name: string; + message?: string; + } | null>(null); const { data, loading, loadingReason, pendingScopeId, activity, error } = state; const projectSidebar = state.projectSidebar; @@ -371,6 +499,22 @@ export const Dashboard: React.FC = () => { if (message.success) { postMessage(sendMessage.getCacheStats(true)); } + } else if (message.type === 'worksetMembersPicked') { + // Folder-picker result from the Host. Cancelled pickers never produce + // this message, and the picker ignores it unless the create form is + // active — a late response after leaving the form is harmless. + // Host-dropped (unrealpath-able) picks ride along as droppedPaths. + worksetResponseSeqRef.current += 1; + setWorksetPickedMembers({ + seq: worksetResponseSeqRef.current, + ...toWorksetPickedMembersState(message), + }); + } else if (message.type === 'worksetCreateResult') { + worksetResponseSeqRef.current += 1; + setWorksetCreateResult({ + seq: worksetResponseSeqRef.current, + ...toWorksetCreateResultState(message), + }); } }); @@ -588,7 +732,10 @@ export const Dashboard: React.FC = () => { : undefined} activeProjectTab={projectSidebar ? projectFirstTab : undefined} worksetCount={projectSidebar?.worksetNavigation?.worksets.length ?? 0} - onOpenWorksets={projectSidebar?.worksetNavigation?.worksets.length + worksetsCapabilityAvailable={projectSidebar + ? projectSidebar.worksetCapabilityAvailable !== false + : undefined} + onOpenWorksets={projectSidebar && getWorksetsTabAvailable(projectSidebar) ? () => selectProjectFirstTab(setProjectFirstTab, 'worksets') : undefined} /> @@ -698,12 +845,12 @@ export const Dashboard: React.FC = () => { {projectFirstTab === 'worksets' && projectSidebar.worksetNavigation ? ( <WorksetProjectPicker navigation={projectSidebar.worksetNavigation} - onSelectProject={(worksetName, memberPath) => { - postMessage(sendMessage.selectWorksetProject(worksetName, memberPath)); - }} - onOpenWorkset={(name) => { - postMessage(sendMessage.openWorkset(name)); - }} + activeStoreId={getWorksetPickerActiveStoreId(projectSidebar)} + explicitStoreSelector={getWorksetPickerExplicitStoreSelector(projectSidebar)} + createAvailable={getWorksetCreateAvailable(projectSidebar)} + {...createWorksetPickerHandlers(postMessage)} + pickedMembers={worksetPickedMembers} + createResult={worksetCreateResult} onBackToCurrentProject={() => returnToCurrentProject(setProjectFirstTab, postMessage)} /> ) : ( diff --git a/src/webview/components/Header.tsx b/src/webview/components/Header.tsx index 96d07f7..846b876 100644 --- a/src/webview/components/Header.tsx +++ b/src/webview/components/Header.tsx @@ -30,6 +30,8 @@ export interface HeaderProps { onOpenWorksets?: () => void; onOpenDashboard?: () => void; worksetCount?: number; + /** Host capability fact; `false` explains the disabled tab with the upgrade copy. */ + worksetsCapabilityAvailable?: boolean; activeProjectTab?: 'changes' | 'specs' | 'worksets'; } @@ -51,13 +53,23 @@ export const Header: React.FC<HeaderProps> = ({ onOpenWorksets, onOpenDashboard, worksetCount, + worksetsCapabilityAvailable, activeProjectTab, }) => { const showSelector = scope && scopes.length > 1 && onSelectScope; const projectScopes = scopes.filter((s) => s.source === 'local' || s.source === 'declared'); const storeScopes = scopes.filter((s) => s.source === 'store'); const storeFeaturesAvailable = scope?.capabilities?.stores === true; - const worksetsAvailable = Boolean(onOpenWorksets && worksetCount !== undefined && worksetCount > 0); + // The tab's availability is decided upstream (navigation + Workset capability, + // never the workset count — zero worksets is the first-creation case). Here + // it only reflects whether an open handler was provided. + const worksetsAvailable = Boolean(onOpenWorksets); + // Disabled reason copy: a runtime without the Workset capability gets the + // existing upgrade explanation; a missing trusted navigation keeps the + // membership-unavailable explanation. + const worksetsDisabledCopy = worksetsCapabilityAvailable === false + ? t('scope.featureGated.upgradeNotice') + : t('projectSidebar.worksetsUnavailable'); const worksetsAccessibleName = `${t('projectSidebar.worksets')}${worksetCount !== undefined ? ` (${worksetCount})` : ''}`; const dashboardAccessibleName = `${t('projectSidebar.dashboard')} · ${t('action.openInEditor')}`; const worksetsUnavailableId = 'project-worksets-unavailable'; @@ -212,7 +224,7 @@ export const Header: React.FC<HeaderProps> = ({ aria-label={worksetsAccessibleName} title={worksetsAvailable ? t('projectSidebar.worksets') - : t('projectSidebar.worksetsUnavailable')} + : worksetsDisabledCopy} className="group min-w-0 overflow-hidden rounded border px-2 py-2 text-left text-xs hover:brightness-110 focus:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-60" style={{ borderColor: 'var(--vscode-panel-border)', @@ -235,7 +247,7 @@ export const Header: React.FC<HeaderProps> = ({ > {worksetsAvailable ? t('projectSidebar.cardWorksetsSupporting') - : t('projectSidebar.worksetsUnavailable')} + : worksetsDisabledCopy} </span> </span> </span> diff --git a/src/webview/components/WorksetProjectPicker.tsx b/src/webview/components/WorksetProjectPicker.tsx index f9cc977..237c809 100644 --- a/src/webview/components/WorksetProjectPicker.tsx +++ b/src/webview/components/WorksetProjectPicker.tsx @@ -1,174 +1,1198 @@ -import React from 'react'; +import React, { useState } from 'react'; import { t } from '../../i18n'; -import type { ProjectWorksetNavigationData, WorksetNavigationMember } from '../types/messages'; +import type { + ProjectContext, + ProjectWorksetNavigationData, + WorksetNavigationMember, +} from '../types/messages'; + +/** + * Component-local creation draft. `members` is an ordered list of unique + * canonical folder paths: the first entry IS the Primary member (no parallel + * Primary field exists). The draft is never persisted and never leaves the + * component except as an explicit `createWorkset` submission. + */ +export interface WorksetCreateDraft { + readonly name: string; + readonly members: readonly string[]; + readonly tool: string; +} + +/** + * Local view state of the Worksets surface. `list` renders one collapsed row + * per containing Workset; `detail` renders a single Workset; `create` renders + * the single-screen creation form with its draft. `staleDetailName` records a + * detail that disappeared from a fresh trusted navigation snapshot: the surface + * returns to the list and explains the recovery. + */ +export type WorksetPickerScene = + | { readonly kind: 'list'; readonly staleDetailName?: string } + | { readonly kind: 'detail'; readonly name: string } + | { readonly kind: 'create'; readonly draft: WorksetCreateDraft }; export interface WorksetProjectPickerProps { navigation: ProjectWorksetNavigationData; + /** Active Planning-root Store id from the trusted Host binding; undefined = project default root. */ + activeStoreId?: string; + /** + * Host-returned fact: an explicit Planning Store selector is active. Gates + * the `Use project default` recovery; a selector-free default binding may + * still carry a storeId (CLI root.store_id), so this flag never derives + * from `activeStoreId`. + */ + explicitStoreSelector?: boolean; onSelectProject: (worksetName: string, memberPath: string) => void; - onOpenWorkset: (name: string) => void; + onSelectWorksetStore: (worksetName: string, memberPath: string) => void; + onSelectProjectDefaultRoot: () => void; + onOpenWorkset: (name: string, tool?: string) => void; + /** Requests the native folder picker; the result returns via `pickedMembers`. */ + onPickMembers: () => void; + /** Submits the draft through the official Host create path. */ + onCreateWorkset: (name: string, members: string[], tool?: string) => void; onBackToCurrentProject: () => void; + /** + * Latest `worksetMembersPicked` response from the Host (sequence-stamped). + * It is applied at most once, and only while the create scene is active — + * a response that arrives after leaving the form is ignored harmlessly. + * `droppedPaths` lists picks the Host could not canonicalize: they were not + * added and must be explained recoverably. + */ + pickedMembers?: Readonly<{ seq: number; paths: readonly unknown[]; droppedPaths?: readonly string[] }> | null; + /** + * Latest `worksetCreateResult` response from the Host (sequence-stamped). + * Success enters the new Workset detail only when the fresh navigation + * contains that name; failure preserves the draft with a recoverable notice. + */ + createResult?: Readonly<{ + seq: number; + success: boolean; + name: string; + message?: string; + }> | null; + /** + * Initial local scene. Production callers omit it (the surface always starts + * on the list); it exists so the detail and stale-detail states are testable + * without a DOM. It is never persisted and never driven by the Host. + */ + initialScene?: WorksetPickerScene; + /** + * Initial one-time opener editor open state. Production callers omit it (the + * disclosure starts collapsed); it exists so the expanded state — where the + * aria-controls id and the mounted form must coexist — is testable without + * a DOM. It is never persisted and never driven by the Host. + */ + initialOpenerOpen?: boolean; + /** + * Host-resolved Workset capability (`worksetCapabilityAvailable`). `false` + * hides the Create entry and shows the upgrade explanation; `undefined` + * (legacy cached payloads) keeps the Create entry available. + */ + createAvailable?: boolean; +} + +interface WorksetOneTimeOpenerEditorState { + readonly worksetName: string; +} + +/** Recoverable, in-form feedback for the creation flow; never a modal error. */ +export type WorksetCreateNotice = + | { readonly kind: 'invalidMembers' } + | { readonly kind: 'createFailed'; readonly message?: string }; + +interface WorksetPickerSelection { + readonly projectKey: string; + readonly scene: WorksetPickerScene; + /** One-time opener editor opened for this detail target; null = closed. */ + readonly openerEditor: WorksetOneTimeOpenerEditorState | null; + /** Sequence of the last applied `worksetMembersPicked` response. */ + readonly appliedPickedSeq?: number; + /** Sequence of the last applied `worksetCreateResult` response. */ + readonly appliedResultSeq?: number; + /** Active recoverable create-form notice; undefined = none. */ + readonly createNotice?: WorksetCreateNotice; +} + +/** Stable identity of the Project whose trusted navigation drives the surface. */ +export function worksetPickerProjectKey(project: ProjectContext): string { + return `${project.id}\u0000${project.projectPath}`; +} + +export function openWorksetDetailScene(name: string): WorksetPickerScene { + return { kind: 'detail', name }; +} + +export function backToWorksetListScene(): WorksetPickerScene { + return { kind: 'list' }; +} + +/** + * Open the creation form. The current Project is seeded as the first member — + * the implicit Primary — and can never be removed in this Project-first flow. + */ +export function openWorksetCreateScene(project: ProjectContext): WorksetPickerScene { + return { + kind: 'create', + draft: { name: '', members: [project.projectPath], tool: '' }, + }; +} + +/** + * Coarse webview-side eligibility check for a member path echoed back by the + * Host. It only rejects obviously forged values (non-strings, empty, relative + * paths); authoritative canonicalization stays a Host responsibility. + */ +export function isEligibleWorksetMemberPath(value: unknown): value is string { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed) return false; + // Posix absolute, Windows drive absolute, or UNC path. + return trimmed.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(trimmed) || trimmed.startsWith('\\\\'); +} + +/** + * Merge picker-returned paths into the draft: one entry per canonical path, + * existing order preserved (the first member stays the Primary unless + * promoted), duplicates skipped, ineligible values rejected with a flag so the + * form can explain the rejection without touching the draft. + */ +export function mergePickedWorksetMembers( + draft: WorksetCreateDraft, + paths: readonly unknown[], +): { draft: WorksetCreateDraft; droppedInvalid: boolean } { + const members = [...draft.members]; + let droppedInvalid = false; + for (const candidate of paths) { + if (!isEligibleWorksetMemberPath(candidate)) { + if (candidate !== undefined && candidate !== null) droppedInvalid = true; + continue; + } + if (!members.includes(candidate)) members.push(candidate); + } + return { draft: { ...draft, members }, droppedInvalid }; +} + +/** + * Promote a member to Primary by moving it to the front. The member stays in + * the list; the previous order of the remaining members is preserved. Promoting + * the current Primary or an unknown path is a no-op returning the same draft. + */ +export function promoteWorksetPrimaryMember( + draft: WorksetCreateDraft, + memberPath: string, +): WorksetCreateDraft { + if (!draft.members.includes(memberPath) || draft.members[0] === memberPath) return draft; + return { + ...draft, + members: [memberPath, ...draft.members.filter((member) => member !== memberPath)], + }; +} + +/** + * Remove a member unless it is the locked current Project member. Removing a + * non-Primary member simply shortens the list; removing the (non-locked) + * Primary leaves the next member as the implicit Primary. + */ +export function removeWorksetDraftMember( + draft: WorksetCreateDraft, + memberPath: string, + lockedMemberPath: string, +): WorksetCreateDraft { + if (memberPath === lockedMemberPath) return draft; + return { ...draft, members: draft.members.filter((member) => member !== memberPath) }; +} + +/** + * Normalize the draft for submission: the name must be non-empty after trim, + * members are submitted verbatim in order (first = Primary), and an empty tool + * is omitted. Returns undefined when the draft cannot be submitted at all. + */ +export function normalizeWorksetCreateSubmit( + draft: WorksetCreateDraft, +): { name: string; members: readonly string[]; tool?: string } | undefined { + const name = draft.name.trim(); + if (!name) return undefined; + const members = draft.members.filter(isEligibleWorksetMemberPath); + if (members.length === 0) return undefined; + const tool = draft.tool.trim(); + return tool ? { name, members, tool } : { name, members }; +} + +/** + * Wire the create-form submit to the Host path: normalize the draft and, only + * when it is submittable, invoke `onCreateWorkset` exactly once with the + * ordered members and optional trimmed tool. Returns whether a submission was + * sent; an unsubmittable draft performs no Host call. + */ +export function runWorksetCreateSubmit( + draft: WorksetCreateDraft, + onCreateWorkset: (name: string, members: string[], tool?: string) => void, +): boolean { + const submission = normalizeWorksetCreateSubmit(draft); + if (submission === undefined) return false; + onCreateWorkset( + submission.name, + [...submission.members], + submission.tool, + ); + return true; +} + +/** + * Apply at most one Host response sequence per message. A picker response is + * merged only into an ACTIVE create scene; after cancel/back the same response + * is recorded as consumed without any side effect (late responses are + * harmless). A create result transitions create → detail(name) only on success + * AND only when the fresh trusted navigation actually contains that Workset; + * failure keeps the draft and records a recoverable notice. + */ +export function reconcileWorksetPickerResponses( + selection: WorksetPickerSelection, + navigation: ProjectWorksetNavigationData, + pickedMembers: Readonly<{ seq: number; paths: readonly unknown[]; droppedPaths?: readonly string[] }> | null | undefined, + createResult: Readonly<{ seq: number; success: boolean; name: string; message?: string }> | null | undefined, +): WorksetPickerSelection { + let next = selection; + if (pickedMembers && pickedMembers.seq !== next.appliedPickedSeq) { + if (next.scene.kind === 'create') { + const merged = mergePickedWorksetMembers(next.scene.draft, pickedMembers.paths); + // Host-dropped picks (unrealpath-able folders) get the same recoverable + // explanation as webview-rejected values: they were not added. + const hostDropped = (pickedMembers.droppedPaths?.length ?? 0) > 0; + next = { + ...next, + scene: { kind: 'create', draft: merged.draft }, + appliedPickedSeq: pickedMembers.seq, + createNotice: merged.droppedInvalid || hostDropped ? { kind: 'invalidMembers' } : undefined, + }; + } else { + // Late response after leaving the create form: consume it silently so it + // can never resurface, but leave the scene and draft untouched. + next = { ...next, appliedPickedSeq: pickedMembers.seq }; + } + } + if (createResult && createResult.seq !== next.appliedResultSeq) { + next = { ...next, appliedResultSeq: createResult.seq }; + if (next.scene.kind === 'create') { + if ( + createResult.success + && createResult.name + && navigation.worksets.some((workset) => workset.name === createResult.name) + ) { + next = { ...next, scene: { kind: 'detail', name: createResult.name }, createNotice: undefined }; + } else if (!createResult.success) { + next = { + ...next, + createNotice: { kind: 'createFailed', message: createResult.message }, + }; + } + // A success whose name is missing from the fresh navigation is never + // fabricated into a detail: the create scene simply stays put. + } + } + return next; +} + +/** + * Commit-level Project identity reset for the persisted selection. The reset + * must be persistent state, not a per-render derivation: A → B → A must land + * on the plain list, so returning to a previously visited Project can never + * revive its old detail scene or opener editor. + */ +export function resetWorksetPickerSelectionForProject( + selection: WorksetPickerSelection, + projectKey: string, +): WorksetPickerSelection { + return selection.projectKey === projectKey + ? selection + : { projectKey, scene: { kind: 'list' }, openerEditor: null }; +} + +/** + * The one-time opener editor is scoped to its detail target: leaving the + * detail (Back, stale reset, Project change) or entering another Workset + * closes it; the form element itself stays mounted (so the toggle's + * aria-controls id always resolves) while the open-state key remount discards + * the typed id. + */ +export function isWorksetOpenerEditorOpen( + openerEditor: WorksetOneTimeOpenerEditorState | null, + detailName: string | undefined, +): boolean { + return detailName !== undefined && openerEditor?.worksetName === detailName; +} + +/** + * Coordinate a persisted scene with a fresh trusted navigation snapshot: + * - Project identity change resets to the plain list (no stale notice). + * - A detail whose Workset still exists is kept (ordinary and binding-only + * refreshes must not clear it). + * - A create scene is preserved while the current Project identity is + * unchanged: a normal refresh never discards the user's draft. + * - A detail whose Workset disappeared returns to the list with a recoverable + * stale-item notice; no action of the removed Workset is retained. + */ +export function resolveWorksetPickerScene( + scene: WorksetPickerScene, + sceneProjectKey: string, + navigation: ProjectWorksetNavigationData, +): WorksetPickerScene { + if (worksetPickerProjectKey(navigation.project) !== sceneProjectKey) { + return { kind: 'list' }; + } + if (scene.kind === 'detail') { + return navigation.worksets.some((workset) => workset.name === scene.name) + ? scene + : { kind: 'list', staleDetailName: scene.name }; + } + return scene; +} + +/** + * Stable heading id for the Workset detail surface. Raw Workset names may + * contain spaces or other characters that are illegal inside an id (an + * aria-labelledby idref list splits on whitespace), so runs of unsafe + * characters collapse to single dashes. Only one detail renders at a time and + * the heading lives inside the same section as the aria-labelledby reference, + * so the pair stays unambiguous even for names that slugify identically. + */ +export function worksetDetailHeadingId(name: string): string { + const slug = name + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + return `workset-detail-${slug}`; +} + +/** + * Whole-Workset open action. It must remain independent from row/detail + * activation, so it stops propagation before performing exactly one open. + * The optional tool is a one-time override; it never mutates the saved tool. + */ +export function runWorksetOpenAction( + name: string, + open: (name: string, tool?: string) => void, + event?: { stopPropagation?: () => void }, + tool?: string, +): void { + event?.stopPropagation?.(); + if (tool === undefined) { + open(name); + return; + } + open(name, tool); +} + +/** + * A one-time opener id is submitted as-is after trimming; the CLI validates it. + * The UI only rejects empty values and never pretends a custom id is verified. + */ +export function normalizeOneTimeOpenerTool(value: string | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +// Focus idiom mirrors Dashboard.tsx: a visible focus-visible outline colored +// by the theme focus token (no Tailwind default-blue ring, no dead inline +// outlineColor). Animations stay within the 120-160ms color-transition budget +// and are globally disabled under `prefers-reduced-motion` (see index.css). +const focusRing = 'focus:outline-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--vscode-focusBorder)]'; +const secondaryButtonClass = `rounded transition-colors duration-150 bg-[var(--vscode-button-secondaryBackground)] text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)] ${focusRing}`; +const primaryButtonClass = `rounded transition-colors duration-150 bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] ${focusRing}`; +const descriptionStyle = { color: 'var(--vscode-descriptionForeground)' } as const; +const inputClass = `w-full min-w-0 rounded px-2 py-1 text-xs ${focusRing}`; +const inputStyle = { + background: 'var(--vscode-input-background)', + color: 'var(--vscode-input-foreground)', + border: '1px solid var(--vscode-input-border)', +} as const; + +/** + * Best-effort visible git identity for a Workset member: the repository's + * basename and the branch, joined compactly so a ~430px row can show both. + * The full repository path stays in the row tooltip. + */ +export function formatWorksetMemberGitLabel( + git: Readonly<{ repository?: string; branch?: string }> | undefined, +): string { + if (!git) return ''; + const segments: string[] = []; + if (git.repository) { + const normalized = git.repository.replace(/[\\/]+$/, ''); + const lastSlash = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\')); + const basename = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; + if (basename) segments.push(basename); + } + if (git.branch) segments.push(git.branch); + return segments.join(' \u00b7 '); +} + +export interface WorksetOneTimeOpenerFormProps { + name: string; + onOpen: (name: string, tool: string) => void; + /** + * Collapsed disclosure state. The form stays mounted so the toggle button's + * aria-controls id always resolves to a real element; the hidden attribute + * removes it from layout, tab order, and the accessibility tree. The flex + * layout utility is dropped while hidden so no author display rule can + * override the attribute's native display:none. + */ + hidden?: boolean; } +/** + * Editable one-time opener: free-form id input with `code`/`cursor` shortcuts. + * The entered id is passed to the CLI verbatim (trimmed); the saved Workset + * tool is never modified by this control. + */ +export const WorksetOneTimeOpenerForm: React.FC<WorksetOneTimeOpenerFormProps> = ({ name, onOpen, hidden }) => { + const [toolId, setToolId] = useState(''); + const submit = () => { + const normalized = normalizeOneTimeOpenerTool(toolId); + if (normalized === undefined) return; + onOpen(name, normalized); + }; + + return ( + <form + id="workset-one-time-opener-form" + data-one-time-opener-form + hidden={hidden} + className={hidden ? 'mt-1 min-w-0' : 'mt-1 flex min-w-0 flex-wrap items-center gap-1'} + onSubmit={(event) => { + // Enter in the input performs exactly one open; preventDefault keeps + // the webview free of page-reload semantics. + event.preventDefault(); + submit(); + }} + > + <input + type="text" + value={toolId} + onChange={(event) => setToolId(event.target.value)} + aria-label={t('worksetNavigation.oneTimeOpenerLabel')} + title={t('worksetNavigation.oneTimeOpenerLabel')} + placeholder={t('worksetNavigation.oneTimeOpenerLabel')} + className={`min-w-0 flex-1 rounded px-2 py-1 text-xs ${focusRing}`} + style={inputStyle} + /> + {(['code', 'cursor'] as const).map((shortcut) => ( + <button + key={shortcut} + type="button" + data-one-time-tool-shortcut={shortcut} + onClick={() => setToolId(shortcut)} + aria-label={t('worksetNavigation.toolShortcut', { tool: shortcut })} + title={t('worksetNavigation.toolShortcut', { tool: shortcut })} + className={`shrink-0 px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {shortcut} + </button> + ))} + {/* Click and Enter both submit the form exactly once: the button is the + form's submit control and carries no separate click handler. */} + <button + type="submit" + data-action="open-with-one-time-tool" + aria-label={t('worksetNavigation.oneTimeOpenerSubmit')} + title={t('worksetNavigation.oneTimeOpenerSubmit')} + className={`shrink-0 px-2 py-1 text-xs ${primaryButtonClass}`} + > + {t('worksetNavigation.oneTimeOpenerSubmit')} + </button> + </form> + ); +}; + function isCurrentProject(navigation: ProjectWorksetNavigationData, member: WorksetNavigationMember): boolean { return member.project?.id === navigation.project.id || member.path === navigation.project.projectPath; } +export interface WorksetCreateFormProps { + draft: WorksetCreateDraft; + /** Canonical path of the current Project member; never removable. */ + lockedMemberPath: string; + /** Recoverable feedback text; undefined renders no notice. */ + noticeText?: string; + onDraftChange: (draft: WorksetCreateDraft) => void; + onPickMembers: () => void; + onSubmit: () => void; + onCancel: () => void; +} + +/** + * Single-screen creation form. The members list is ordered: the first entry is + * the Primary (submitted first). The current Project member is displayed as a + * locked state text — it is included by construction and offers no remove + * control. Cancel is a pure local scene switch; only the submit control sends + * a creation message, and it stays disabled until the trimmed name is valid. + */ +export const WorksetCreateForm: React.FC<WorksetCreateFormProps> = ({ + draft, + lockedMemberPath, + noticeText, + onDraftChange, + onPickMembers, + onSubmit, + onCancel, +}) => { + const primaryPath = draft.members[0]; + const submitDisabled = draft.name.trim() === ''; + + return ( + <form + data-workset-create-form + className="space-y-2 rounded border p-2" + style={{ borderColor: 'var(--vscode-panel-border)' }} + aria-label={t('worksetCreate.formLabel')} + onSubmit={(event) => { + // Enter in any input performs at most one submission; preventDefault + // keeps the webview free of page-reload semantics. + event.preventDefault(); + if (!submitDisabled) onSubmit(); + }} + > + <div className="min-w-0 space-y-1"> + <label + htmlFor="workset-create-name" + className="block text-xs font-semibold" + style={descriptionStyle} + > + {t('worksetCreate.nameLabel')} + </label> + <input + id="workset-create-name" + type="text" + data-create-workset-name + value={draft.name} + onChange={(event) => onDraftChange({ ...draft, name: event.target.value })} + aria-label={t('worksetCreate.nameLabel')} + className={inputClass} + style={inputStyle} + /> + </div> + + <div className="min-w-0 space-y-1"> + <div className="flex min-w-0 items-baseline justify-between gap-2"> + <span className="text-xs font-semibold" style={descriptionStyle}> + {t('worksetCreate.membersLabel')} + </span> + <span className="min-w-0 truncate text-[10px]" style={descriptionStyle}> + {t('worksetCreate.primaryHint')} + </span> + </div> + <div + data-workset-create-members + className="divide-y divide-[color:var(--vscode-panel-border)] rounded border" + style={{ borderColor: 'var(--vscode-panel-border)' }} + > + {draft.members.map((member) => { + const locked = member === lockedMemberPath; + const primary = member === primaryPath; + return ( + <div + key={member} + data-create-member={member} + data-create-member-primary={primary ? 'true' : undefined} + data-create-member-state={locked ? 'current-project' : undefined} + className="flex min-w-0 items-center justify-between gap-2 px-2 py-1 text-xs" + title={member} + style={locked ? { ...descriptionStyle } : undefined} + > + <span className="flex min-w-0 flex-1 items-center gap-1.5"> + <span aria-hidden="true" className="codicon codicon-folder shrink-0" /> + <span className="min-w-0 flex-1 truncate">{member}</span> + </span> + {locked && ( + <span className="shrink-0" style={descriptionStyle}> + {t('worksetCreate.currentProjectMember')} + </span> + )} + {primary && ( + <span + className="shrink-0 rounded px-1" + data-create-primary-badge + style={{ ...descriptionStyle }} + > + {t('worksetCreate.primaryBadge')} + </span> + )} + {!primary && ( + <button + type="button" + data-action="promote-create-primary" + onClick={() => onDraftChange(promoteWorksetPrimaryMember(draft, member))} + aria-label={t('worksetCreate.makePrimaryAria', { name: member })} + title={t('worksetCreate.makePrimaryAria', { name: member })} + className={`shrink-0 px-1.5 py-0.5 ${secondaryButtonClass}`} + > + {t('worksetCreate.makePrimary')} + </button> + )} + {!locked && ( + <button + type="button" + data-action="remove-create-member" + onClick={() => onDraftChange(removeWorksetDraftMember(draft, member, lockedMemberPath))} + aria-label={t('worksetCreate.removeMemberAria', { name: member })} + title={t('worksetCreate.removeMemberAria', { name: member })} + className={`shrink-0 px-1.5 py-0.5 ${secondaryButtonClass}`} + > + {t('worksetCreate.removeMember')} + </button> + )} + </div> + ); + })} + </div> + <button + type="button" + data-action="pick-workset-members" + onClick={onPickMembers} + aria-label={t('worksetCreate.addMembers')} + title={t('worksetCreate.addMembers')} + className={`w-full px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {t('worksetCreate.addMembers')} + </button> + </div> + + <div className="min-w-0 space-y-1"> + <label + htmlFor="workset-create-tool" + className="block text-xs font-semibold" + style={descriptionStyle} + > + {t('worksetCreate.toolLabel')} + </label> + <input + id="workset-create-tool" + type="text" + data-create-workset-tool + value={draft.tool} + onChange={(event) => onDraftChange({ ...draft, tool: event.target.value })} + aria-label={t('worksetCreate.toolLabel')} + className={inputClass} + style={inputStyle} + /> + <p className="text-[10px]" style={descriptionStyle}> + {t('worksetCreate.toolHint')} + </p> + </div> + + {noticeText !== undefined && ( + <div + role="status" + data-create-notice + className="text-xs leading-snug" + style={{ color: 'var(--vscode-errorForeground)' }} + > + {noticeText} + </div> + )} + + <div className="space-y-1"> + {/* Visual acceptance D1: the native disabled attribute alone leaves the + saturated --vscode-button-background intact in dark/light (High + Contrast grays disabled buttons by itself). The disabled-variant + muting idiom shared with ChangePagination (opacity + no-click + cursor) conveys the state in every theme while the control stays a + native disabled submit. */} + <button + type="submit" + data-action="submit-create-workset" + disabled={submitDisabled} + aria-label={t('worksetCreate.submit')} + title={t('worksetCreate.submit')} + className={`w-full px-2 py-1 text-xs ${primaryButtonClass} disabled:opacity-50 disabled:cursor-not-allowed`} + > + {t('worksetCreate.submit')} + </button> + <button + type="button" + data-action="cancel-create-workset" + onClick={onCancel} + aria-label={t('worksetCreate.cancel')} + title={t('worksetCreate.cancel')} + className={`w-full px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {t('worksetCreate.cancel')} + </button> + </div> + </form> + ); +}; + export const WorksetProjectPicker: React.FC<WorksetProjectPickerProps> = ({ navigation, + activeStoreId, + explicitStoreSelector, onSelectProject, + onSelectWorksetStore, + onSelectProjectDefaultRoot, onOpenWorkset, + onPickMembers, + onCreateWorkset, onBackToCurrentProject, -}) => ( - <section className="mb-6" data-workset-project-picker data-workset-picker-scene> - <div className="mb-3 flex items-start justify-between gap-2"> - <div className="min-w-0"> + pickedMembers, + createResult, + initialScene, + initialOpenerOpen, + createAvailable, +}) => { + const [selection, setSelection] = useState<WorksetPickerSelection>(() => ({ + projectKey: worksetPickerProjectKey(navigation.project), + scene: initialScene ?? { kind: 'list' }, + openerEditor: initialOpenerOpen && initialScene?.kind === 'detail' + ? { worksetName: initialScene.name } + : null, + })); + // Persistent Project-identity reset: committing the reset to state (not + // deriving it per render) means A → B → A cannot revive A's old detail. + // Render-phase commit, the same idiom as the Dashboard's view-state restore. + const projectKey = worksetPickerProjectKey(navigation.project); + const resetSelection = resetWorksetPickerSelectionForProject(selection, projectKey); + // Sequence-stamped Host responses apply at most once each, and picker + // responses apply only while the create scene is active. Also committed in + // the render phase so the whole reconciliation stays pure and DOM-less. + const committedSelection = reconcileWorksetPickerResponses( + resetSelection, + navigation, + pickedMembers, + createResult, + ); + if (committedSelection !== selection) { + setSelection(committedSelection); + } + // Pure per-render coordination with the latest trusted snapshot. The state + // itself only changes through user activation or response application, so no + // router, global store, or persistence is involved. + const scene = resolveWorksetPickerScene(committedSelection.scene, committedSelection.projectKey, navigation); + // Scene switches are user-initiated: applied-response sequences stay recorded + // (an old response must not re-apply after cancel/back), stale notices drop. + const selectScene = (next: WorksetPickerScene) => setSelection((previous) => ({ + ...previous, + scene: next, + openerEditor: null, + createNotice: undefined, + })); + const detail = scene.kind === 'detail' + ? navigation.worksets.find((workset) => workset.name === scene.name) + : undefined; + const openerEditorOpen = isWorksetOpenerEditorOpen(committedSelection.openerEditor, detail?.name); + const toggleOpenerEditor = () => { + if (detail === undefined) return; + setSelection({ + ...committedSelection, + openerEditor: openerEditorOpen ? null : { worksetName: detail.name }, + }); + }; + const updateCreateDraft = (draft: WorksetCreateDraft) => { + setSelection((previous) => ( + previous.scene.kind === 'create' + ? { ...previous, scene: { kind: 'create', draft }, createNotice: undefined } + : previous + )); + }; + const submitCreateWorkset = () => { + if (scene.kind !== 'create') return; + // A fresh submit drops the previous failure notice; the draft itself is + // sent verbatim (normalized) and only cleared by an explicit success + // transition. An unsubmittable draft performs no Host call. + setSelection((previous) => ({ ...previous, createNotice: undefined })); + runWorksetCreateSubmit(scene.draft, onCreateWorkset); + }; + const createNoticeText = committedSelection.createNotice === undefined + ? undefined + : committedSelection.createNotice.kind === 'invalidMembers' + ? t('worksetCreate.invalidMemberNotice') + : committedSelection.createNotice.message ?? t('worksetCreate.createFailedDefault'); + + return ( + <section className="mb-6" data-workset-project-picker data-workset-picker-scene> + <div className="mb-3 flex items-start justify-between gap-2"> + <div className="min-w-0"> + <div + className="text-[10px] font-semibold uppercase tracking-wide" + style={descriptionStyle} + > + {t('worksetNavigation.title')} + </div> + <h2 className="truncate text-base font-semibold" title={navigation.project.label}> + {navigation.project.label} + </h2> + </div> + <button + type="button" + onClick={onBackToCurrentProject} + aria-label={t('worksetNavigation.returnCurrent')} + title={t('worksetNavigation.returnCurrent')} + className={`shrink-0 px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {t('worksetNavigation.returnCurrent')} + </button> + </div> + + {scene.kind === 'list' && scene.staleDetailName !== undefined && ( <div - className="text-[10px] font-semibold uppercase tracking-wide" - style={{ color: 'var(--vscode-descriptionForeground)' }} + role="status" + data-workset-stale-notice + className="mb-3 text-xs" + style={descriptionStyle} > - {t('worksetNavigation.title')} + {t('worksetNavigation.staleDetailNotice', { name: scene.staleDetailName })} </div> - <h2 className="truncate text-base font-semibold" title={navigation.project.label}> - {navigation.project.label} - </h2> - </div> - <button - type="button" - onClick={onBackToCurrentProject} - aria-label={t('worksetNavigation.returnCurrent')} - title={t('worksetNavigation.returnCurrent')} - className="shrink-0 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1" - style={{ - background: 'var(--vscode-button-secondaryBackground)', - color: 'var(--vscode-button-secondaryForeground)', - outlineColor: 'var(--vscode-focusBorder)', - }} - > - {t('worksetNavigation.returnCurrent')} - </button> - </div> - - <div className="space-y-3"> - {navigation.worksets.map((workset) => { - const selectableProjects = workset.members.filter((member) => ( - member.role === 'project' - && member.selectable - && member.project - && !isCurrentProject(navigation, member) - )); - - return ( + )} + + <div data-workset-scene={scene.kind} className="space-y-2"> + {detail ? ( <section - key={workset.name} + data-workset-detail={detail.name} className="rounded border p-2" style={{ borderColor: 'var(--vscode-panel-border)' }} - aria-labelledby={`workset-${workset.name}`} + aria-labelledby={worksetDetailHeadingId(detail.name)} > - <div className="mb-2 flex items-start justify-between gap-2"> - <div className="min-w-0"> - <h3 - id={`workset-${workset.name}`} - className="truncate text-sm font-semibold" - title={workset.name} - > - {workset.name} - </h3> - {workset.tool && ( - <div className="truncate text-xs" style={{ color: 'var(--vscode-descriptionForeground)' }}> - {workset.tool} - </div> - )} + <div className="mb-2 min-w-0"> + <button + type="button" + data-action="back-to-worksets" + onClick={() => selectScene(backToWorksetListScene())} + aria-label={t('worksetNavigation.backToList')} + title={t('worksetNavigation.backToList')} + className={`px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {/* The arrow glyph is decorative: hidden from assistive tech so + only the translated label is announced. */} + <span aria-hidden="true" className="mr-1">←</span> + {t('worksetNavigation.backToList')} + </button> + <h3 + id={worksetDetailHeadingId(detail.name)} + className="mt-2 truncate text-sm font-semibold" + title={detail.name} + > + {detail.name} + </h3> + <div className="truncate text-xs" style={descriptionStyle}> + {detail.tool + ? t('worksetNavigation.savedTool', { tool: detail.tool }) + : t('worksetNavigation.defaultTool')} </div> + </div> + + <div className="mb-2 flex min-w-0 flex-wrap items-center gap-1"> <button type="button" data-action="open-workset" - onClick={() => onOpenWorkset(workset.name)} - aria-label={t('worksetsPage.openWholeWorkset', { name: workset.name })} - title={t('worksetsPage.openWholeWorkset', { name: workset.name })} - className="shrink-0 rounded px-2 py-0.5 text-xs focus:outline-none focus:ring-1" - style={{ - background: 'var(--vscode-button-background)', - color: 'var(--vscode-button-foreground)', - outlineColor: 'var(--vscode-focusBorder)', - }} + onClick={(event) => runWorksetOpenAction(detail.name, onOpenWorkset, event)} + aria-label={t('worksetsPage.openWholeWorkset', { name: detail.name })} + title={t('worksetsPage.openWholeWorkset', { name: detail.name })} + className={`px-2 py-1 text-xs ${primaryButtonClass}`} > - {t('worksetsPage.openWholeWorksetShort')} + {t('worksetNavigation.openAll')} + </button> + <button + type="button" + data-action="reveal-one-time-opener" + onClick={toggleOpenerEditor} + aria-label={t('worksetNavigation.openWithTool')} + title={t('worksetNavigation.openWithTool')} + aria-expanded={openerEditorOpen} + aria-controls="workset-one-time-opener-form" + className={`px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {t('worksetNavigation.openWithTool')} </button> </div> - <div className="space-y-1"> - {workset.members.map((member) => { + {/* Always-mounted disclosure: the toggle's aria-controls id + resolves in both states, and the open state is part of the key + so any close (toggle, Back, Workset switch) remounts the form + and discards the typed id exactly as the previous unmount-on- + collapse did. */} + <WorksetOneTimeOpenerForm + key={`${detail.name}:${openerEditorOpen ? 'open' : 'closed'}`} + name={detail.name} + hidden={!openerEditorOpen} + onOpen={(name, tool) => runWorksetOpenAction(name, onOpenWorkset, undefined, tool)} + /> + + <div + className="mb-2 flex min-w-0 items-baseline justify-between gap-2" + data-workset-members-section + > + <span + className="text-[10px] font-semibold uppercase tracking-wide" + style={descriptionStyle} + > + {t('worksetNavigation.membersSection')} + </span> + <span className="min-w-0 truncate text-xs" style={descriptionStyle}> + {t('worksetsPage.memberCount', { count: String(detail.members.length) })} + </span> + </div> + <div + className="divide-y divide-[color:var(--vscode-panel-border)]" + data-workset-members={detail.name} + > + {detail.members.map((member) => { const current = member.role === 'project' && isCurrentProject(navigation, member); + const currentRoot = member.role === 'store' + && activeStoreId !== undefined + && member.storeId === activeStoreId; const selectable = member.role === 'project' && member.selectable && Boolean(member.project) && !current; + const memberIcon = member.role === 'store' + ? 'codicon-library' + : 'codicon-folder'; + const gitLabel = formatWorksetMemberGitLabel(member.git); if (selectable) { return ( <button - key={`${workset.name}:${member.path}`} + key={`${detail.name}:${member.path}`} type="button" data-workset-project={member.path} - onClick={() => onSelectProject(workset.name, member.path)} + onClick={() => onSelectProject(detail.name, member.path)} aria-label={t('worksetNavigation.switchProject', { name: member.name })} title={member.path} - className="flex w-full min-w-0 items-start justify-between gap-2 rounded px-2 py-1 text-left text-xs focus:outline-none focus:ring-1" - style={{ - background: 'var(--vscode-button-secondaryBackground)', - color: 'var(--vscode-button-secondaryForeground)', - outlineColor: 'var(--vscode-focusBorder)', - }} + className={`flex w-full min-w-0 items-start justify-between gap-2 px-2 py-1.5 text-left text-xs ${focusRing} transition-colors duration-150 hover:bg-[var(--vscode-list-hoverBackground)]`} > - <span className="flex min-w-0 flex-col"> - <span className="truncate">{member.name}</span> - {member.git?.branch && ( - <span - className="truncate text-[10px]" - title={member.git.repository ?? member.git.branch} - style={{ color: 'var(--vscode-descriptionForeground)' }} - > - {member.git.branch} - </span> - )} + <span className="flex min-w-0 flex-1 items-start gap-1.5"> + <span aria-hidden="true" className={`codicon ${memberIcon} shrink-0`} /> + <span className="flex min-w-0 flex-col"> + <span className="min-w-0 truncate">{member.name}</span> + {gitLabel && ( + <span + className="min-w-0 truncate text-[10px]" + title={member.git?.repository ?? member.git?.branch} + style={descriptionStyle} + > + {gitLabel} + </span> + )} + </span> </span> <span className="shrink-0">{t('worksetNavigation.switchProjectShort')}</span> </button> ); } + if (member.role === 'store' && !currentRoot) { + return ( + <button + key={`${detail.name}:${member.path}`} + type="button" + data-workset-store={member.storeId} + data-action="use-planning-root" + onClick={() => onSelectWorksetStore(detail.name, member.path)} + aria-label={t('worksetNavigation.useAsPlanningRootAria', { name: member.name })} + title={member.path} + className={`flex w-full min-w-0 items-start justify-between gap-2 px-2 py-1.5 text-left text-xs ${focusRing} transition-colors duration-150 hover:bg-[var(--vscode-list-hoverBackground)]`} + > + <span className="flex min-w-0 flex-1 items-start gap-1.5"> + <span aria-hidden="true" className={`codicon ${memberIcon} shrink-0`} /> + <span className="flex min-w-0 flex-col"> + <span className="min-w-0 truncate">{member.name}</span> + <span className="min-w-0 truncate text-[10px]"> + {t('worksetNavigation.planningStore')} + </span> + </span> + </span> + <span className="shrink-0">{t('worksetNavigation.useAsPlanningRoot')}</span> + </button> + ); + } + return ( <div - key={`${workset.name}:${member.path}`} + key={`${detail.name}:${member.path}`} data-workset-store={member.role === 'store' ? member.storeId : undefined} - className="flex min-w-0 items-start justify-between gap-2 px-2 py-1 text-xs" + data-workset-member-state={ + currentRoot ? 'current-root' : current ? 'current' : undefined + } + className="flex min-w-0 items-start justify-between gap-2 px-2 py-1.5 text-xs" title={member.path} - style={{ color: 'var(--vscode-descriptionForeground)' }} + style={descriptionStyle} > - <span className="min-w-0 truncate"> - {member.name} - {member.git?.branch ? ` · ${member.git.branch}` : ''} + <span className="flex min-w-0 flex-1 items-start gap-1.5"> + <span aria-hidden="true" className={`codicon ${memberIcon} shrink-0`} /> + <span className="flex min-w-0 flex-col"> + <span className="min-w-0 truncate">{member.name}</span> + {member.role === 'store' && ( + <span className="min-w-0 truncate text-[10px]"> + {t('worksetNavigation.planningStore')} + </span> + )} + {gitLabel && ( + <span + className="min-w-0 truncate text-[10px]" + title={member.git?.repository ?? member.git?.branch} + > + {gitLabel} + </span> + )} + </span> </span> <span className="shrink-0"> - {member.role === 'store' - ? t('worksetNavigation.planningStore') - : current - ? t('worksetNavigation.current') - : t('worksetNavigation.unavailable')} + {currentRoot + ? t('worksetNavigation.currentRoot') + : member.role === 'store' + ? t('worksetNavigation.planningStore') + : current + ? t('worksetNavigation.current') + : t('worksetNavigation.unavailable')} </span> </div> ); })} </div> - {selectableProjects.length === 0 && ( - <p className="mt-2 text-xs" style={{ color: 'var(--vscode-descriptionForeground)' }}> + {explicitStoreSelector === true && ( + <div className="mt-2" data-planning-root-context> + <button + type="button" + data-action="use-project-default" + onClick={onSelectProjectDefaultRoot} + aria-label={t('worksetNavigation.useProjectDefault')} + title={t('worksetNavigation.useProjectDefault')} + className={`px-2 py-1 text-xs ${secondaryButtonClass}`} + > + {t('worksetNavigation.useProjectDefault')} + </button> + </div> + )} + + {detail.members.every((member) => ( + member.role !== 'project' + || !member.selectable + || !member.project + || isCurrentProject(navigation, member) + )) && ( + <p className="mt-2 text-xs" style={descriptionStyle}> {t('worksetNavigation.noSelectableProjects')} </p> )} </section> - ); - })} - </div> - </section> -); + ) : scene.kind === 'create' ? ( + <WorksetCreateForm + draft={scene.draft} + lockedMemberPath={navigation.project.projectPath} + noticeText={createNoticeText} + onDraftChange={updateCreateDraft} + onPickMembers={onPickMembers} + onSubmit={submitCreateWorkset} + onCancel={() => selectScene(backToWorksetListScene())} + /> + ) : ( + <> + {/* Create entry (or its capability explanation). Inside the empty + list the explanation and entry form one bounded group so the + first-creation affordance reads as a single clear unit. */} + {(() => { + const createEntry = createAvailable === false ? ( + // The runtime lacks the Workset capability: the Create entry is + // hidden and the existing upgrade explanation stays visible. + // List/detail remain usable for reading existing data. + <p + role="status" + data-workset-capability-notice + className="text-xs leading-snug" + style={descriptionStyle} + > + {t('scope.featureGated.upgradeNotice')} + </p> + ) : ( + <button + type="button" + data-action="create-workset" + onClick={() => selectScene(openWorksetCreateScene(navigation.project))} + aria-label={t('worksetCreate.createAction')} + title={t('worksetCreate.createAction')} + className={`w-full px-2 py-1.5 text-xs ${secondaryButtonClass}`} + > + {t('worksetCreate.createAction')} + </button> + ); + if (navigation.worksets.length === 0) { + return ( + <div + data-workset-empty-group + className="space-y-2 rounded border p-2" + style={{ borderColor: 'var(--vscode-panel-border)' }} + > + <p + data-workset-empty-list + className="text-xs" + style={descriptionStyle} + > + {t('worksetCreate.emptyList')} + </p> + {createEntry} + </div> + ); + } + return ( + <> + <div + data-workset-list + className="overflow-hidden rounded border" + style={{ borderColor: 'var(--vscode-panel-border)' }} + > + <div className="divide-y divide-[color:var(--vscode-panel-border)]"> + {navigation.worksets.map((workset) => ( + <div + key={workset.name} + className="flex items-stretch justify-between gap-1 transition-colors duration-150 hover:bg-[var(--vscode-list-hoverBackground)]" + > + <button + type="button" + data-workset-row={workset.name} + onClick={() => selectScene(openWorksetDetailScene(workset.name))} + aria-label={t('worksetNavigation.openDetail', { name: workset.name })} + className={`flex min-w-0 flex-1 items-center justify-between gap-2 px-2 py-1.5 text-left ${focusRing}`} + > + <span className="flex min-w-0 flex-1 flex-col items-start gap-0.5"> + <span className="w-full truncate text-sm font-semibold" title={workset.name}> + {workset.name} + </span> + <span + className="flex w-full min-w-0 items-center gap-2 text-xs" + style={descriptionStyle} + > + <span className="shrink-0"> + {t('worksetsPage.memberCount', { count: String(workset.members.length) })} + </span> + <span className="min-w-0 truncate" title={workset.tool}> + {workset.tool ?? t('worksetNavigation.defaultTool')} + </span> + </span> + </span> + <span aria-hidden="true" className="codicon codicon-chevron-right shrink-0" /> + </button> + <button + type="button" + data-action="open-workset" + onClick={(event) => runWorksetOpenAction(workset.name, onOpenWorkset, event)} + aria-label={t('worksetsPage.openWholeWorkset', { name: workset.name })} + title={t('worksetsPage.openWholeWorkset', { name: workset.name })} + className={`shrink-0 self-center px-2 py-0.5 text-xs ${primaryButtonClass}`} + > + {t('worksetsPage.openWholeWorksetShort')} + </button> + </div> + ))} + </div> + </div> + <div className="pt-1">{createEntry}</div> + </> + ); + })()} + </> + )} + </div> + </section> + ); +}; diff --git a/src/webview/types/messages.ts b/src/webview/types/messages.ts index 5f71c9b..3b1e4d1 100644 --- a/src/webview/types/messages.ts +++ b/src/webview/types/messages.ts @@ -128,7 +128,11 @@ export type WebviewMessage = | { type: 'copyCliDiagnostic' } | { type: 'openCliInstallDocs' } | { type: 'selectScope'; scopeId: string } - | { type: 'openWorkset'; name: string } + | { type: 'selectWorksetStore'; worksetName: string; memberPath: string } + | { type: 'selectProjectDefaultRoot' } + | { type: 'pickWorksetMembers' } + | { type: 'createWorkset'; name: string; members: string[]; tool?: string } + | { type: 'openWorkset'; name: string; tool?: string } | { type: 'removeWorkset'; name: string } | { type: 'requestRegisterStore' } | { type: 'requestSetupStore' }; @@ -215,7 +219,18 @@ export type ExtensionMessage = | { type: 'specRequirements'; specId: string; requirements: string[] } | { type: 'artifactInvalidated'; changeName: string; artifactTypes: string[] } | { type: 'interactiveWorkflowState'; changeName: string; state: InteractiveWorkflowState } - | { type: 'cliActivationDiagnostic'; diagnostic: CliActivationDiagnosticView; mode: 'blocking' | 'warning' }; + | { type: 'cliActivationDiagnostic'; diagnostic: CliActivationDiagnosticView; mode: 'blocking' | 'warning' } + | { + type: 'worksetMembersPicked'; + paths: string[]; + /** + * Picks the Host dropped because their realpath could not be resolved. + * Optional so older senders/consumers are unaffected; non-empty means the + * create form must show the recoverable invalid-member explanation. + */ + droppedPaths?: string[]; + } + | { type: 'worksetCreateResult'; success: boolean; name: string; message?: string }; // Data types export interface CliActivationDiagnosticView { @@ -721,9 +736,31 @@ export const sendMessage = { scopeId, }), - openWorkset: (name: string): WebviewMessage => ({ + openWorkset: (name: string, tool?: string): WebviewMessage => ({ type: 'openWorkset', name, + ...(tool ? { tool } : {}), + }), + + pickWorksetMembers: (): WebviewMessage => ({ + type: 'pickWorksetMembers', + }), + + createWorkset: (name: string, members: string[], tool?: string): WebviewMessage => ({ + type: 'createWorkset', + name, + members, + ...(tool ? { tool } : {}), + }), + + selectWorksetStore: (worksetName: string, memberPath: string): WebviewMessage => ({ + type: 'selectWorksetStore', + worksetName, + memberPath, + }), + + selectProjectDefaultRoot: (): WebviewMessage => ({ + type: 'selectProjectDefaultRoot', }), removeWorkset: (name: string): WebviewMessage => ({ diff --git a/test/extension/providers/dashboardViewProvider.test.ts b/test/extension/providers/dashboardViewProvider.test.ts index 3a6c8f5..b604f7d 100644 --- a/test/extension/providers/dashboardViewProvider.test.ts +++ b/test/extension/providers/dashboardViewProvider.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; +import * as fsPromises from 'fs/promises'; import { DashboardViewProvider } from '@extension/providers/dashboardViewProvider'; import { ChangeDetailPanelManager } from '@extension/providers/changeDetailPanelManager'; +import { logger } from '@extension/utils/logger'; import type { ExtensionMessage, ProjectChangesExplorerData, @@ -12,6 +14,15 @@ import type { OpenSpecRootBinding, ProjectContext } from '@extension/services/ty const adapterFillChat = vi.hoisted(() => vi.fn()); +// realpath is the Host's canonicalization step for Workset member paths. The +// fake fixture paths do not exist on disk, so the identity implementation is +// mocked in; per-test `mockImplementationOnce` chains simulate symlink +// resolution and unresolvable folders. +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal<typeof import('fs/promises')>(); + return { ...actual, realpath: vi.fn(async (value: unknown) => value) }; +}); + vi.mock('@extension/adapters', () => ({ getCurrentAdapter: vi.fn(async () => ({ id: 'cursor', @@ -58,6 +69,7 @@ vi.mock('vscode', () => { createWebviewPanel: vi.fn(), showInputBox: vi.fn(async () => 'project-change'), showInformationMessage: vi.fn(), + showOpenDialog: vi.fn(), }, ViewColumn: { One: 1, @@ -677,6 +689,15 @@ describe('DashboardViewProvider', () => { return { onRefresh: vi.fn(() => ({ dispose: vi.fn() })), getCliDiagnostic: vi.fn().mockReturnValue(null), + // Runtime default: the resolved CLI supports Worksets. Tests exercising + // the unavailable-capability paths override this with worksets: false. + getCapabilities: vi.fn().mockReturnValue({ + stores: true, + context: true, + doctor: true, + worksets: true, + diagnostics: [], + }), ...overrides, }; } @@ -707,6 +728,33 @@ describe('DashboardViewProvider', () => { }; } + function makeNavigationPayload( + fixture: ReturnType<typeof makeProjectFixture>, + worksetNames: string[], + ) { + return { + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + worksetNavigation: { + project: fixture.project, + worksets: worksetNames.map((name) => ({ + name, + members: [{ + name: fixture.project.label, + path: fixture.project.projectPath, + role: 'project' as const, + selectable: true, + project: fixture.project, + }], + })), + }, + }; + } + function makeProjectProvider( dataManager: Record<string, unknown>, gateway: Record<string, unknown>, @@ -1059,6 +1107,2272 @@ describe('DashboardViewProvider', () => { })); }); + it('activates an explicitly selected Workset Store only after full validation', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (binding: OpenSpecRootBinding) => ({ + project: fixture.project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(storeBinding) : payloadFor(fixture.binding) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + + expect(gateway.resolveWorksetStore).toHaveBeenCalledWith(fixture.project, 'team', '/stores/team-store'); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + const storeMessage = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(storeMessage).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + binding: storeBinding, + changes: [makeProjectChange('store-change')], + cache: { source: 'fresh', stale: false }, + }), + })); + + // The accepted selector keeps driving later reloads of the same Project. + postMessage.mockClear(); + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + }); + + it('rejects a forged or stale Workset Store request without publishing or activating a selector', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + resolveWorksetStore: vi.fn().mockRejectedValue({ + name: 'ProjectDataAccessError', + phase: 'resolve', + message: 'Workset member is not a registered Planning Store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const initialLoadCalls = gateway.loadProjectSidebarData.mock.calls.length; + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/forged/store' }); + await vi.runAllTimersAsync(); + + expect(gateway.resolveWorksetStore).toHaveBeenCalledTimes(1); + expect(gateway.loadProjectSidebarData).toHaveBeenCalledTimes(initialLoadCalls); + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + + // No selector was activated: the next plain reload stays selector-free. + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + }); + + it('discards a Store selection whose resolved binding misses the validated Store context', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const mismatchedBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/other-root', + rootSource: 'store', + storeId: 'team-store', + }; + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ({ + project: fixture.project, + binding: storeId ? mismatchedBinding : fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + })), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'setContext' && message.data?.binding?.rootPath === '/stores/other-root' + ))).toBe(false); + + // The selector never became active because the canonical root mismatched. + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + }); + + it('keeps the previous Project snapshot when a Store selection load fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const initialPayload = { + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }; + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => { + if (storeId) throw new Error('store context unavailable'); + return initialPayload; + }), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const acceptedMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext'); + expect(acceptedMessages.length).toBeGreaterThan(0); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + expect((provider as any).cachedProjectSidebarData.changes).toEqual(initialPayload.changes); + expect((provider as any).currentProjectBinding).toEqual(fixture.binding); + + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + }); + + it('posts exactly one recoverable error when a Workset Store selection is rejected', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const initialPayload = { + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }; + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(initialPayload), + resolveWorksetStore: vi.fn().mockRejectedValue({ + name: 'ProjectDataAccessError', + phase: 'resolve', + message: 'Workset member is not a registered Planning Store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/forged/store' }); + await vi.runAllTimersAsync(); + + const errorMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'error'); + expect(errorMessages).toHaveLength(1); + expect(errorMessages[0].message).toContain('not a registered Planning Store'); + // Fail-closed: no snapshot publish, and the previous state is preserved. + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + expect((provider as any).explicitProjectStoreId).toBeUndefined(); + expect((provider as any).currentProjectBinding).toEqual(fixture.binding); + expect((provider as any).cachedProjectSidebarData.changes).toEqual(initialPayload.changes); + }); + + it('posts exactly one recoverable error when an accepted Store selection load fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const initialPayload = { + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }; + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce(initialPayload) + .mockImplementation(async (_project: ProjectContext, storeId?: string) => { + if (storeId) throw new Error('store context unavailable'); + return initialPayload; + }), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + + const errorMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'error'); + expect(errorMessages).toHaveLength(1); + expect(errorMessages[0].message).toContain('store context unavailable'); + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + expect((provider as any).explicitProjectStoreId).toBeUndefined(); + expect((provider as any).currentProjectBinding).toEqual(fixture.binding); + }); + + it('posts exactly one recoverable error when the default-root restore fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storePayload = { + project: fixture.project, + binding: { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + } satisfies OpenSpecRootBinding, + changes: [makeProjectChange('store-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }; + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce({ + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }) + .mockImplementation(async (_project: ProjectContext, storeId?: string) => { + if (storeId) return storePayload; + throw new Error('project default root unavailable'); + }), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + + const errorMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'error'); + expect(errorMessages).toHaveLength(1); + expect(errorMessages[0].message).toContain('project default root unavailable'); + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + }); + + it('logs expected-versus-actual context when the acceptance gate drops a refresh', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const mismatchedBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/other-root', + rootSource: 'store', + storeId: 'team-store', + }; + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ({ + project: fixture.project, + binding: storeId ? mismatchedBinding : fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + })), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + vi.mocked(logger.warn).mockClear(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext' && message.data?.binding?.rootPath === '/stores/other-root')).toBe(false); + const gateWarnings = vi.mocked(logger.warn).mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes('acceptance gate')); + expect(gateWarnings).toHaveLength(1); + // The warning carries enough context to diagnose the permanently-dropped + // refresh: the validated expectation and the mismatching actual values. + expect(gateWarnings[0]).toContain('/stores/team-store'); + expect(gateWarnings[0]).toContain('/stores/other-root'); + expect(gateWarnings[0]).toContain('team-store'); + }); + + it('discards a Store selection response superseded by a newer request generation', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const resolvers: Array<(value: unknown) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn((_project: ProjectContext, storeId?: string) => ( + storeId + ? new Promise((resolve) => resolvers.push(resolve)) + : Promise.resolve({ + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + const staleSelection = handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + for (let tick = 0; tick < 10; tick += 1) { + await Promise.resolve(); + } + expect(resolvers).toHaveLength(1); + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + resolvers[0]?.({ + project: fixture.project, + binding: storeBinding, + changes: [makeProjectChange('store-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + await staleSelection; + await Promise.resolve(); + + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'setContext' && message.data?.binding?.storeId === 'team-store' + ))).toBe(false); + expect((provider as any).currentProjectBinding).toEqual(fixture.binding); + + // The superseded selection never committed its selector. + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + }); + + it('restores the Project default Planning root only after a selector-free binding resolves', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (binding: OpenSpecRootBinding) => ({ + project: fixture.project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(storeBinding) : payloadFor(fixture.binding) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + postMessage.mockClear(); + + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + const restoredMessage = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(restoredMessage).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + cache: { source: 'fresh', stale: false }, + }), + })); + + // The selector stays cleared for later reloads. + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + + // Without an active selector the restore action is a fail-closed no-op. + const loadCallsBeforeNoop = gateway.loadProjectSidebarData.mock.calls.length; + postMessage.mockClear(); + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenCalledTimes(loadCallsBeforeNoop); + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + }); + + it('publishes whether an explicit Store selector drove the accepted snapshot', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (binding: OpenSpecRootBinding, explicitStoreSelector: boolean) => ({ + project: fixture.project, + binding, + explicitStoreSelector, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(storeBinding, true) : payloadFor(fixture.binding, false) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect(postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1)) + .toEqual(expect.objectContaining({ + data: expect.objectContaining({ explicitStoreSelector: true }), + })); + + // The selector keeps marking later selector-driven reloads as explicit. + postMessage.mockClear(); + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1)) + .toEqual(expect.objectContaining({ + data: expect.objectContaining({ explicitStoreSelector: true }), + })); + + // Returning to the Project default root clears the flag. + postMessage.mockClear(); + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + expect(postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1)) + .toEqual(expect.objectContaining({ + data: expect.objectContaining({ explicitStoreSelector: false }), + })); + }); + + it('treats a CLI-declared default Store root as selector-free in the published snapshot', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + // The project default root IS a Store root: the selector-free binding + // carries a storeId from the CLI's root.store_id, but no explicit selector + // is active, so the published flag must stay false. + const declaredStoreBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/declared-store', + rootSource: 'store', + storeId: 'declared-store', + }; + const gateway = { + loadProjectSidebarData: vi.fn(async () => ({ + project: fixture.project, + binding: declaredStoreBinding, + explicitStoreSelector: false, + changes: [makeProjectChange('declared-store-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + })), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + + const published = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(published).toEqual(expect.objectContaining({ + data: expect.objectContaining({ + binding: expect.objectContaining({ storeId: 'declared-store' }), + explicitStoreSelector: false, + }), + })); + }); + + it('retargets the watcher to the restored binding root when the default root is itself a Store root', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const selectedStoreBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + // The selector-free default still resolves to a Store root via the CLI's + // root.store_id: the watcher must follow the restored binding root, not the + // Project path, or store-root edits would never auto-refresh. + const declaredStoreBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/declared-store', + rootSource: 'store', + storeId: 'declared-store', + }; + const payloadFor = (binding: OpenSpecRootBinding) => ({ + project: fixture.project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(selectedStoreBinding) : payloadFor(declaredStoreBinding) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const setWatchedProjectRoot = vi.fn(); + const dataManager = makeDataManager({ setWatchedProjectRoot }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect(setWatchedProjectRoot).toHaveBeenLastCalledWith('/stores/team-store'); + + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + + expect(setWatchedProjectRoot).toHaveBeenCalledTimes(2); + expect(setWatchedProjectRoot).toHaveBeenLastCalledWith('/stores/declared-store'); + expect(setWatchedProjectRoot).not.toHaveBeenCalledWith(fixture.project.projectPath); + }); + + it('drops a Store selection whose resolve is superseded by a faster Store selection', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const bindingFor = (storeId: string): OpenSpecRootBinding => ({ + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: `/stores/${storeId}`, + rootSource: 'store', + storeId, + }); + const payloadFor = (storeId?: string) => ({ + project: fixture.project, + binding: storeId ? bindingFor(storeId) : fixture.binding, + changes: [makeProjectChange(storeId ?? 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const resolveStore: Array<(value: { storeId: string; canonicalRoot: string }) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => payloadFor(storeId)), + resolveWorksetStore: vi.fn(() => new Promise<{ storeId: string; canonicalRoot: string }>((resolve) => { + resolveStore.push(resolve); + })), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + // Click Store A first: its fresh-inventory resolve stays pending. + const slowSelection = handler?.({ + type: 'selectWorksetStore', + worksetName: 'team', + memberPath: '/stores/store-a', + }); + expect(resolveStore).toHaveLength(1); + // Click Store B: it resolves fast and commits. + const fastSelection = handler?.({ + type: 'selectWorksetStore', + worksetName: 'team', + memberPath: '/stores/store-b', + }); + resolveStore[1]?.({ storeId: 'store-b', canonicalRoot: '/stores/store-b' }); + await fastSelection; + await vi.runAllTimersAsync(); + + const loadCallsAfterB = gateway.loadProjectSidebarData.mock.calls.length; + const contextMessages = () => postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext'); + expect(contextMessages().at(-1)).toEqual(expect.objectContaining({ + data: expect.objectContaining({ binding: bindingFor('store-b') }), + })); + expect((provider as any).explicitProjectStoreId).toBe('store-b'); + + // Store A's late resolve must be dropped: no reload, no publish, no selector flip. + resolveStore[0]?.({ storeId: 'store-a', canonicalRoot: '/stores/store-a' }); + await slowSelection; + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenCalledTimes(loadCallsAfterB); + expect(gateway.loadProjectSidebarData.mock.calls.some(([, storeId]) => storeId === 'store-a')).toBe(false); + expect(contextMessages().at(-1)).toEqual(expect.objectContaining({ + data: expect.objectContaining({ binding: bindingFor('store-b') }), + })); + expect((provider as any).explicitProjectStoreId).toBe('store-b'); + }); + + it('drops a Store selection whose resolve is superseded by a return to the Project default root', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const bindingFor = (storeId: string): OpenSpecRootBinding => ({ + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: `/stores/${storeId}`, + rootSource: 'store', + storeId, + }); + const payloadFor = (storeId?: string) => ({ + project: fixture.project, + binding: storeId ? bindingFor(storeId) : fixture.binding, + changes: [makeProjectChange(storeId ?? 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const resolveStore: Array<(value: { storeId: string; canonicalRoot: string }) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => payloadFor(storeId)), + resolveWorksetStore: vi.fn(() => new Promise<{ storeId: string; canonicalRoot: string }>((resolve) => { + resolveStore.push(resolve); + })), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + // Activate Store B first so the default-root action has a selector to clear. + const activateB = handler?.({ + type: 'selectWorksetStore', + worksetName: 'team', + memberPath: '/stores/store-b', + }); + resolveStore[0]?.({ storeId: 'store-b', canonicalRoot: '/stores/store-b' }); + await activateB; + await vi.runAllTimersAsync(); + expect((provider as any).explicitProjectStoreId).toBe('store-b'); + postMessage.mockClear(); + + // Click Store A: its resolve stays pending while the user returns to default. + const slowSelection = handler?.({ + type: 'selectWorksetStore', + worksetName: 'team', + memberPath: '/stores/store-a', + }); + expect(resolveStore).toHaveLength(2); + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + + const loadCallsAfterDefault = gateway.loadProjectSidebarData.mock.calls.length; + const contextMessages = () => postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext'); + expect(contextMessages().at(-1)).toEqual(expect.objectContaining({ + data: expect.objectContaining({ binding: fixture.binding }), + })); + expect((provider as any).explicitProjectStoreId).toBeUndefined(); + + // Store A's late resolve must not overturn the explicit return-to-default. + resolveStore[1]?.({ storeId: 'store-a', canonicalRoot: '/stores/store-a' }); + await slowSelection; + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenCalledTimes(loadCallsAfterDefault); + expect(gateway.loadProjectSidebarData.mock.calls.some(([, storeId]) => storeId === 'store-a')).toBe(false); + expect(contextMessages().at(-1)).toEqual(expect.objectContaining({ + data: expect.objectContaining({ binding: fixture.binding }), + })); + expect((provider as any).explicitProjectStoreId).toBeUndefined(); + + // The selector stays cleared for later reloads. + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + }); + + it('drops a Workset Project selection superseded by a newer committed request', async () => { + vi.useFakeTimers(); + const current = makeProjectFixture('/planning/current'); + const other: ProjectContext = { + id: '/projects/other', + label: 'Other Project', + projectPath: '/projects/other', + }; + const otherBinding: OpenSpecRootBinding = { + projectId: other.id, + commandCwd: other.projectPath, + rootPath: '/planning/other', + rootSource: 'nearest', + }; + const storeBinding: OpenSpecRootBinding = { + projectId: current.project.id, + commandCwd: current.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (project: ProjectContext, binding: OpenSpecRootBinding) => ({ + project, + binding, + changes: [makeProjectChange(binding.storeId ?? project.label)], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const projectResolvers: Array<(value: ProjectContext | undefined) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn(async (project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(project, storeBinding) : payloadFor(project, project.id === current.project.id ? current.binding : otherBinding) + )), + resolveWorksetProject: vi.fn(() => new Promise<ProjectContext | undefined>((resolve) => { + projectResolvers.push(resolve); + })), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + resolveBinding: vi.fn(), + }; + const setWatchedProjectRoot = vi.fn(); + const dataManager = makeDataManager({ setWatchedProjectRoot }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, current); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + const supersededSelection = handler?.({ + type: 'selectWorksetProject', + worksetName: 'shared-workset', + memberPath: other.projectPath, + }); + expect(projectResolvers).toHaveLength(1); + + // A newer Store selection resolves fast and commits while the Project resolve pends. + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + + projectResolvers[0]?.(other); + await supersededSelection; + await vi.runAllTimersAsync(); + + expect(gateway.resolveBinding).not.toHaveBeenCalled(); + expect(setWatchedProjectRoot).not.toHaveBeenCalledWith(other.projectPath); + expect(gateway.loadProjectSidebarData.mock.calls.some(([project]) => project?.id === other.id)).toBe(false); + expect((provider as any).projectContext).toEqual(current.project); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + }); + + it('retargets the watcher to the accepted Store root and back to the Project root', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (binding: OpenSpecRootBinding) => ({ + project: fixture.project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(storeBinding) : payloadFor(fixture.binding) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const setWatchedProjectRoot = vi.fn(); + const dataManager = makeDataManager({ setWatchedProjectRoot }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + expect(setWatchedProjectRoot).not.toHaveBeenCalled(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect(setWatchedProjectRoot).toHaveBeenCalledTimes(1); + expect(setWatchedProjectRoot).toHaveBeenCalledWith('/stores/team-store'); + + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + expect(setWatchedProjectRoot).toHaveBeenCalledTimes(2); + expect(setWatchedProjectRoot).toHaveBeenLastCalledWith(fixture.project.projectPath); + }); + + it('keeps the watcher on the accepted binding root while an explicit Store selector stays active', async () => { + vi.useFakeTimers(); + const current = makeProjectFixture(); + const other: ProjectContext = { + id: '/projects/other', + label: 'Other Project', + projectPath: '/projects/other', + }; + const currentStoreBinding: OpenSpecRootBinding = { + projectId: current.project.id, + commandCwd: current.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const otherStoreBinding: OpenSpecRootBinding = { + projectId: other.id, + commandCwd: other.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (project: ProjectContext, binding: OpenSpecRootBinding) => ({ + project, + binding, + changes: [makeProjectChange('member-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (project: ProjectContext, storeId?: string) => ( + storeId + ? payloadFor(project, project.id === current.project.id ? currentStoreBinding : otherStoreBinding) + : payloadFor(project, current.binding) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + resolveWorksetProject: vi.fn().mockResolvedValue(other), + resolveBinding: vi.fn(async (project: ProjectContext, storeId?: string) => ( + storeId + ? (project.id === current.project.id ? currentStoreBinding : otherStoreBinding) + : { ...current.binding, projectId: project.id, commandCwd: project.projectPath } + )), + }; + const setWatchedProjectRoot = vi.fn(); + const dataManager = makeDataManager({ setWatchedProjectRoot }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, current); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + // Activate the explicit Store selector first. + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + expect(setWatchedProjectRoot).toHaveBeenLastCalledWith('/stores/team-store'); + setWatchedProjectRoot.mockClear(); + + // A Project switch under the active selector must retarget the watcher to + // the accepted binding root (the store root), not the Project path. + await handler?.({ type: 'selectWorksetProject', worksetName: 'shared-workset', memberPath: other.projectPath }); + await vi.runAllTimersAsync(); + expect(setWatchedProjectRoot).toHaveBeenCalledTimes(1); + expect(setWatchedProjectRoot).toHaveBeenLastCalledWith(otherStoreBinding.rootPath); + expect(setWatchedProjectRoot).not.toHaveBeenCalledWith(other.projectPath); + setWatchedProjectRoot.mockClear(); + + // Restoring the Current Project under the same selector follows the same rule. + await handler?.({ type: 'selectCurrentProject' }); + await vi.runAllTimersAsync(); + expect(setWatchedProjectRoot).toHaveBeenCalledTimes(1); + expect(setWatchedProjectRoot).toHaveBeenLastCalledWith(currentStoreBinding.rootPath); + expect(setWatchedProjectRoot).not.toHaveBeenCalledWith(current.project.projectPath); + }); + + it('leaves the watcher target untouched when a Store selection fails to resolve', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + resolveWorksetStore: vi.fn().mockRejectedValue({ + name: 'ProjectDataAccessError', + phase: 'resolve', + message: 'Workset member is not a registered Planning Store', + }), + }; + const setWatchedProjectRoot = vi.fn(); + const dataManager = makeDataManager({ setWatchedProjectRoot }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/forged/store' }); + await vi.runAllTimersAsync(); + + expect(setWatchedProjectRoot).not.toHaveBeenCalled(); + expect((provider as any).explicitProjectStoreId).toBeUndefined(); + }); + + it('opens the native folder picker and posts canonical absolute member paths', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + const vscode = await import('vscode'); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([ + { fsPath: '/repos/docs-link' }, + { fsPath: '/repos/other' }, + ] as any); + // Cross-platform canonicalization is the Host's job: a symlinked pick must + // come back as its real path, and two picks resolving to the same canonical + // root collapse into one entry. + vi.mocked(fsPromises.realpath) + .mockImplementationOnce(async () => '/repos/docs-real') + .mockImplementationOnce(async () => '/repos/docs-real'); + postMessage.mockClear(); + + await handler?.({ type: 'pickWorksetMembers' }); + await vi.runAllTimersAsync(); + + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ + canSelectFolders: true, + canSelectMany: true, + }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'worksetMembersPicked', + paths: ['/repos/docs-real'], + }); + }); + + it('posts nothing when the native folder picker is dismissed', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + // Default showOpenDialog mock resolves undefined (dismissed picker). + postMessage.mockClear(); + + await handler?.({ type: 'pickWorksetMembers' }); + await vi.runAllTimersAsync(); + + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('drops picked paths that cannot be canonicalized instead of posting them', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + const vscode = await import('vscode'); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([ + { fsPath: '/repos/ok' }, + { fsPath: '/repos/gone' }, + ] as any); + vi.mocked(fsPromises.realpath) + .mockImplementationOnce(async () => '/repos/ok') + .mockRejectedValueOnce(new Error('ENOENT')); + postMessage.mockClear(); + + await handler?.({ type: 'pickWorksetMembers' }); + await vi.runAllTimersAsync(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: 'worksetMembersPicked', + paths: ['/repos/ok'], + droppedPaths: ['/repos/gone'], + }); + }); + + it('reports Host-dropped picks recoverably even when every pick is unrealpath-able', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + const vscode = await import('vscode'); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([ + { fsPath: '/repos/gone-a' }, + { fsPath: '/repos/gone-b' }, + ] as any); + vi.mocked(fsPromises.realpath) + .mockRejectedValueOnce(new Error('ENOENT')) + .mockRejectedValueOnce(new Error('ENOENT')); + postMessage.mockClear(); + + await handler?.({ type: 'pickWorksetMembers' }); + await vi.runAllTimersAsync(); + + // Empty add plus the dropped paths for a recoverable notice — no error banner. + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: 'worksetMembersPicked', + paths: [], + droppedPaths: ['/repos/gone-a', '/repos/gone-b'], + }); + expect(postMessage.mock.calls.some(([message]) => message.type === 'error')).toBe(false); + }); + + it('posts one recoverable error when the native folder picker rejects', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + const vscode = await import('vscode'); + vi.mocked(vscode.window.showOpenDialog).mockRejectedValueOnce(new Error('Throwing Thenable')); + postMessage.mockClear(); + + await handler?.({ type: 'pickWorksetMembers' }); + await vi.runAllTimersAsync(); + + const errorMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'error'); + expect(errorMessages).toHaveLength(1); + expect(typeof errorMessages[0].message).toBe('string'); + expect(errorMessages[0].message).toBeTruthy(); + expect(postMessage.mock.calls.some(([message]) => message.type === 'worksetMembersPicked')).toBe(false); + }); + + it('suppresses the selection error when a rejected Store resolve is superseded by a newer request', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const rejectors: Array<(error: Error) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + resolveWorksetStore: vi.fn(() => new Promise<never>((_, reject) => { + rejectors.push(reject); + })), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + // The slow selection's resolve rejects only after a newer request committed. + const slowSelection = handler?.({ + type: 'selectWorksetStore', + worksetName: 'team', + memberPath: '/stores/team-store', + }); + expect(rejectors).toHaveLength(1); + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + rejectors[0]?.(new Error('stale resolve rejection')); + await slowSelection; + await vi.runAllTimersAsync(); + + // The superseded rejection stays silent: no recoverable error is posted. + expect(postMessage.mock.calls.some(([message]) => message.type === 'error')).toBe(false); + }); + + it('rejects malformed Workset creation input before invoking the CLI', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue( + makeNavigationPayload(fixture, ['planning']), + ), + }; + const createWorkset = vi.fn(); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + const malformed: Record<string, unknown>[] = [ + { name: 42, members: ['/projects/current'] }, + { name: ' ', members: ['/projects/current'] }, + { name: '--store', members: ['/projects/current'] }, + { name: 'ok', members: 'not-an-array' }, + { name: 'ok', members: [] }, + { name: 'ok', members: ['/projects/current', 'relative/path'] }, + { name: 'ok', members: ['/projects/current'], tool: 7 }, + { name: 'ok', members: ['/projects/current'], tool: '--json' }, + ]; + for (const message of malformed) { + await handler?.({ type: 'createWorkset', ...message }); + } + await vi.runAllTimersAsync(); + + expect(createWorkset).not.toHaveBeenCalled(); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(malformed.length); + expect(results.every((result) => result.success === false)).toBe(true); + expect(results.every((result) => typeof result.message === 'string' && result.message)).toBe(true); + }); + + it('rejects creation when a submitted member cannot be canonicalized', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue( + makeNavigationPayload(fixture, ['planning']), + ), + }; + const createWorkset = vi.fn(); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + vi.mocked(fsPromises.realpath) + .mockImplementationOnce(async (value: Parameters<typeof fsPromises.realpath>[0]) => String(value)) + .mockRejectedValueOnce(new Error('ENOENT')); + postMessage.mockClear(); + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current', '/repos/gone'], + }); + await vi.runAllTimersAsync(); + + expect(createWorkset).not.toHaveBeenCalled(); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + }); + + it('rejects Workset creation when the Workset capability is unavailable', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue({ + project: fixture.project, + binding: fixture.binding, + changes: [], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }), + }; + const createWorkset = vi.fn(); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + + expect(createWorkset).not.toHaveBeenCalled(); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('not supported'); + }); + + it('creates selector-free, reloads officially, and posts success only after the fresh snapshot contains the name', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce(makeNavigationPayload(fixture, ['planning'])) + .mockResolvedValue(makeNavigationPayload(fixture, ['planning', 'feature'])), + }; + const createWorkset = vi.fn().mockResolvedValue({ name: 'feature' }); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + await handler?.({ + type: 'createWorkset', + name: ' feature ', + members: ['/projects/current', '/repos/docs'], + tool: ' cursor ', + }); + await vi.runAllTimersAsync(); + + // Selector-free official creation: exactly the validated payload, no store + // selector argument ever reaches the DataManager method. + expect(createWorkset).toHaveBeenCalledTimes(1); + expect(createWorkset).toHaveBeenCalledWith('feature', ['/projects/current', '/repos/docs'], 'cursor'); + // The official reload happened and published the fresh navigation. + const freshContext = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(freshContext?.data?.worksetNavigation?.worksets.some((workset: { name: string }) => ( + workset.name === 'feature' + ))).toBe(true); + // Success is posted only after that fresh snapshot contained the name. + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toEqual([{ type: 'worksetCreateResult', success: true, name: 'feature' }]); + }); + + it('posts a recoverable failure and keeps the previous snapshot when the CLI rejects creation', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const initialPayload = makeNavigationPayload(fixture, ['planning']); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(initialPayload), + }; + const createWorkset = vi.fn().mockRejectedValue(new Error('workset already exists')); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const initialLoadCalls = gateway.loadProjectSidebarData.mock.calls.length; + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + + expect(createWorkset).toHaveBeenCalledTimes(1); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('workset already exists'); + // No reload and no optimistic publish: the cached snapshot stays untouched. + expect(gateway.loadProjectSidebarData).toHaveBeenCalledTimes(initialLoadCalls); + expect((provider as any).cachedProjectSidebarData.worksetNavigation.worksets) + .toEqual(initialPayload.worksetNavigation.worksets); + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'setContext' + && message.data?.worksetNavigation?.worksets.some((workset: { name: string }) => workset.name === 'feature') + ))).toBe(false); + }); + + it('reports failure without fabricating a detail when the refreshed snapshot lacks the created Workset', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(makeNavigationPayload(fixture, ['planning'])), + }; + const createWorkset = vi.fn().mockResolvedValue({ name: 'feature' }); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const initialLoadCalls = gateway.loadProjectSidebarData.mock.calls.length; + postMessage.mockClear(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + + expect(createWorkset).toHaveBeenCalledTimes(1); + // The official reload ran, but the fresh navigation still lacks the name. + expect(gateway.loadProjectSidebarData.mock.calls.length).toBe(initialLoadCalls + 1); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(typeof results[0].message).toBe('string'); + expect(results[0].message).toBeTruthy(); + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'worksetCreateResult' && message.success === true + ))).toBe(false); + }); + + it('replies to a duplicate creation submission with one recoverable in-progress result', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce(makeNavigationPayload(fixture, ['planning'])) + .mockResolvedValue(makeNavigationPayload(fixture, ['planning', 'feature'])), + }; + let resolveCreate: ((value: unknown) => void) | undefined; + const createWorkset = vi.fn(() => new Promise((resolve) => { + resolveCreate = resolve; + })); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + const first = handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + const duplicate = handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + resolveCreate?.({ name: 'feature' }); + await first; + await duplicate; + await vi.runAllTimersAsync(); + + // Single-flight holds: exactly one CLI create. Both requests get exactly + // one result each — the duplicate is a recoverable in-progress failure. + expect(createWorkset).toHaveBeenCalledTimes(1); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(2); + expect(results[0]).toEqual(expect.objectContaining({ + type: 'worksetCreateResult', + success: false, + name: 'feature', + })); + expect(typeof results[0].message).toBe('string'); + expect(results[0].message).toBeTruthy(); + expect(results[1]).toEqual({ type: 'worksetCreateResult', success: true, name: 'feature' }); + }); + + it('reports success when the create reload is superseded by a newer reload that publishes the new name', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + // Deferred loads let the test interleave reloads in a deterministic order. + const resolvers: Array<(value: unknown) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn(() => new Promise((resolve) => { + resolvers.push(resolve); + })), + }; + const createWorkset = vi.fn().mockResolvedValue({ name: 'feature' }); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + resolvers[0]?.(makeNavigationPayload(fixture, ['planning'])); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + // Create succeeds; its official reload (load #2) stays pending. + const submit = handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + // A newer surface request supersedes it with reload #3. + const newer = handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(resolvers).toHaveLength(3); + + // The superseded create reload resolves without the name; the newer reload + // publishes a snapshot that contains it. + resolvers[1]?.(makeNavigationPayload(fixture, ['planning'])); + resolvers[2]?.(makeNavigationPayload(fixture, ['planning', 'feature'])); + await submit; + await newer; + await vi.runAllTimersAsync(); + + expect(createWorkset).toHaveBeenCalledTimes(1); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toEqual([{ type: 'worksetCreateResult', success: true, name: 'feature' }]); + }); + + it('reports failure when the create reload is superseded by a newer reload that lacks the new name', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const resolvers: Array<(value: unknown) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn(() => new Promise((resolve) => { + resolvers.push(resolve); + })), + }; + const createWorkset = vi.fn().mockResolvedValue({ name: 'feature' }); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + resolvers[0]?.(makeNavigationPayload(fixture, ['planning'])); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + const submit = handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + const newer = handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + + // The finally-published snapshot still does not contain the new name. + resolvers[1]?.(makeNavigationPayload(fixture, ['planning'])); + resolvers[2]?.(makeNavigationPayload(fixture, ['planning'])); + await submit; + await newer; + await vi.runAllTimersAsync(); + + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(typeof results[0].message).toBe('string'); + expect(results[0].message).toBeTruthy(); + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'worksetCreateResult' && message.success === true + ))).toBe(false); + }); + + it('reports a recoverable failure when the create reload is superseded by a newer reload that fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const resolvers: Array<(value: unknown) => void> = []; + const gateway = { + loadProjectSidebarData: vi.fn(() => new Promise((resolve) => { + resolvers.push(resolve); + })), + }; + const createWorkset = vi.fn().mockResolvedValue({ name: 'feature' }); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + resolvers[0]?.(makeNavigationPayload(fixture, ['planning'])); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + const submit = handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + const newer = handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + + // The superseded create load resolves; the terminal newer load fails. + resolvers[1]?.(makeNavigationPayload(fixture, ['planning'])); + resolvers[2]?.(new Error('sidebar load failed')); + await submit; + await newer; + await vi.runAllTimersAsync(); + + expect(createWorkset).toHaveBeenCalledTimes(1); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(typeof results[0].message).toBe('string'); + expect(results[0].message).toBeTruthy(); + // Accepted dual-surface semantics: the create flow reports its reload + // failure itself as exactly one result, while the newer (non-suppressed) + // reload's generic error is the one and only error message posted. + const errorMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'error'); + expect(errorMessages).toHaveLength(1); + expect(postMessage.mock.calls.some(([message]) => ( + message.type === 'worksetCreateResult' && message.success === true + ))).toBe(false); + }); + + it('suppresses the generic reload error when creation succeeds but the refresh fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce(makeNavigationPayload(fixture, ['planning'])) + .mockRejectedValue(new Error('sidebar load failed')), + }; + const createWorkset = vi.fn().mockResolvedValue({ name: 'feature' }); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + + // Exactly one createResult failure and zero generic error messages: the + // reload failure belongs to the create flow, which reports it itself. + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(typeof results[0].message).toBe('string'); + expect(results[0].message).toBeTruthy(); + expect(postMessage.mock.calls.some(([message]) => message.type === 'error')).toBe(false); + }); + + it('includes a sanitized single-line CLI stderr excerpt in the failure result', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(makeNavigationPayload(fixture, ['planning'])), + }; + const cliFailure = Object.assign( + new Error('Command failed with code 1'), + { stderr: 'Error: workset already exists\n at createWorkset (cli.ts:12:3)\n\nhint: choose another name' }, + ); + const createWorkset = vi.fn().mockRejectedValue(cliFailure); + const dataManager = makeDataManager({ createWorkset }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('Command failed with code 1'); + expect(results[0].message).toContain('workset already exists'); + // Sanitized: single line, bounded length, no raw CLI stack lines. + expect(results[0].message).not.toMatch(/[\r\n]/); + expect(results[0].message.length).toBeLessThanOrEqual(300); + }); + + it('rejects Workset creation before any CLI call when the capability is unavailable even with present navigation', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + // Present-but-empty navigation: the Gateway swallows CLI capability errors + // into an empty list, so navigation shape alone cannot gate creation. + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(makeNavigationPayload(fixture, [])), + }; + const createWorkset = vi.fn(); + const dataManager = makeDataManager({ + createWorkset, + getCapabilities: vi.fn().mockReturnValue({ + stores: true, + context: true, + doctor: true, + worksets: false, + diagnostics: [], + }), + }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + postMessage.mockClear(); + + await handler?.({ + type: 'createWorkset', + name: 'feature', + members: ['/projects/current'], + }); + await vi.runAllTimersAsync(); + + expect(createWorkset).not.toHaveBeenCalled(); + const results = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'worksetCreateResult'); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('not supported'); + }); + + it('publishes the Workset capability flag with an empty navigation for the first-creation surface', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(makeNavigationPayload(fixture, [])), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + + const freshContext = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + // Empty navigation publishes (the Create entry lives on the empty list) + // together with the authoritative capability fact. + expect(freshContext?.data?.worksetNavigation).toEqual( + expect.objectContaining({ worksets: [] }), + ); + expect(freshContext?.data?.worksetCapabilityAvailable).toBe(true); + }); + + it('marks the published snapshot capability-unavailable when CLI detection lacks worksets', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const gateway = { + loadProjectSidebarData: vi.fn().mockResolvedValue(makeNavigationPayload(fixture, [])), + }; + const dataManager = makeDataManager({ + getCapabilities: vi.fn().mockReturnValue({ + stores: true, + context: true, + doctor: true, + worksets: false, + diagnostics: [], + }), + }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + + const freshContext = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(freshContext?.data?.worksetCapabilityAvailable).toBe(false); + }); + + it('leaves the watcher target untouched when an accepted Store selection load fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const initialPayload = { + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }; + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce(initialPayload) + .mockImplementation(async (_project: ProjectContext, storeId?: string) => { + if (storeId) throw new Error('store context unavailable'); + return initialPayload; + }), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const setWatchedProjectRoot = vi.fn(); + const dataManager = makeDataManager({ setWatchedProjectRoot }); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + expect(setWatchedProjectRoot).not.toHaveBeenCalled(); + expect((provider as any).explicitProjectStoreId).toBeUndefined(); + expect((provider as any).currentProjectBinding).toEqual(fixture.binding); + }); + + it('loads store-scoped data on a watcher-triggered refresh while a selector is active', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (binding: OpenSpecRootBinding) => ({ + project: fixture.project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (_project: ProjectContext, storeId?: string) => ( + storeId ? payloadFor(storeBinding) : payloadFor(fixture.binding) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const dataManager = makeDataManager(); + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(dataManager, gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + postMessage.mockClear(); + + // A watcher event fires the DataManager refresh callback. + const refreshCallback = (dataManager.onRefresh as any).mock.calls[0]?.[0] as ((data: any) => void) | undefined; + refreshCallback?.(makeDashboardData({ changeName: 'legacy-refresh', lastRefresh: 3 })); + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + const contextMessages = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext'); + expect(contextMessages.at(-1)).toEqual(expect.objectContaining({ + data: expect.objectContaining({ binding: storeBinding }), + })); + }); + + it('keeps the active selector and snapshot when the default-root restore fails', async () => { + vi.useFakeTimers(); + const fixture = makeProjectFixture('/planning/current'); + const storeBinding: OpenSpecRootBinding = { + projectId: fixture.project.id, + commandCwd: fixture.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const storePayload = { + project: fixture.project, + binding: storeBinding, + changes: [makeProjectChange('store-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }; + const gateway = { + loadProjectSidebarData: vi.fn() + .mockResolvedValueOnce({ + project: fixture.project, + binding: fixture.binding, + changes: [makeProjectChange('project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }) + .mockImplementation(async (_project: ProjectContext, storeId?: string) => { + if (storeId) return storePayload; + throw new Error('project default root unavailable'); + }), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, fixture); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + const snapshotBefore = (provider as any).cachedProjectSidebarData; + const bindingBefore = (provider as any).currentProjectBinding; + postMessage.mockClear(); + + await handler?.({ type: 'selectProjectDefaultRoot' }); + await vi.runAllTimersAsync(); + + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, undefined); + expect(postMessage.mock.calls.some(([message]) => message.type === 'setContext')).toBe(false); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + expect((provider as any).cachedProjectSidebarData).toBe(snapshotBefore); + expect((provider as any).currentProjectBinding).toBe(bindingBefore); + + // The selector survives the failed restore and keeps driving later reloads. + await handler?.({ type: 'getProjectSidebarData' }); + await vi.runAllTimersAsync(); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(fixture.project, 'team-store'); + }); + + it('carries an active Planning Store selector into Current Project restoration', async () => { + vi.useFakeTimers(); + const current = makeProjectFixture('/planning/current'); + const selectedProject: ProjectContext = { + id: '/projects/server-dotnetcore', + label: 'server-dotnetcore', + projectPath: '/projects/server-dotnetcore', + }; + const currentStoreBinding: OpenSpecRootBinding = { + projectId: current.project.id, + commandCwd: current.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const selectedStoreBinding: OpenSpecRootBinding = { + projectId: selectedProject.id, + commandCwd: selectedProject.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (project: ProjectContext, binding: OpenSpecRootBinding) => ({ + project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (project: ProjectContext, storeId?: string) => ( + storeId + ? payloadFor(project, project.id === current.project.id ? currentStoreBinding : selectedStoreBinding) + : payloadFor(project, { + projectId: project.id, + commandCwd: project.projectPath, + rootPath: '/planning/current', + rootSource: 'nearest', + }) + )), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + resolveWorksetProject: vi.fn().mockResolvedValue(selectedProject), + resolveBinding: vi.fn(async (project: ProjectContext, storeId?: string) => ( + storeId + ? (project.id === current.project.id ? currentStoreBinding : selectedStoreBinding) + : { + projectId: project.id, + commandCwd: project.projectPath, + rootPath: '/planning/current', + rootSource: 'nearest', + } + )), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, current); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + await handler?.({ + type: 'selectWorksetProject', + worksetName: 'shared-workset', + memberPath: selectedProject.projectPath, + }); + await vi.runAllTimersAsync(); + expect(gateway.resolveBinding).toHaveBeenLastCalledWith(selectedProject, 'team-store'); + postMessage.mockClear(); + + await handler?.({ type: 'selectCurrentProject' }); + await vi.runAllTimersAsync(); + + expect(gateway.resolveBinding).toHaveBeenLastCalledWith(current.project, 'team-store'); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(current.project, 'team-store'); + const restoredMessage = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(restoredMessage).toEqual(expect.objectContaining({ + data: expect.objectContaining({ project: current.project, binding: currentStoreBinding }), + })); + expect((provider as any).explicitProjectStoreId).toBe('team-store'); + }); + + it('carries an active Planning Store selector into Workset Project navigation', async () => { + vi.useFakeTimers(); + const current = makeProjectFixture('/planning/current'); + const selectedProject: ProjectContext = { + id: '/projects/server-dotnetcore', + label: 'server-dotnetcore', + projectPath: '/projects/server-dotnetcore', + }; + const currentStoreBinding: OpenSpecRootBinding = { + projectId: current.project.id, + commandCwd: current.project.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const selectedStoreBinding: OpenSpecRootBinding = { + projectId: selectedProject.id, + commandCwd: selectedProject.projectPath, + rootPath: '/stores/team-store', + rootSource: 'store', + storeId: 'team-store', + }; + const payloadFor = (project: ProjectContext, binding: OpenSpecRootBinding) => ({ + project, + binding, + changes: [makeProjectChange(binding.storeId ? 'store-change' : 'project-change')], + archivedChanges: [], + projectSpecs: [], + referencedStoreSpecs: [], + }); + const gateway = { + loadProjectSidebarData: vi.fn(async (project: ProjectContext, storeId?: string) => { + if (!storeId) { + return payloadFor(project, { + projectId: project.id, + commandCwd: project.projectPath, + rootPath: '/planning/current', + rootSource: 'nearest', + }); + } + return payloadFor( + project, + project.id === current.project.id ? currentStoreBinding : selectedStoreBinding + ); + }), + resolveWorksetStore: vi.fn().mockResolvedValue({ + storeId: 'team-store', + canonicalRoot: '/stores/team-store', + }), + resolveWorksetProject: vi.fn().mockResolvedValue(selectedProject), + resolveBinding: vi.fn().mockResolvedValue(selectedStoreBinding), + }; + const postMessage = vi.fn(); + const webview = makeWebview(postMessage); + const provider = makeProjectProvider(makeDataManager(), gateway, current); + provider.resolveWebviewView(makeWebviewView(webview) as any, {} as any, {} as any); + await vi.runAllTimersAsync(); + const handler = vi.mocked(webview.onDidReceiveMessage).mock.calls[0]?.[0]; + + await handler?.({ type: 'selectWorksetStore', worksetName: 'team', memberPath: '/stores/team-store' }); + await vi.runAllTimersAsync(); + postMessage.mockClear(); + + await handler?.({ + type: 'selectWorksetProject', + worksetName: 'shared-workset', + memberPath: selectedProject.projectPath, + }); + await vi.runAllTimersAsync(); + + expect(gateway.resolveBinding).toHaveBeenLastCalledWith(selectedProject, 'team-store'); + expect(gateway.loadProjectSidebarData).toHaveBeenLastCalledWith(selectedProject, 'team-store'); + const selectedMessage = postMessage.mock.calls + .map(([message]) => message) + .filter((message) => message.type === 'setContext' && message.view === 'sidebar') + .at(-1); + expect(selectedMessage).toEqual(expect.objectContaining({ + data: expect.objectContaining({ project: selectedProject, binding: selectedStoreBinding }), + })); + }); + it('project refreshes use the Project loader for watcher and manual refresh', async () => { vi.useFakeTimers(); const fixture = makeProjectFixture(); diff --git a/test/extension/providers/webviewMessageHandler.test.ts b/test/extension/providers/webviewMessageHandler.test.ts index 41e05a7..1afe442 100644 --- a/test/extension/providers/webviewMessageHandler.test.ts +++ b/test/extension/providers/webviewMessageHandler.test.ts @@ -1804,6 +1804,28 @@ describe('handleWebviewMessage toggleTask', () => { expect(dataManager.refresh).not.toHaveBeenCalled(); }); + it('openWorkset forwards a one-time opener tool as an explicit, trimmed override', async () => { + const dataManager = { + getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), + openWorkset: vi.fn().mockResolvedValue(undefined), + selectScope: vi.fn(), + refresh: vi.fn(), + }; + const webview = { postMessage: vi.fn() }; + + await handleWebviewMessage( + { type: 'openWorkset', name: 'platform', tool: ' cursor ' }, + webview as any, + dataManager as any, + ); + + expect(dataManager.openWorkset).toHaveBeenCalledTimes(1); + expect(dataManager.openWorkset).toHaveBeenCalledWith('platform', 'cursor'); + expect(dataManager.selectScope).not.toHaveBeenCalled(); + expect(dataManager.refresh).not.toHaveBeenCalled(); + expect(webview.postMessage).not.toHaveBeenCalled(); + }); + it('reports whole-Workset open failures as recoverable webview errors', async () => { const dataManager = { getWorkspaceRoot: vi.fn().mockReturnValue('/workspace'), diff --git a/test/extension/services/dataManager.test.ts b/test/extension/services/dataManager.test.ts index 9643d6d..db6bfe1 100644 --- a/test/extension/services/dataManager.test.ts +++ b/test/extension/services/dataManager.test.ts @@ -1788,6 +1788,165 @@ describe('DataManager workset data contract', () => { }); }); +describe('DataManager workset create and open commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Minimal fixture: openWorkset/createWorkset only touch this.cliService, so a + // manager with mocked runCommand/runJson runners is sufficient for argv checks. + function createManagerWithWorksetRunners() { + const manager = new DataManager('/tmp/openspec-ext-test-workspace'); + const runCommand = vi.fn().mockResolvedValue('Opened platform\n'); + const runJson = vi.fn().mockResolvedValue({ workset: { name: 'platform' } }); + + Object.assign(manager as any, { + cliService: { + getCliActivationDiagnostic: vi.fn().mockReturnValue(null), + runCommand, + runJson, + }, + }); + + return { manager, runCommand, runJson }; + } + + it('openWorkset appends the one-time --tool override and keeps using the plain runner', async () => { + const { manager, runCommand, runJson } = createManagerWithWorksetRunners(); + + await manager.openWorkset('platform', 'cursor'); + + expect(runCommand).toHaveBeenCalledWith(['workset', 'open', 'platform', '--tool', 'cursor']); + // Ordinary open output must never pass JSON parsing. + expect(runJson).not.toHaveBeenCalled(); + // And the plain argv must not be JSON-shaped. + expect(runCommand.mock.calls[0][0]).not.toContain('--json'); + }); + + it('openWorkset treats a whitespace-only tool override as absent', async () => { + const { manager, runCommand } = createManagerWithWorksetRunners(); + + await manager.openWorkset('platform', ' '); + + expect(runCommand).toHaveBeenCalledWith(['workset', 'open', 'platform']); + }); + + it('createWorkset without a tool repeats --member in input order and ends with --json', async () => { + const { manager, runCommand, runJson } = createManagerWithWorksetRunners(); + + await manager.createWorkset('platform', ['/work/primary', '/stores/team-plans']); + + expect(runJson).toHaveBeenCalledWith([ + 'workset', + 'create', + 'platform', + '--member', + '/work/primary', + '--member', + '/stores/team-plans', + '--json', + ], { retries: 1 }); + // Creation is the JSON runner path; the plain runner must stay untouched. + expect(runCommand).not.toHaveBeenCalled(); + }); + + it('createWorkset with a preferred tool appends --tool before --json', async () => { + const { manager, runJson } = createManagerWithWorksetRunners(); + + await manager.createWorkset('platform', ['/work/primary'], 'cursor'); + + expect(runJson).toHaveBeenCalledWith([ + 'workset', + 'create', + 'platform', + '--member', + '/work/primary', + '--tool', + 'cursor', + '--json', + ], { retries: 1 }); + }); + + it('createWorkset treats a whitespace-only tool as absent', async () => { + const { manager, runJson } = createManagerWithWorksetRunners(); + + await manager.createWorkset('platform', ['/work/primary'], ' '); + + expect(runJson).toHaveBeenCalledWith([ + 'workset', + 'create', + 'platform', + '--member', + '/work/primary', + '--json', + ], { retries: 1 }); + expect(runJson.mock.calls[0][0]).not.toContain('--tool'); + }); + + it('createWorkset never reorders or dedupes members (primary is array-order only)', async () => { + const { manager, runJson } = createManagerWithWorksetRunners(); + + await manager.createWorkset('platform', ['/work/second', '/work/primary', '/work/second']); + + expect(runJson).toHaveBeenCalledWith([ + 'workset', + 'create', + 'platform', + '--member', + '/work/second', + '--member', + '/work/primary', + '--member', + '/work/second', + '--json', + ], { retries: 1 }); + }); + + it('createWorkset forwards an empty members array verbatim (zero --member flags)', async () => { + const { manager, runJson } = createManagerWithWorksetRunners(); + + await manager.createWorkset('platform', []); + + // Contract documentation, not endorsement: this layer performs no payload + // validation — an empty array is passed through with no --member flags and + // non-string entries are not checked. Host-side payload guards own that + // validation. + expect(runJson).toHaveBeenCalledWith(['workset', 'create', 'platform', '--json'], { retries: 1 }); + }); + + it('createWorkset runs the mutation without retry backoff', async () => { + const { manager, runCommand, runJson } = createManagerWithWorksetRunners(); + + await manager.createWorkset('platform', ['/work/primary']); + + // Deterministic failures (e.g. duplicate Workset name) must surface + // immediately instead of burning the default 3-attempt exponential backoff. + expect(runJson.mock.calls[0][1]).toEqual({ retries: 1 }); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it('propagates CLI rejections from createWorkset without swallowing them', async () => { + const { manager, runJson } = createManagerWithWorksetRunners(); + runJson.mockRejectedValue(new Error('workset create failed')); + + await expect(manager.createWorkset('platform', ['/work/primary'])).rejects.toThrow('workset create failed'); + }); + + it('never includes --store in any workset create or open argv', async () => { + const { manager, runCommand, runJson } = createManagerWithWorksetRunners(); + + await manager.openWorkset('platform'); + await manager.openWorkset('platform', 'cursor'); + await manager.createWorkset('platform', ['/work/primary'], 'cursor'); + + const allArgv = [...runCommand.mock.calls, ...runJson.mock.calls].map(([args]) => args as string[]); + expect(allArgv).toHaveLength(3); + for (const argv of allArgv) { + expect(argv).not.toContain('--store'); + } + }); +}); + describe('DataManager declared project-root scopes', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/test/extension/services/openspecCli.test.ts b/test/extension/services/openspecCli.test.ts index da0196d..2379a4e 100644 --- a/test/extension/services/openspecCli.test.ts +++ b/test/extension/services/openspecCli.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { spawn } from 'child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { OpenSpecCliService } from '@extension/services/openspecCli'; vi.mock('vscode', () => ({ @@ -266,7 +269,7 @@ describe('OpenSpecCliService', () => { try { await expect(service.runCommand(['workset', 'open', 'planning'])) .resolves.toBe('Opened planning\n'); - expect(exec).toHaveBeenCalledWith(['workset', 'open', 'planning']); + expect(exec).toHaveBeenCalledWith(['workset', 'open', 'planning'], 3); expect(parse).not.toHaveBeenCalled(); } finally { parse.mockRestore(); @@ -1383,3 +1386,422 @@ describe('argsPrefix and scope support', () => { expect(capturedOptions.every((o) => o.cwd === declaredRoot)).toBe(true); }); }); + +describe('Windows shell-free spawning', () => { + const workspaceRoot = '/fake/workspace'; + + beforeEach(() => { + vi.mocked(spawn).mockReset(); + }); + + // Same platform-faking idiom as the shell-resolution-failed diagnostic test above. + async function withFakePlatform<T>(platform: string, fn: () => Promise<T>): Promise<T> { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform }); + try { + return await fn(); + } finally { + if (original) { + Object.defineProperty(process, 'platform', original); + } + } + } + + function installInstalledRuntime(service: OpenSpecCliService, command: string): void { + (service as any).resolver.resolveRuntime = vi.fn().mockResolvedValue({ + command, + argsPrefix: [], + env: process.env, + version: '1.5.0', + source: 'installed', + sourceLabel: 'installed', + diagnostics: [], + }); + } + + function recordSpawns(stdoutFor: (command: string, args: readonly string[]) => string = () => '') { + const spawned: Array<{ command: string; args: readonly string[]; options: any }> = []; + vi.mocked(spawn).mockImplementation((command, args, options) => { + spawned.push({ command, args, options }); + return createSpawnSuccessProcess(stdoutFor(command, args)) as any; + }); + return spawned; + } + + const SHIM_ENTRY_REL = 'node_modules\\@fission-ai\\openspec\\bin\\openspec.js'; + + /** Writes an npm-style .cmd launcher fixture; returns its path. */ + function writeCmdShim(dir: string, style: 'modern' | 'legacy'): string { + const shimPath = path.join(dir, 'openspec.cmd'); + const content = style === 'modern' + ? [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + 'IF EXIST "%~dp0\\node.exe" (', + ' SET "_prog=%~dp0\\node.exe"', + ') ELSE (', + ' SET "_prog=node"', + ' SET PATHEXT=%PATHEXT:;.JS;=;%', + ')', + '', + `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%~dp0\\${SHIM_ENTRY_REL}" %*`, + ].join('\r\n') + : [ + '@IF EXIST "%~dp0\\node.exe" (', + ` "%~dp0\\node.exe" "%~dp0\\${SHIM_ENTRY_REL}" %*`, + ') ELSE (', + ' @SETLOCAL', + ' @SET PATHEXT=%PATHEXT:;.JS;=;%', + ` node "%~dp0\\${SHIM_ENTRY_REL}" %*`, + ')', + ].join('\r\n'); + writeFileSync(shimPath, content); + return shimPath; + } + + function makeTempShimDir(): string { + return mkdtempSync(path.join(os.tmpdir(), 'openspec-cmd-shim-')); + } + + it('passes %VAR%, quoted-percent, and spaced args verbatim through a resolved .cmd shim without a shell', async () => { + const tmp = makeTempShimDir(); + try { + // Bundled node.exe beside the shim: npm shims prefer it when present. + writeFileSync(path.join(tmp, 'node.exe'), ''); + const shimPath = writeCmdShim(tmp, 'modern'); + const spawned = recordSpawns(); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, shimPath); + + await withFakePlatform('win32', () => + service.runCommand([ + 'workset', + 'create', + 'my workset', + '--member', + 'C:\\work\\%USERNAME%\\repo', + '--ref', + "'%PATH%'", + '--pct', + '100%', + ]) + ); + + expect(spawned).toHaveLength(1); + // The .cmd shim is resolved to its underlying node invocation, and every + // argv element reaches the child process byte-identical — cmd.exe never + // parses (and %-expands) these strings. + expect(spawned[0].command).toBe(path.join(tmp, 'node.exe')); + expect(spawned[0].args).toEqual([ + path.join(tmp, SHIM_ENTRY_REL), + 'workset', + 'create', + 'my workset', + '--member', + 'C:\\work\\%USERNAME%\\repo', + '--ref', + "'%PATH%'", + '--pct', + '100%', + ]); + expect(spawned[0].options.shell).toBe(false); + expect(spawned[0].options.cwd).toBe(workspaceRoot); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("spawns bare 'node' when the shim directory has no node.exe (CreateProcess resolves node.exe via PATH)", async () => { + const tmp = makeTempShimDir(); + try { + const shimPath = writeCmdShim(tmp, 'legacy'); + const spawned = recordSpawns(); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, shimPath); + + await withFakePlatform('win32', () => service.runCommand(['workset', 'list', '--json'])); + + // where.exe must NOT be used to locate node: its first hit may be a + // node.cmd wrapper (unspawnable with shell:false), and its pipe output + // is OEM-codepage decoded, so non-ASCII install paths (e.g. zh-cn + // usernames) mojibake into ENOENT. A bare 'node' command lets + // CreateProcess resolve node.exe on PATH with correct Unicode handling. + expect(spawned).toHaveLength(1); + expect(spawned[0].command).toBe('node'); + expect(spawned[0].args).toEqual([ + path.join(tmp, SHIM_ENTRY_REL), + 'workset', + 'list', + '--json', + ]); + expect(spawned[0].options.shell).toBe(false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('spawns .exe commands directly with shell:false and byte-identical argv', async () => { + const spawned = recordSpawns(); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'C:\\tools\\openspec.exe'); + + await withFakePlatform('win32', () => + service.runCommand(['workset', 'open', 'team workspace', 'C:\\work\\%USERNAME%\\repo']) + ); + + expect(spawned).toHaveLength(1); + expect(spawned[0].command).toBe('C:\\tools\\openspec.exe'); + expect(spawned[0].args).toEqual([ + 'workset', + 'open', + 'team workspace', + 'C:\\work\\%USERNAME%\\repo', + ]); + expect(spawned[0].options.shell).toBe(false); + }); + + it('fails closed without spawning when a .cmd shim cannot be resolved to a node invocation', async () => { + const tmp = makeTempShimDir(); + try { + const shimPath = path.join(tmp, 'openspec.cmd'); + writeFileSync(shimPath, '@echo off\r\nrem not an npm-style launcher\r\n'); + const spawned = recordSpawns(); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, shimPath); + + await expect( + withFakePlatform('win32', () => service.runCommand(['workset', 'list'])) + ).rejects.toThrow(/could not be resolved/i); + + // Fail-closed: no child process may be spawned when the launcher cannot + // be resolved safely (never silently fall back to cmd.exe parsing). + expect(spawned).toHaveLength(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('resolves bare Windows commands through where.exe so PATH .cmd shims also spawn shell-free', async () => { + const tmp = makeTempShimDir(); + try { + writeFileSync(path.join(tmp, 'node.exe'), ''); + const shimPath = writeCmdShim(tmp, 'modern'); + const spawned = recordSpawns((command, args) => + command === 'where.exe' && args[0] === 'openspec' ? `${shimPath}\r\n` : '' + ); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'openspec'); + + await withFakePlatform('win32', () => service.runCommand(['workset', 'list', '--json'])); + + expect(spawned).toHaveLength(2); + expect(spawned[0].command).toBe('where.exe'); + expect(spawned[0].args).toEqual(['openspec']); + // where.exe probes PATH from the workspace root, matching the resolver + // and main-spawn cwd semantics (a workspace-local launcher must resolve + // the same way it will later be executed). + expect(spawned[0].options.cwd).toBe(workspaceRoot); + expect(spawned[0].options.shell).toBe(false); + expect(spawned[1].command).toBe(path.join(tmp, 'node.exe')); + expect(spawned[1].args).toEqual([ + path.join(tmp, SHIM_ENTRY_REL), + 'workset', + 'list', + '--json', + ]); + expect(spawned[1].options.shell).toBe(false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('skips extensionless and PowerShell where.exe entries and resolves the first spawnable launcher', async () => { + const tmp = makeTempShimDir(); + try { + // npm global dirs contain an extension-less bash shim and a .ps1 + // launcher alongside openspec.cmd. Taking the first where.exe line + // blindly would hand the extension-less shim to CreateProcess, which + // cannot execute a non-PE shell script. + writeFileSync(path.join(tmp, 'node.exe'), ''); + const shimPath = writeCmdShim(tmp, 'modern'); + const whereLines = [ + path.join(tmp, 'openspec'), // extension-less bash shim — skipped + 'C:\\npm\\openspec.ps1', // PowerShell launcher — skipped + shimPath, // spawnable .cmd — chosen + ].join('\r\n'); + const spawned = recordSpawns((command, args) => + command === 'where.exe' && args[0] === 'openspec' ? `${whereLines}\r\n` : '' + ); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'openspec'); + + await withFakePlatform('win32', () => service.runCommand(['workset', 'list', '--json'])); + + expect(spawned).toHaveLength(2); + expect(spawned[0].command).toBe('where.exe'); + expect(spawned[0].options.cwd).toBe(workspaceRoot); + // The .cmd entry is resolved to its node invocation, not the first line. + expect(spawned[1].command).toBe(path.join(tmp, 'node.exe')); + expect(spawned[1].args).toEqual([ + path.join(tmp, SHIM_ENTRY_REL), + 'workset', + 'list', + '--json', + ]); + expect(spawned[1].options.shell).toBe(false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('fails closed when where.exe only offers a PowerShell launcher', async () => { + const spawned = recordSpawns((command, args) => + command === 'where.exe' && args[0] === 'openspec' ? 'C:\\npm\\openspec.ps1\r\n' : '' + ); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'openspec'); + + await expect( + withFakePlatform('win32', () => service.runCommand(['workset', 'list'])) + ).rejects.toThrow(/no spawnable openspec cli launcher/i); + + // Only the where.exe probe ran; no main spawn of the PowerShell launcher. + expect(spawned).toHaveLength(1); + expect(spawned[0].command).toBe('where.exe'); + }); + + it('fails closed when where.exe reports no spawnable launcher at all', async () => { + const spawned: Array<{ command: string; args: readonly string[]; options: any }> = []; + vi.mocked(spawn).mockImplementation((command, args, options) => { + spawned.push({ command, args, options }); + if (command === 'where.exe') { + return { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: (event: string, cb: (...args2: unknown[]) => void) => { + if (event === 'close') setImmediate(() => cb(1)); + }, + kill: vi.fn(), + } as any; + } + return createSpawnSuccessProcess('') as any; + }); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'openspec'); + + await expect( + withFakePlatform('win32', () => service.runCommand(['workset', 'list'])) + ).rejects.toThrow(/no spawnable openspec cli launcher/i); + + // Only the where.exe probe ran; the bare name is never handed to + // CreateProcess as a blind direct spawn. + expect(spawned).toHaveLength(1); + expect(spawned[0].command).toBe('where.exe'); + }); + + it('fails closed without spawning when the runtime command is a .ps1 launcher', async () => { + const spawned = recordSpawns(); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'C:\\npm\\openspec.ps1'); + + await expect( + withFakePlatform('win32', () => service.runCommand(['workset', 'list'])) + ).rejects.toThrow(/powershell/i); + + expect(spawned).toHaveLength(0); + }); + + it('keeps local-source mode shell-free and unquoted even on Windows', async () => { + const spawned: Array<{ command: string; args: readonly string[]; options: any }> = []; + vi.mocked(spawn).mockImplementation((command, args, options) => { + spawned.push({ command, args, options }); + return createSpawnSuccessProcess('') as any; + }); + + const service = new OpenSpecCliService(workspaceRoot); + (service as any).resolver.resolveRuntime = vi.fn().mockResolvedValue({ + command: process.execPath, + argsPrefix: ['/Users/test/openspec/bin/openspec.js'], + env: process.env, + version: '1.4.0', + source: 'localSource', + sourceLabel: 'local source (/Users/test/openspec)', + diagnostics: [], + }); + + await withFakePlatform('win32', () => service.runCommand(['workset', 'list', '--json'])); + + expect(spawned).toHaveLength(1); + expect(spawned[0].command).toBe(process.execPath); + expect(spawned[0].args).toEqual(['/Users/test/openspec/bin/openspec.js', 'workset', 'list', '--json']); + expect(spawned[0].options.shell).toBe(false); + }); + + it('passes argv through byte-identical on non-Windows platforms', async () => { + const spawned: Array<{ command: string; args: readonly string[]; options: any }> = []; + vi.mocked(spawn).mockImplementation((command, args, options) => { + spawned.push({ command, args, options }); + return createSpawnSuccessProcess('') as any; + }); + + const service = new OpenSpecCliService(workspaceRoot); + installInstalledRuntime(service, 'openspec'); + + await withFakePlatform('darwin', () => + service.runCommand(['workset', 'create', 'my workset', '--member', '/work/my folder']) + ); + + expect(spawned).toHaveLength(1); + expect(spawned[0].command).toBe('openspec'); + expect(spawned[0].args).toEqual(['workset', 'create', 'my workset', '--member', '/work/my folder']); + expect(spawned[0].options.shell).toBe(false); + }); +}); + +describe('runJson/runCommand retry options', () => { + const workspaceRoot = '/fake/workspace'; + + it('forwards a retries override to execOpenSpec', async () => { + const service = new OpenSpecCliService(workspaceRoot); + const exec = vi.spyOn(service as any, 'execOpenSpec').mockResolvedValue('{"ok":true}'); + + await service.runJson(['workset', 'create', 'x', '--json'], { retries: 1 }); + + expect(exec).toHaveBeenCalledWith(['workset', 'create', 'x', '--json'], 1); + }); + + it('defaults to 3 attempts when no retry override is given', async () => { + const service = new OpenSpecCliService(workspaceRoot); + const exec = vi.spyOn(service as any, 'execOpenSpec').mockResolvedValue('{"ok":true}'); + + await service.runJson(['workset', 'list', '--json']); + await service.runCommand(['workset', 'open', 'x']); + + expect(exec).toHaveBeenNthCalledWith(1, ['workset', 'list', '--json'], 3); + expect(exec).toHaveBeenNthCalledWith(2, ['workset', 'open', 'x'], 3); + }); + + it('keeps default retries for read paths like listWorksets', async () => { + const service = new OpenSpecCliService(workspaceRoot); + const runJson = vi.spyOn(service, 'runJson').mockResolvedValue({ worksets: [] }); + + await (service as any).listWorksets(); + + expect(runJson).toHaveBeenCalledWith(['workset', 'list', '--json']); + }); +}); diff --git a/test/extension/services/projectDataGateway.test.ts b/test/extension/services/projectDataGateway.test.ts index d094cab..8a2b8b3 100644 --- a/test/extension/services/projectDataGateway.test.ts +++ b/test/extension/services/projectDataGateway.test.ts @@ -1275,6 +1275,52 @@ describe('ProjectDataGateway Workset navigation', () => { expect(readGitMetadata).toHaveBeenCalledWith(canonicalWorktreePath); }); + it('canonicalizes the navigation project path so a symlinked project root dedups with picked members', async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-workset-canonical-project-')); + temporaryDirectories.push(base); + const realPath = path.join(base, 'current-project'); + const aliasPath = path.join(base, 'current-alias'); + await fs.mkdir(path.join(realPath, 'openspec'), { recursive: true }); + await fs.symlink(realPath, aliasPath, 'dir'); + const canonicalRealPath = await fs.realpath(realPath); + + // The Project context arrives through a symlinked root: without gateway + // canonicalization its navigation.project.projectPath would not match the + // canonical member paths (or Host folder-picker results), showing two + // rows for one folder in the creation form. + const aliasedProject: ProjectContext = { + id: aliasPath, + label: 'Current Project', + projectPath: aliasPath, + }; + const gateway = new ProjectDataGateway({ + createCli: () => ({ + // Inlined CLI context payload (no `context` helper is in scope in this + // describe block, so referencing one would only add tsc debt). + getContext: async () => ({ root: { path: realPath, source: 'nearest' } }) as OpenSpecContextResult, + listWorksets: async () => ({ + worksets: [{ + name: 'planning', + members: [{ name: 'current', path: realPath }], + }], + }), + listStores: async () => ({ stores: [] }), + }) as any, + readGitMetadata: async () => ({}), + } as any); + + const data = await gateway.loadWorksetNavigation(aliasedProject); + + expect(data.project.projectPath).toBe(canonicalRealPath); + expect(data.project.id).toBe(canonicalRealPath); + // The current member dedups against the navigation project path: the + // locked create-form member and the canonical member are one row. + expect(data.worksets).toHaveLength(1); + const member = data.worksets[0]?.members.find((m) => m.path === canonicalRealPath); + expect(member).toMatchObject({ role: 'project', selectable: true }); + expect(member?.path).toBe(data.project.projectPath); + }); + it('uses the canonical Git common directory for linked Worktree identity', async () => { const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-workset-git-identity-')); temporaryDirectories.push(base); @@ -1400,7 +1446,7 @@ describe('ProjectDataGateway Workset navigation', () => { const currentProject = await createProjectContext('Current Project', currentPath); const gateway = new ProjectDataGateway({ createCli: () => ({ - getContext: async () => context(currentPath, 'nearest'), + getContext: async () => ({ root: { path: currentPath, source: 'nearest' } }) as OpenSpecContextResult, listWorksets: async () => ({ worksets: [{ name: 'planning', @@ -1422,6 +1468,252 @@ describe('ProjectDataGateway Workset navigation', () => { }); }); +describe('ProjectDataGateway Workset Planning Store resolution', () => { + const temporaryDirectories: string[] = []; + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })) + ); + }); + + function context(rootPath: string, rootSource: string, extra: Record<string, unknown> = {}): OpenSpecContextResult { + return { root: { path: rootPath, source: rootSource }, ...extra }; + } + + async function createStoreFixture() { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-store-selection-')); + temporaryDirectories.push(base); + const projectPath = path.join(base, 'current-project'); + const storePath = path.join(base, 'planning-store'); + const otherProjectPath = path.join(base, 'other-project'); + const forgedPath = path.join(base, 'forged-directory'); + await Promise.all([ + fs.mkdir(path.join(projectPath, 'openspec'), { recursive: true }), + fs.mkdir(path.join(storePath, 'openspec'), { recursive: true }), + fs.mkdir(path.join(otherProjectPath, 'openspec'), { recursive: true }), + fs.mkdir(forgedPath, { recursive: true }), + ]); + const storeAlias = path.join(base, 'planning-store-alias'); + await fs.symlink(storePath, storeAlias, 'dir'); + return { + base, + project: await createProjectContext('Current Project', projectPath), + projectPath, + storePath, + storeAlias, + otherProjectPath, + forgedPath, + canonicalStorePath: await fs.realpath(storePath), + }; + } + + it('returns the official Store id from fresh inventories after canonicalizing the member path', async () => { + const fixture = await createStoreFixture(); + const inventoryCalls: string[] = []; + const getContext = vi.fn(); + const gateway = new ProjectDataGateway({ + createCli: () => ({ + getContext, + listWorksets: async () => { + inventoryCalls.push('worksets'); + return { + worksets: [{ + name: 'team', + members: [ + { name: 'current', path: fixture.projectPath }, + { name: 'planning', path: fixture.storeAlias }, + ], + }], + }; + }, + listStores: async () => { + inventoryCalls.push('stores'); + return { stores: [{ id: 'team-store', root: fixture.storePath }] }; + }, + }) as any, + readGitMetadata: async () => ({}), + }); + + const resolution = await gateway.resolveWorksetStore(fixture.project, 'team', fixture.storeAlias); + + expect(resolution).toEqual({ + storeId: 'team-store', + canonicalRoot: fixture.canonicalStorePath, + }); + expect(inventoryCalls).toEqual(['stores', 'worksets']); + expect(getContext).not.toHaveBeenCalled(); + }); + + it('rejects a member path that no fresh Store inventory registers', async () => { + const fixture = await createStoreFixture(); + const gateway = new ProjectDataGateway({ + createCli: () => ({ + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => ({ + worksets: [{ + name: 'team', + members: [ + { name: 'current', path: fixture.projectPath }, + { name: 'forged', path: fixture.forgedPath }, + ], + }], + }), + listStores: async () => ({ stores: [{ id: 'team-store', root: fixture.storePath }] }), + }) as any, + }); + + const error = await gateway.resolveWorksetStore(fixture.project, 'team', fixture.forgedPath).catch((value) => value); + + expect(error).toBeInstanceOf(ProjectDataAccessError); + expect(error).toMatchObject({ projectId: fixture.project.id, phase: 'resolve' }); + }); + + it('rejects a registered Store root that the named Workset no longer lists', async () => { + const fixture = await createStoreFixture(); + const gateway = new ProjectDataGateway({ + createCli: () => ({ + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => ({ + worksets: [{ + name: 'team', + members: [{ name: 'current', path: fixture.projectPath }], + }], + }), + listStores: async () => ({ stores: [{ id: 'team-store', root: fixture.storePath }] }), + }) as any, + }); + + const staleError = await gateway.resolveWorksetStore(fixture.project, 'team', fixture.storePath).catch((value) => value); + const unknownWorksetError = await gateway + .resolveWorksetStore(fixture.project, 'renamed-workset', fixture.storePath) + .catch((value) => value); + + expect(staleError).toBeInstanceOf(ProjectDataAccessError); + expect(staleError).toMatchObject({ projectId: fixture.project.id, phase: 'resolve' }); + expect(unknownWorksetError).toBeInstanceOf(ProjectDataAccessError); + expect(unknownWorksetError).toMatchObject({ projectId: fixture.project.id, phase: 'resolve' }); + }); + + it('rejects a Workset member that has the Project role instead of a Store role', async () => { + const fixture = await createStoreFixture(); + const gateway = new ProjectDataGateway({ + createCli: () => ({ + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => ({ + worksets: [{ + name: 'team', + members: [ + { name: 'current', path: fixture.projectPath }, + { name: 'other', path: fixture.otherProjectPath }, + ], + }], + }), + listStores: async () => ({ stores: [{ id: 'team-store', root: fixture.storePath }] }), + }) as any, + }); + + const error = await gateway.resolveWorksetStore(fixture.project, 'team', fixture.otherProjectPath).catch((value) => value); + + expect(error).toBeInstanceOf(ProjectDataAccessError); + expect(error).toMatchObject({ projectId: fixture.project.id, phase: 'resolve' }); + }); + + it('rejects a member path that cannot be canonicalized before any inventory read', async () => { + const fixture = await createStoreFixture(); + const inventoryCalls: string[] = []; + const gateway = new ProjectDataGateway({ + createCli: () => ({ + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => { + inventoryCalls.push('worksets'); + return { worksets: [] }; + }, + listStores: async () => { + inventoryCalls.push('stores'); + return { stores: [{ id: 'team-store', root: fixture.storePath }] }; + }, + }) as any, + }); + + const error = await gateway + .resolveWorksetStore(fixture.project, 'team', path.join(fixture.base, 'missing-member')) + .catch((value) => value); + + expect(error).toBeInstanceOf(ProjectDataAccessError); + expect(error).toMatchObject({ projectId: fixture.project.id, phase: 'resolve' }); + expect(inventoryCalls).toEqual([]); + }); + + it('fails closed when the Store or Workset inventory probe fails or is malformed', async () => { + const fixture = await createStoreFixture(); + + const failingCases: Array<{ name: string; cli: Record<string, unknown> }> = [ + { + name: 'listStores throws', + cli: { + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => ({ worksets: [{ name: 'team', members: [{ path: fixture.storePath }] }] }), + listStores: async () => { + throw new Error('store list unavailable'); + }, + }, + }, + { + name: 'Store inventory malformed', + cli: { + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => ({ worksets: [{ name: 'team', members: [{ path: fixture.storePath }] }] }), + listStores: async () => ({ stores: [{ id: 'team-store' }] }), + }, + }, + { + name: 'listWorksets throws', + cli: { + getContext: async () => context(fixture.projectPath, 'nearest'), + listWorksets: async () => { + throw new Error('workset list unavailable'); + }, + listStores: async () => ({ stores: [{ id: 'team-store', root: fixture.storePath }] }), + }, + }, + { + name: 'worksets capability missing', + cli: { + getContext: async () => context(fixture.projectPath, 'nearest'), + listStores: async () => ({ stores: [{ id: 'team-store', root: fixture.storePath }] }), + }, + }, + ]; + + for (const failingCase of failingCases) { + const cause = new Error('probe failed'); + const gateway = new ProjectDataGateway({ + createCli: () => failingCase.cli as any, + readGitMetadata: async () => { throw cause; }, + }); + + const error = await gateway + .resolveWorksetStore(fixture.project, 'team', fixture.storePath) + .catch((value) => value); + + expect(error, failingCase.name).toBeInstanceOf(ProjectDataAccessError); + expect(error, failingCase.name).toMatchObject({ projectId: fixture.project.id, phase: 'resolve' }); + } + }); + + it('rejects non-string Workset names and member paths without probing', async () => { + const fixture = await createStoreFixture(); + const gateway = new ProjectDataGateway({ createCli: () => ({ getContext: async () => context(fixture.projectPath, 'nearest') }) as any }); + + const blankName = await gateway.resolveWorksetStore(fixture.project, ' ', fixture.storePath).catch((value) => value); + const nonStringPath = await gateway.resolveWorksetStore(fixture.project, 'team', 42 as unknown as string).catch((value) => value); + + expect(blankName).toBeInstanceOf(ProjectDataAccessError); + expect(nonStringPath).toBeInstanceOf(ProjectDataAccessError); + }); +}); + describe('ProjectDataGateway unified Project Sidebar data', () => { const temporaryDirectories: string[] = []; @@ -1498,6 +1790,116 @@ describe('ProjectDataGateway unified Project Sidebar data', () => { expect(contexts).toEqual([undefined, { storeId: 'referenced-store' }]); }); + it('resolves every Sidebar reader through an explicit validated Store selector', async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sidebar-explicit-store-')); + temporaryDirectories.push(base); + const projectRoot = path.join(base, 'project-root'); + const storeRoot = path.join(base, 'store-root'); + await fs.mkdir(path.join(projectRoot, 'openspec'), { recursive: true }); + await fs.mkdir(path.join(storeRoot, 'openspec'), { recursive: true }); + const project = await createProjectContext('Project', projectRoot); + const canonicalStoreRoot = await fs.realpath(storeRoot); + const contexts: unknown[] = []; + const listScopes: unknown[] = []; + const contentRoots: string[] = []; + const gateway = new ProjectDataGateway({ + createCli: () => ({ + getContext: async (scope) => { + contexts.push(scope); + return scope?.storeId + ? { root: { path: storeRoot, source: 'store', store_id: scope.storeId } } + : context(projectRoot, 'nearest'); + }, + listChanges: async (scope) => { + listScopes.push(scope); + return scope?.storeId + ? [{ name: 'store-change', lifecycleStatus: 'planning' }] as any + : [{ name: 'project-change', lifecycleStatus: 'planning' }] as any; + }, + listSpecs: async (scope) => { + listScopes.push(scope); + return scope?.storeId + ? [{ id: 'store-spec', requirementCount: 2 }] + : [{ id: 'project-spec', requirementCount: 1 }]; + }, + listWorksets: async () => ({ + worksets: [{ + name: 'team', + members: [{ name: 'current', path: projectRoot }], + }], + }), + listStores: async () => ({ stores: [{ id: 'team-store', root: storeRoot }] }), + }), + createContentAccess: (openspecPath) => { + contentRoots.push(openspecPath); + return { listArchivedChanges: async () => [] }; + }, + readGitMetadata: async () => ({}), + }); + + const storeBound = await gateway.loadProjectSidebarData(project, 'team-store'); + const selectorFree = await gateway.loadProjectSidebarData(project); + + expect(storeBound.binding).toEqual({ + projectId: project.id, + commandCwd: project.projectPath, + rootPath: canonicalStoreRoot, + rootSource: 'store', + storeId: 'team-store', + }); + expect(storeBound.changes.map((change) => change.name)).toEqual(['store-change']); + expect(storeBound.projectSpecs).toEqual([{ id: 'store-spec', requirementCount: 2 }]); + expect(storeBound.worksetNavigation).toEqual({ project, worksets: expect.any(Array) }); + expect(contexts[0]).toEqual({ storeId: 'team-store' }); + expect(listScopes[0]).toEqual({ storeId: 'team-store' }); + expect(listScopes[1]).toEqual({ storeId: 'team-store' }); + expect(contentRoots[0]).toBe(path.join(canonicalStoreRoot, 'openspec')); + + expect(selectorFree.binding).toMatchObject({ + rootPath: await fs.realpath(projectRoot), + rootSource: 'nearest', + }); + expect(selectorFree.binding.storeId).toBeUndefined(); + expect(selectorFree.changes.map((change) => change.name)).toEqual(['project-change']); + expect(contexts[1]).toBeUndefined(); + expect(listScopes[2]).toBeUndefined(); + expect(listScopes[3]).toBeUndefined(); + }); + + it('reports whether an explicit Store selector drove the Sidebar binding', async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sidebar-explicit-flag-')); + temporaryDirectories.push(base); + const projectRoot = path.join(base, 'project-root'); + const declaredStoreRoot = path.join(base, 'declared-store-root'); + const selectedStoreRoot = path.join(base, 'selected-store-root'); + await fs.mkdir(path.join(projectRoot, 'openspec'), { recursive: true }); + await fs.mkdir(path.join(declaredStoreRoot, 'openspec'), { recursive: true }); + await fs.mkdir(path.join(selectedStoreRoot, 'openspec'), { recursive: true }); + const project = await createProjectContext('Project', projectRoot); + const gateway = new ProjectDataGateway({ + createCli: () => ({ + // The selector-free root IS a Store root (CLI root.store_id is set): + // its binding carries a storeId without any explicit selector. + getContext: async (scope) => (scope?.storeId + ? { root: { path: selectedStoreRoot, source: 'store', store_id: scope.storeId } } + : { root: { path: declaredStoreRoot, source: 'store', store_id: 'declared-store' } }), + listChanges: async () => [], + listSpecs: async () => [], + listWorksets: async () => ({ worksets: [] }), + listStores: async () => ({ stores: [] }), + }), + createContentAccess: () => ({ listArchivedChanges: async () => [] }), + }); + + const selectorFree = await gateway.loadProjectSidebarData(project); + const explicit = await gateway.loadProjectSidebarData(project, 'team-store'); + + expect(selectorFree.binding.storeId).toBe('declared-store'); + expect(selectorFree.explicitStoreSelector).toBe(false); + expect(explicit.binding.storeId).toBe('team-store'); + expect(explicit.explicitStoreSelector).toBe(true); + }); + it('accepts official context members and keeps Store Specs out of Project metrics', async () => { const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-sidebar-official-shape-')); temporaryDirectories.push(base); diff --git a/test/i18n/i18n.test.ts b/test/i18n/i18n.test.ts index e46a428..74d06e0 100644 --- a/test/i18n/i18n.test.ts +++ b/test/i18n/i18n.test.ts @@ -202,7 +202,53 @@ describe('i18n', () => { for (const key of requiredKeys) { expect(en[key as keyof typeof en]).toBeTruthy(); - expect(zhCn[key as keyof typeof zhCn]).toBeTruthy(); + expect(zhCn[key as keyof typeof en]).toBeTruthy(); + } + }); + + it('contains Workset picker action, state, and helper copy in both locales', () => { + const requiredKeys = [ + 'worksetNavigation.title', + 'worksetNavigation.returnCurrent', + 'worksetNavigation.backToList', + 'worksetNavigation.openDetail', + 'worksetNavigation.openAll', + 'worksetNavigation.openWithTool', + 'worksetNavigation.oneTimeOpenerLabel', + 'worksetNavigation.oneTimeOpenerSubmit', + 'worksetNavigation.toolShortcut', + 'worksetNavigation.switchProject', + 'worksetNavigation.switchProjectShort', + 'worksetNavigation.planningStore', + 'worksetNavigation.current', + 'worksetNavigation.currentRoot', + 'worksetNavigation.useAsPlanningRoot', + 'worksetNavigation.useAsPlanningRootAria', + 'worksetNavigation.useProjectDefault', + 'worksetNavigation.membersSection', + 'worksetCreate.formLabel', + 'worksetCreate.createAction', + 'worksetCreate.nameLabel', + 'worksetCreate.membersLabel', + 'worksetCreate.primaryHint', + 'worksetCreate.toolLabel', + 'worksetCreate.toolHint', + 'worksetCreate.addMembers', + 'worksetCreate.currentProjectMember', + 'worksetCreate.primaryBadge', + 'worksetCreate.makePrimary', + 'worksetCreate.removeMember', + 'worksetCreate.cancel', + 'worksetCreate.submit', + 'worksetCreate.emptyList', + 'worksetsPage.openWholeWorkset', + 'worksetsPage.openWholeWorksetShort', + 'worksetsPage.memberCount', + ]; + + for (const key of requiredKeys) { + expect((en as Record<string, string>)[key]).toBeTruthy(); + expect((zhCn as Record<string, string>)[key]).toBeTruthy(); } }); diff --git a/test/webview/components/dashboard.test.tsx b/test/webview/components/dashboard.test.tsx index 4f8aa78..5117c9e 100644 --- a/test/webview/components/dashboard.test.tsx +++ b/test/webview/components/dashboard.test.tsx @@ -6,9 +6,16 @@ import { AppProvider, appReducer, type AppState } from '../../../src/webview/con import { Dashboard, createScopeSelectHandler, + createWorksetPickerHandlers, + getWorksetPickerActiveStoreId, + getWorksetPickerExplicitStoreSelector, + getWorksetCreateAvailable, + getWorksetsTabAvailable, returnToCurrentProject, selectProjectFirstTab, sendProjectSidebarSpecDetail, + toWorksetCreateResultState, + toWorksetPickedMembersState, getDashboardActionScopeId, getDashboardPriorityChanges, requestInitialDashboardData, @@ -667,6 +674,37 @@ describe('project page contract', () => { expect(html).toContain('data-project-action="dashboard"'); }); + it('enables the Worksets tab for zero worksets when the Workset capability is available', () => { + const html = renderProjectSidebar({ + ...projectSidebarData, + worksetNavigation: { project: projectContext, worksets: [] }, + worksetCapabilityAvailable: true, + }); + + expect(html).toContain('data-project-action="worksets"'); + // The disabled attribute must be absent (Tailwind disabled: classes always exist). + expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*data-project-action="worksets"/); + expect(html).toContain('Browse Workset Projects'); + expect(html).not.toContain('No trusted Workset membership available'); + }); + + it('keeps the Worksets tab disabled with the upgrade explanation when the capability is unavailable', () => { + const html = renderProjectSidebar({ + ...projectSidebarData, + worksetNavigation: projectWorksetNavigation, + worksetCapabilityAvailable: false, + }); + + expect(html).toContain('data-project-action="worksets"'); + expect(html).toMatch(/<button[^>]*disabled=""[^>]*data-project-action="worksets"/); + expect(html).toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); + // Changes/Specs stay fully usable next to the disabled Worksets tab. + expect(html).toContain('data-project-action="changes"'); + expect(html).toContain('data-project-action="specs"'); + expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*data-project-action="changes"/); + expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*data-project-action="specs"/); + }); + it('changes only local Project-first tab state without posting an Explorer request', () => { const setTab = vi.fn(); const postMessage = vi.fn(); @@ -721,6 +759,146 @@ describe('project page contract', () => { expect(postMessage).toHaveBeenCalledWith(sendMessage.selectCurrentProject()); }); + it('builds single-purpose Workset picker handlers that post exactly one message each', () => { + const postMessage = vi.fn(); + const handlers = createWorksetPickerHandlers(postMessage); + + handlers.onSelectProject('planning', '/projects/other'); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: 'selectWorksetProject', + worksetName: 'planning', + memberPath: '/projects/other', + }); + + postMessage.mockClear(); + handlers.onSelectWorksetStore('planning', '/stores/team-plans'); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: 'selectWorksetStore', + worksetName: 'planning', + memberPath: '/stores/team-plans', + }); + + postMessage.mockClear(); + handlers.onSelectProjectDefaultRoot(); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ type: 'selectProjectDefaultRoot' }); + + postMessage.mockClear(); + handlers.onOpenWorkset('planning'); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ type: 'openWorkset', name: 'planning' }); + + postMessage.mockClear(); + handlers.onOpenWorkset('planning', 'cursor'); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ type: 'openWorkset', name: 'planning', tool: 'cursor' }); + }); + + it('derives the active Planning Store id only from the trusted binding', () => { + expect(getWorksetPickerActiveStoreId(undefined)).toBeUndefined(); + expect(getWorksetPickerActiveStoreId(projectSidebarData)).toBeUndefined(); + expect(getWorksetPickerActiveStoreId({ + ...projectSidebarData, + binding: { + ...projectBinding, + rootPath: '/stores/team-plans', + rootSource: 'store', + storeId: 'team-plans', + }, + })).toBe('team-plans'); + }); + + it('derives the explicit-selector fact only from the Host-returned sidebar flag', () => { + // The webview never infers the selector from binding.storeId: a + // selector-free default binding may legitimately carry a CLI-declared + // store id, and only the Host flag marks an explicit selector as active. + expect(getWorksetPickerExplicitStoreSelector(undefined)).toBe(false); + expect(getWorksetPickerExplicitStoreSelector(null)).toBe(false); + expect(getWorksetPickerExplicitStoreSelector(projectSidebarData)).toBe(false); + expect(getWorksetPickerExplicitStoreSelector({ + ...projectSidebarData, + binding: { ...projectBinding, storeId: 'cli-declared-store' }, + })).toBe(false); + expect(getWorksetPickerExplicitStoreSelector({ + ...projectSidebarData, + explicitStoreSelector: true, + })).toBe(true); + }); + + it('derives the Worksets tab availability from navigation and capability, never from the count', () => { + expect(getWorksetsTabAvailable(undefined)).toBe(false); + expect(getWorksetsTabAvailable(null)).toBe(false); + // No trusted navigation: unavailable regardless of the flag. + expect(getWorksetsTabAvailable({ ...projectSidebarData, worksetCapabilityAvailable: true })).toBe(false); + // Capability available: zero worksets still enables the first-creation tab. + expect(getWorksetsTabAvailable({ + ...projectSidebarData, + worksetNavigation: { project: projectContext, worksets: [] }, + worksetCapabilityAvailable: true, + })).toBe(true); + expect(getWorksetsTabAvailable({ + ...projectSidebarData, + worksetNavigation: projectWorksetNavigation, + worksetCapabilityAvailable: true, + })).toBe(true); + // Legacy payloads without the flag keep the tab available. + expect(getWorksetsTabAvailable({ + ...projectSidebarData, + worksetNavigation: projectWorksetNavigation, + })).toBe(true); + // Capability explicitly unavailable: disabled even with navigation. + expect(getWorksetsTabAvailable({ + ...projectSidebarData, + worksetNavigation: projectWorksetNavigation, + worksetCapabilityAvailable: false, + })).toBe(false); + }); + + it('derives the picker create availability only from the Host capability flag', () => { + expect(getWorksetCreateAvailable(undefined)).toBe(false); + expect(getWorksetCreateAvailable(projectSidebarData)).toBe(true); + expect(getWorksetCreateAvailable({ + ...projectSidebarData, + worksetCapabilityAvailable: true, + })).toBe(true); + expect(getWorksetCreateAvailable({ + ...projectSidebarData, + worksetCapabilityAvailable: false, + })).toBe(false); + }); + + it('normalizes untrusted worksetMembersPicked payloads into picker-response state', () => { + expect(toWorksetPickedMembersState({ paths: ['/repos/docs'], droppedPaths: ['/repos/gone'] })).toEqual({ + paths: ['/repos/docs'], + droppedPaths: ['/repos/gone'], + }); + // Non-array paths degrade to an empty add; empty dropped lists are omitted. + expect(toWorksetPickedMembersState({ paths: 'nope' })).toEqual({ paths: [] }); + expect(toWorksetPickedMembersState({ paths: [], droppedPaths: [] })).toEqual({ paths: [] }); + // Malformed dropped values never reach the picker state. + expect(toWorksetPickedMembersState({ paths: ['/repos/docs'], droppedPaths: [42, ''] })).toEqual({ + paths: ['/repos/docs'], + }); + }); + + it('normalizes untrusted worksetCreateResult payloads into picker-response state', () => { + expect(toWorksetCreateResultState({ success: true, name: 'feature' })).toEqual({ + success: true, + name: 'feature', + }); + expect(toWorksetCreateResultState({ success: 'yes', name: 7, message: 42 })).toEqual({ + success: false, + name: '', + }); + expect(toWorksetCreateResultState({ success: false, name: 'feature', message: 'duplicate' })).toEqual({ + success: false, + name: 'feature', + message: 'duplicate', + }); + }); + it('does not forward a legacy selected Store scope from Project-first actions', () => { expect(getDashboardActionScopeId(projectSidebarData, storeScope.id)).toBeUndefined(); expect(getDashboardActionScopeId(undefined, storeScope.id)).toBe(storeScope.id); diff --git a/test/webview/components/header.test.tsx b/test/webview/components/header.test.tsx index 222cb8e..287bd6a 100644 --- a/test/webview/components/header.test.tsx +++ b/test/webview/components/header.test.tsx @@ -36,13 +36,16 @@ function collectElements(node: React.ReactNode, predicate: (element: React.React ]; } -function createProjectHeader(overrides: Partial<HeaderProps> = {}) { +// Direct-call rendering returns `ReactNode | Promise<ReactNode>` under React 19 +// types; Header renders synchronously, so a precise element return type keeps +// every collector call site free of tsc debt. +function createProjectHeader(overrides: Partial<HeaderProps> = {}): React.ReactElement { return Header({ onRefresh: vi.fn(), loading: false, project: { id: '/projects/current', label: 'Current Project', projectPath: '/projects/current' }, ...overrides, - }); + }) as React.ReactElement; } describe('Project-first Header', () => { @@ -144,10 +147,11 @@ describe('Project-first Header', () => { it('gives unavailable Worksets a complete reason and prevents navigation', () => { const onOpenWorksets = vi.fn(); + // No onOpenWorksets handler: upstream (navigation + capability) decided the + // surface is unavailable, so the tab must stay disabled and inert. const header = createProjectHeader({ onOpenChanges: vi.fn(), onOpenSpecs: vi.fn(), - onOpenWorksets, onOpenDashboard: vi.fn(), worksetCount: 0, activeProjectTab: 'changes', @@ -170,10 +174,9 @@ describe('Project-first Header', () => { expect(onOpenWorksets).not.toHaveBeenCalled(); }); - it('fails closed when Workset membership count is unavailable', () => { - const onOpenWorksets = vi.fn(); + it('explains a capability-gated Worksets tab with the upgrade notice', () => { const header = createProjectHeader({ - onOpenWorksets, + worksetsCapabilityAvailable: false, }); const worksets = collectProjectActionButtons(header).find( (button) => button.props['data-project-action'] === 'worksets', @@ -181,14 +184,33 @@ describe('Project-first Header', () => { expect(worksets?.props.disabled).toBe(true); expect(worksets?.props['aria-describedby']).toBeDefined(); - expect(worksets?.props.title).toContain('No trusted Workset membership available'); + expect(worksets?.props.title).toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); const reason = collectElements(header, (element) => ( (element.props as { id?: string }).id === worksets?.props['aria-describedby'] )); expect(reason).toHaveLength(1); - expect(reason[0].props.children).toContain('No trusted Workset membership available'); + expect((reason[0]?.props as { children?: string } | undefined)?.children) + .toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); + }); + + it('keeps Worksets available for zero worksets when an open handler exists', () => { + // Zero worksets is the first-creation case, never an unavailable state: + // the count must not gate the tab, only the upstream-provided handler does. + const onOpenWorksets = vi.fn(); + const header = createProjectHeader({ + onOpenWorksets, + worksetCount: 0, + worksetsCapabilityAvailable: true, + }); + const worksets = collectProjectActionButtons(header).find( + (button) => button.props['data-project-action'] === 'worksets', + ); + + expect(worksets?.props.disabled).toBe(false); + expect(worksets?.props['aria-describedby']).toBeUndefined(); + expect(worksets?.props.title).toContain('Browse Workset Projects'); worksets?.props.onClick?.(); - expect(onOpenWorksets).not.toHaveBeenCalled(); + expect(onOpenWorksets).toHaveBeenCalledTimes(1); }); it('opens Dashboard in its Editor route without changing the selected local view', () => { @@ -275,6 +297,10 @@ describe('Project-first Header', () => { expect(html).toContain('浏览本地 Changes'); expect(html).toContain('浏览本地 Specs'); expect(html).toContain('在编辑器中打开项目 Dashboard'); + // The Worksets launcher keeps its browsing-for-current-Project accessible + // name in zh-cn too (short visible label, descriptive accessible name). + expect(html).toContain('浏览 Workset 项目'); expect(html).not.toContain('aria-label="Project navigation"'); + expect(html).not.toContain('Browse Workset Projects'); }); }); diff --git a/test/webview/components/worksetProjectPicker.test.tsx b/test/webview/components/worksetProjectPicker.test.tsx index f54d8b1..8043cf1 100644 --- a/test/webview/components/worksetProjectPicker.test.tsx +++ b/test/webview/components/worksetProjectPicker.test.tsx @@ -1,7 +1,30 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; -import { WorksetProjectPicker } from '../../../src/webview/components/WorksetProjectPicker'; +import { setLocale } from '../../../src/i18n'; +import { + WorksetOneTimeOpenerForm, + WorksetProjectPicker, + backToWorksetListScene, + formatWorksetMemberGitLabel, + isWorksetOpenerEditorOpen, + mergePickedWorksetMembers, + normalizeOneTimeOpenerTool, + normalizeWorksetCreateSubmit, + openWorksetCreateScene, + openWorksetDetailScene, + promoteWorksetPrimaryMember, + reconcileWorksetPickerResponses, + removeWorksetDraftMember, + resetWorksetPickerSelectionForProject, + resolveWorksetPickerScene, + runWorksetCreateSubmit, + runWorksetOpenAction, + worksetDetailHeadingId, + worksetPickerProjectKey, + type WorksetCreateDraft, +} from '../../../src/webview/components/WorksetProjectPicker'; import type { ProjectWorksetNavigationData } from '../../../src/webview/types/messages'; const current = { @@ -10,6 +33,19 @@ const current = { projectPath: '/projects/current', }; +const docsWorktreeMember = { + name: 'Docs Worktree', + path: '/repos/docs-worktree', + role: 'project' as const, + selectable: true, + project: { + id: '/repos/docs-worktree', + label: 'Docs Worktree', + projectPath: '/repos/docs-worktree', + }, + git: { repository: '/repos/docs', branch: 'feature/docs' }, +}; + const navigation: ProjectWorksetNavigationData = { project: current, worksets: [ @@ -20,67 +56,181 @@ const navigation: ProjectWorksetNavigationData = { { name: current.label, path: current.projectPath, - role: 'project', + role: 'project' as const, selectable: true, project: current, }, - { - name: 'Docs Worktree', - path: '/repos/docs-worktree', - role: 'project', - selectable: true, - project: { - id: '/repos/docs-worktree', - label: 'Docs Worktree', - projectPath: '/repos/docs-worktree', - }, - git: { repository: '/repos/docs', branch: 'feature/docs' }, - }, + docsWorktreeMember, { name: 'team-plans', path: '/stores/team-plans', - role: 'store', + role: 'store' as const, selectable: false, storeId: 'team-plans', }, ], }, + { + name: 'solo', + members: [ + { + name: current.label, + path: current.projectPath, + role: 'project' as const, + selectable: true, + project: current, + }, + ], + }, ], }; -describe('WorksetProjectPicker', () => { - it('renders Project-only selection rows and read-only Planning Store rows', () => { - const html = renderToStaticMarkup( - <WorksetProjectPicker - navigation={navigation} - onSelectProject={vi.fn()} - onOpenWorkset={vi.fn()} - onBackToCurrentProject={vi.fn()} - />, - ); +function renderPicker( + overrides: { + navigation?: ProjectWorksetNavigationData; + initialScene?: Parameters<typeof WorksetProjectPicker>[0]['initialScene']; + initialOpenerOpen?: boolean; + activeStoreId?: string; + explicitStoreSelector?: boolean; + createAvailable?: boolean; + pickedMembers?: Parameters<typeof WorksetProjectPicker>[0]['pickedMembers']; + createResult?: Parameters<typeof WorksetProjectPicker>[0]['createResult']; + } = {}, +) { + return renderToStaticMarkup( + <WorksetProjectPicker + navigation={overrides.navigation ?? navigation} + initialScene={overrides.initialScene} + initialOpenerOpen={overrides.initialOpenerOpen} + activeStoreId={overrides.activeStoreId} + explicitStoreSelector={overrides.explicitStoreSelector} + createAvailable={overrides.createAvailable} + pickedMembers={overrides.pickedMembers} + createResult={overrides.createResult} + onSelectProject={vi.fn()} + onSelectWorksetStore={vi.fn()} + onSelectProjectDefaultRoot={vi.fn()} + onOpenWorkset={vi.fn()} + onPickMembers={vi.fn()} + onCreateWorkset={vi.fn()} + onBackToCurrentProject={vi.fn()} + />, + ); +} + +describe('WorksetProjectPicker scene state machine', () => { + it('selects a detail scene by official Workset name and returns locally on Back', () => { + expect(openWorksetDetailScene('planning')).toEqual({ kind: 'detail', name: 'planning' }); + expect(backToWorksetListScene()).toEqual({ kind: 'list' }); + }); + + it('keeps detail across a same-Project refresh while the Workset still exists', () => { + const scene = openWorksetDetailScene('planning'); + const projectKey = worksetPickerProjectKey(navigation.project); + // Same reference means "no reset": an ordinary snapshot refresh (including a + // binding-only refresh that produces a new navigation object) keeps the detail. + expect(resolveWorksetPickerScene(scene, projectKey, navigation)).toBe(scene); + expect( + resolveWorksetPickerScene(scene, projectKey, { + ...navigation, + worksets: [...navigation.worksets], + }), + ).toBe(scene); + }); + + it('returns to the list with a recoverable notice when the selected Workset disappears', () => { + const scene = openWorksetDetailScene('planning'); + const refreshed: ProjectWorksetNavigationData = { ...navigation, worksets: [navigation.worksets[1]] }; + expect( + resolveWorksetPickerScene(scene, worksetPickerProjectKey(navigation.project), refreshed), + ).toEqual({ kind: 'list', staleDetailName: 'planning' }); + }); + + it('resets to the plain list without a stale notice when Project identity changes', () => { + const scene = openWorksetDetailScene('planning'); + const otherProject: ProjectWorksetNavigationData = { + ...navigation, + project: { ...current, id: '/projects/other', projectPath: '/projects/other' }, + }; + expect( + resolveWorksetPickerScene(scene, worksetPickerProjectKey(navigation.project), otherProject), + ).toEqual({ kind: 'list' }); + }); + + it('commits the Project identity reset so returning to a previous Project cannot revive its detail', () => { + // The reset must be persistent state, not a per-render derivation: + // A -> B -> A must land on the plain list with a closed opener editor. + const selection = { + projectKey: worksetPickerProjectKey(current), + scene: openWorksetDetailScene('planning'), + openerEditor: { worksetName: 'planning' }, + }; + const projectBKey = worksetPickerProjectKey({ ...current, id: '/projects/other', projectPath: '/projects/other' }); + + const reset = resetWorksetPickerSelectionForProject(selection, projectBKey); + expect(reset).toEqual({ + projectKey: projectBKey, + scene: { kind: 'list' }, + openerEditor: null, + }); + // The committed reset means navigating back to A keeps the plain list. + const backToA = resetWorksetPickerSelectionForProject(reset, selection.projectKey); + expect(backToA).toEqual({ + projectKey: selection.projectKey, + scene: { kind: 'list' }, + openerEditor: null, + }); + // Same Project identity keeps the persisted selection untouched. + expect(resetWorksetPickerSelectionForProject(selection, selection.projectKey)).toBe(selection); + }); +}); + +describe('WorksetProjectPicker one-time opener editor lifetime', () => { + it('derives the editor open state from the current detail target', () => { + const editor = { worksetName: 'planning' }; + expect(isWorksetOpenerEditorOpen(editor, 'planning')).toBe(true); + // A different detail target (Workset B after Back) keeps the editor closed. + expect(isWorksetOpenerEditorOpen(editor, 'solo')).toBe(false); + // Leaving the detail scene (list reset) closes the editor and discards the + // typed id by unmounting the form. + expect(isWorksetOpenerEditorOpen(editor, undefined)).toBe(false); + expect(isWorksetOpenerEditorOpen(null, 'planning')).toBe(false); + }); +}); - expect(html).toContain('planning'); +describe('WorksetProjectPicker list scene', () => { + it('renders collapsed Workset rows without member rows until a Workset is selected', () => { + const html = renderPicker(); + + expect(html).toContain('data-workset-scene="list"'); + expect(html).toContain('data-workset-row="planning"'); + expect(html).toContain('data-workset-row="solo"'); + expect(html).toContain('3 members'); expect(html).toContain('vscode'); - expect(html).toContain('Docs Worktree'); - expect(html).toContain('feature/docs'); - expect(html).toContain('Planning Store'); - expect(html).toContain('data-workset-project="/repos/docs-worktree"'); - expect(html).toContain('title="/repos/docs-worktree"'); - expect(html).toContain('aria-label="Switch Sidebar Project to Docs Worktree"'); - expect(html).not.toContain('aria-label="Open Docs Worktree project"'); - expect(html).not.toContain('data-workset-project="/stores/team-plans"'); - expect(html).toContain('data-workset-store="team-plans"'); + // Member rows MUST stay collapsed in the list scene. + expect(html).not.toContain('data-workset-project='); + expect(html).not.toContain('data-workset-store='); + expect(html).not.toContain('Docs Worktree'); + expect(html).not.toContain('team-plans'); + }); + + it('keeps a separate accessible whole-Workset open control outside the row body', () => { + const html = renderPicker(); + + expect(html).toContain('data-action="open-workset"'); + expect(html).toContain('aria-label="Open Whole Workset planning"'); + expect(html).toContain('title="Open Whole Workset planning"'); + // Row body activation and the Open control are separate focusable elements: + // the open control must not live inside the row-body button markup. + const rowStart = html.indexOf('data-workset-row="planning"'); + const rowEnd = html.indexOf('</button>', rowStart); + expect(rowStart).toBeGreaterThan(-1); + expect(rowEnd).toBeGreaterThan(rowStart); + expect(html.slice(rowStart, rowEnd)).not.toContain('data-action="open-workset"'); }); it('keeps the picker separate from the Project content scene and exposes a keyboard-safe return', () => { - const html = renderToStaticMarkup( - <WorksetProjectPicker - navigation={navigation} - onSelectProject={vi.fn()} - onOpenWorkset={vi.fn()} - onBackToCurrentProject={vi.fn()} - />, - ); + const html = renderPicker(); expect(html).toContain('data-workset-project-picker'); expect(html).toContain('data-workset-picker-scene'); @@ -91,22 +241,7 @@ describe('WorksetProjectPicker', () => { expect(html).toContain('truncate'); }); - it('exposes an accessible whole-Workset open action for each Workset', () => { - const html = renderToStaticMarkup( - <WorksetProjectPicker - navigation={navigation} - onSelectProject={vi.fn()} - onOpenWorkset={vi.fn()} - onBackToCurrentProject={vi.fn()} - />, - ); - - expect(html).toContain('data-action="open-workset"'); - expect(html).toContain('aria-label="Open Whole Workset planning"'); - expect(html).toContain('title="Open Whole Workset planning"'); - }); - - it('bounds long Workset and Project labels while keeping every switch keyboard-focusable', () => { + it('bounds long Workset labels while keeping every row keyboard-focusable', () => { const longNavigation: ProjectWorksetNavigationData = { ...navigation, project: { ...current, label: 'A very long current Project label that must remain bounded' }, @@ -115,23 +250,168 @@ describe('WorksetProjectPicker', () => { name: 'A very long Workset label that must remain bounded', }], }; - const html = renderToStaticMarkup( - <WorksetProjectPicker - navigation={longNavigation} - onSelectProject={vi.fn()} - onOpenWorkset={vi.fn()} - onBackToCurrentProject={vi.fn()} - />, - ); + const html = renderPicker({ navigation: longNavigation }); expect(html).toContain('A very long Workset label'); expect(html).toContain('truncate'); expect(html).toContain('type="button"'); - expect(html).toContain('focus:ring-1'); + expect(html).toContain('focus-visible:outline-[var(--vscode-focusBorder)]'); }); +}); - it('shows a concise empty state when no selectable Project member exists', () => { - const emptyNavigation: ProjectWorksetNavigationData = { +describe('WorksetProjectPicker detail scene', () => { + const renderDetail = ( + initialScene: Parameters<typeof WorksetProjectPicker>[0]['initialScene'] = { kind: 'detail', name: 'planning' }, + nav: ProjectWorksetNavigationData = navigation, + ) => renderPicker({ initialScene, navigation: nav }); + + it('shows the Workset name, opener info, members, and a local Back control', () => { + const html = renderDetail(); + + expect(html).toContain('data-workset-scene="detail"'); + expect(html).toContain('data-workset-detail="planning"'); + expect(html).toContain('data-action="back-to-worksets"'); + expect(html).toContain('aria-label="Back to Worksets"'); + expect(html).toContain('Saved tool: vscode'); + expect(html).toContain('Docs Worktree'); + expect(html).toContain('feature/docs'); + expect(html).toContain('Planning Store'); + expect(html).not.toContain('data-workset-stale-notice'); + expect(html).not.toContain('data-workset-row='); + }); + + it('shows the default opener info when no tool is saved', () => { + const html = renderDetail({ kind: 'detail', name: 'solo' }); + + expect(html).toContain('data-workset-detail="solo"'); + expect(html).toContain('Default opener'); + expect(html).not.toContain('Saved tool:'); + }); + + it('marks the current Project member as a state text, not a disabled action', () => { + const html = renderDetail(); + + expect(html).toContain('data-workset-member-state="current"'); + expect(html).toContain('>Current<'); + // The current member row carries no button control of its own. + const currentIdx = html.indexOf('data-workset-member-state="current"'); + const rowStart = html.lastIndexOf('<div', currentIdx); + const rowEnd = html.indexOf('</div>', currentIdx); + expect(html.slice(rowStart, rowEnd)).not.toContain('<button'); + expect(html.slice(rowStart, rowEnd)).not.toContain('disabled'); + }); + + it('falls back to the list with a recoverable notice when the detail Workset vanished', () => { + const html = renderDetail({ kind: 'detail', name: 'removed' }); + + expect(html).toContain('data-workset-scene="list"'); + expect(html).toContain('data-workset-stale-notice'); + expect(html).toContain('removed'); + // No detail surface and no actions retained from the removed Workset. + expect(html).not.toContain('data-workset-detail='); + expect(html).not.toContain('data-workset-members='); + }); + + it('slugifies the detail heading id so names with spaces stay a single idref', () => { + // Pure slug: whitespace and other characters that are illegal inside an id + // (an aria-labelledby idref list splits on whitespace) collapse to dashes. + expect(worksetDetailHeadingId('planning')).toBe('workset-detail-planning'); + expect(worksetDetailHeadingId('alpha beta')).toBe('workset-detail-alpha-beta'); + expect(worksetDetailHeadingId('my set (x)')).toBe('workset-detail-my-set-x'); + expect(worksetDetailHeadingId('a -- b')).toBe('workset-detail-a-b'); + + const spacedNavigation: ProjectWorksetNavigationData = { + ...navigation, + worksets: [{ ...navigation.worksets[0], name: 'alpha beta' }], + }; + const html = renderPicker({ + navigation: spacedNavigation, + initialScene: { kind: 'detail', name: 'alpha beta' }, + }); + expect(html).toContain('aria-labelledby="workset-detail-alpha-beta"'); + expect(html).toMatch(/<h3[^>]*id="workset-detail-alpha-beta"/); + // The id/idref pair never contains whitespace. + expect(html).not.toContain('workset-detail-alpha beta'); + }); +}); + +describe('WorksetProjectPicker detail actions', () => { + it('offers an Open all action that opens the whole Workset without leaving the detail', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + expect(html).toContain('data-action="open-workset"'); + expect(html).toContain('aria-label="Open Whole Workset planning"'); + expect(html).toContain('>Open all<'); + expect(html).toContain('data-workset-scene="detail"'); + }); + + it('never routes a Planning Store member as a Project target', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + const storeIdx = html.indexOf('data-workset-store="team-plans"'); + expect(storeIdx).toBeGreaterThan(-1); + expect(html).toContain('data-action="use-planning-root"'); + expect(html.slice(storeIdx, html.indexOf('</div>', storeIdx))).not.toContain('data-workset-project='); + expect(html).not.toMatch(/data-workset-project="\/stores\/team-plans"/); + }); + + it('marks the active Planning Store member as Current root state text, not a disabled action', () => { + const html = renderPicker({ + initialScene: { kind: 'detail', name: 'planning' }, + activeStoreId: 'team-plans', + }); + + expect(html).toContain('data-workset-member-state="current-root"'); + expect(html).toContain('>Current root<'); + const rowStart = html.lastIndexOf('<div', html.indexOf('data-workset-member-state="current-root"')); + const rowEnd = html.indexOf('</div>', rowStart); + expect(html.slice(rowStart, rowEnd)).not.toContain('<button'); + expect(html.slice(rowStart, rowEnd)).not.toContain('disabled'); + }); + + it('offers Use as planning root for non-active Store members and hides project-default recovery when unused', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + expect(html).toContain('data-action="use-planning-root"'); + expect(html).toContain('aria-label="Use as planning root: team-plans"'); + expect(html).toContain('>Use as planning root<'); + expect(html).not.toContain('data-workset-member-state="current-root"'); + expect(html).not.toContain('data-action="use-project-default"'); + }); + + it('gates the project-default recovery on the explicit selector flag, not on binding.storeId', () => { + // A selector-free default binding may still carry a storeId when the CLI's + // root.store_id is set (the project default root IS a Store root): the + // recovery action must stay hidden because no explicit selector is active. + const selectorFreeStoreRoot = renderPicker({ + initialScene: { kind: 'detail', name: 'planning' }, + activeStoreId: 'team-plans', + }); + expect(selectorFreeStoreRoot).toContain('data-workset-member-state="current-root"'); + expect(selectorFreeStoreRoot).toContain('>Current root<'); + expect(selectorFreeStoreRoot).not.toContain('data-action="use-project-default"'); + expect(selectorFreeStoreRoot).not.toContain('>Use project default<'); + + // With an explicit selector active the recovery action is offered. + const explicitSelector = renderPicker({ + initialScene: { kind: 'detail', name: 'planning' }, + activeStoreId: 'team-plans', + explicitStoreSelector: true, + }); + expect(explicitSelector).toContain('data-action="use-project-default"'); + expect(explicitSelector).toContain('>Use project default<'); + + // The flag alone gates the action; it never depends on storeId labeling. + const explicitSelectorWithoutActiveMember = renderPicker({ + initialScene: { kind: 'detail', name: 'planning' }, + explicitStoreSelector: true, + }); + expect(explicitSelectorWithoutActiveMember).toContain('data-action="use-project-default"'); + expect(explicitSelectorWithoutActiveMember).toContain('data-action="use-planning-root"'); + }); + + it('shows a no-other-Projects state without offering Store members as Project actions', () => { + const storeOnly: ProjectWorksetNavigationData = { project: current, worksets: [{ name: 'stores-only', @@ -144,18 +424,880 @@ describe('WorksetProjectPicker', () => { }], }], }; + const html = renderPicker({ + navigation: storeOnly, + initialScene: { kind: 'detail', name: 'stores-only' }, + }); - const html = renderToStaticMarkup( - <WorksetProjectPicker - navigation={emptyNavigation} - onSelectProject={vi.fn()} - onOpenWorkset={vi.fn()} - onBackToCurrentProject={vi.fn()} - />, - ); + expect(html).toContain('No selectable Projects'); + expect(html).not.toContain('data-workset-project='); + expect(html).toContain('data-action="use-planning-root"'); + }); + + it('covers selectable Project members without a resolved Project context in the empty state', () => { + // The empty-state predicate must match the row predicate: a member that is + // neither actionable (no project context) nor current still counts as + // "no selectable Projects" instead of rendering an unexplained dead row. + const danglingMember: ProjectWorksetNavigationData = { + project: current, + worksets: [{ + name: 'dangling', + members: [ + { + name: current.label, + path: current.projectPath, + role: 'project', + selectable: true, + project: current, + }, + { + name: 'Dangling Member', + path: '/repos/dangling', + role: 'project', + selectable: true, + }, + ], + }], + }; + const html = renderPicker({ + navigation: danglingMember, + initialScene: { kind: 'detail', name: 'dangling' }, + }); expect(html).toContain('No selectable Projects'); expect(html).not.toContain('data-workset-project='); + expect(html).toContain('Dangling Member'); + }); + + it('keeps every detail action a keyboard-operable native button', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + expect(html).not.toContain('<select'); + // The only text input lives inside the collapsed one-time opener + // disclosure, which the hidden attribute removes from layout and tab order. + const inputIdx = html.indexOf('<input'); + if (inputIdx > -1) { + const formStart = html.lastIndexOf('<form', inputIdx); + const formOpenTag = html.slice(formStart, html.indexOf('>', formStart) + 1); + expect(formOpenTag).toContain('hidden'); + } + expect(html.match(/<button[^>]*type="button"/g)?.length).toBeGreaterThanOrEqual(4); + expect(html).toContain('focus-visible:outline-[var(--vscode-focusBorder)]'); + }); +}); + +describe('WorksetProjectPicker one-time opener', () => { + it('normalizes submitted opener ids and rejects empty values', () => { + expect(normalizeOneTimeOpenerTool(undefined)).toBeUndefined(); + expect(normalizeOneTimeOpenerTool('')).toBeUndefined(); + expect(normalizeOneTimeOpenerTool(' ')).toBeUndefined(); + expect(normalizeOneTimeOpenerTool(' cursor ')).toBe('cursor'); + expect(normalizeOneTimeOpenerTool('my-custom-tool')).toBe('my-custom-tool'); + }); + + it('keeps the one-time opener form mounted but hidden until explicitly requested', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + expect(html).toContain('data-action="reveal-one-time-opener"'); + expect(html).toContain('>Open with another tool<'); + // The disclosure form stays mounted so the toggle's aria-controls id always + // resolves to a real element, while the hidden attribute removes the form + // from layout, tab order, and the accessibility tree. + expect(html).toMatch(/<form[^>]*id="workset-one-time-opener-form"[^>]*hidden/); + expect(html).toContain('data-one-time-opener-form'); + }); + + it('mounts the expanded opener form with its id, aria-controls, and aria-expanded coexisting', () => { + const html = renderPicker({ + initialScene: { kind: 'detail', name: 'planning' }, + initialOpenerOpen: true, + }); + + const toggleMatch = html.match(/<button[^>]*data-action="reveal-one-time-opener"[^>]*>/); + expect(toggleMatch?.[0]).toContain('aria-expanded="true"'); + expect(toggleMatch?.[0]).toContain('aria-controls="workset-one-time-opener-form"'); + // The controlled element exists in the same document and is visible: the + // expanded disclosure carries no hidden attribute. + const formMatch = html.match(/<form[^>]*id="workset-one-time-opener-form"[^>]*>/); + expect(formMatch).not.toBeNull(); + expect(formMatch?.[0]).not.toContain('hidden'); + expect(formMatch?.[0]).toContain('data-one-time-opener-form'); + }); + + it('renders a free-form opener editor with code/cursor shortcuts and one submit action', () => { + const html = renderToStaticMarkup( + <WorksetOneTimeOpenerForm name="planning" onOpen={vi.fn()} />, + ); + + expect(html).toContain('data-one-time-opener-form'); + expect(html).toMatch(/<input[^>]*type="text"/); + expect(html).toContain('aria-label="Custom opener id"'); + expect(html).toContain('data-one-time-tool-shortcut="code"'); + expect(html).toContain('data-one-time-tool-shortcut="cursor"'); + expect((html.match(/data-action="open-with-one-time-tool"/g) ?? [])).toHaveLength(1); + expect(html).toContain('type="button"'); + }); + + it('submits the opener editor through a real form so Enter performs exactly one open', () => { + const html = renderToStaticMarkup( + <WorksetOneTimeOpenerForm name="planning" onOpen={vi.fn()} />, + ); + + // The editor is a native form: pressing Enter in the text input submits it. + const formMatch = html.match(/<form[^>]*data-one-time-opener-form[^>]*>/); + expect(formMatch).not.toBeNull(); + const formStart = html.indexOf('<form'); + const formEnd = html.indexOf('</form>', formStart); + expect(html.slice(formStart, formEnd)).toMatch(/<input[^>]*type="text"/); + // Exactly one submit control; shortcuts stay auxiliary buttons. + expect((html.match(/type="submit"/g) ?? [])).toHaveLength(1); + const submitMatch = html.match(/<button[^>]*data-action="open-with-one-time-tool"[^>]*>/); + expect(submitMatch?.[0]).toContain('type="submit"'); + }); +}); + +describe('whole-Workset open isolation', () => { + it('stops propagation and performs exactly one open per activation', () => { + const open = vi.fn(); + const stopPropagation = vi.fn(); + + runWorksetOpenAction('planning', open, { stopPropagation }); + expect(stopPropagation).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith('planning'); + + runWorksetOpenAction('planning', open, { stopPropagation }, 'cursor'); + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenLastCalledWith('planning', 'cursor'); + expect(stopPropagation).toHaveBeenCalledTimes(2); + }); +}); + +describe('WorksetProjectPicker create draft', () => { + it('seeds the draft with the current Project as the locked initial member and Primary', () => { + expect(openWorksetCreateScene(current)).toEqual({ + kind: 'create', + draft: { name: '', members: [current.projectPath], tool: '' }, + }); + }); + + it('moves the chosen member to the front for Primary while keeping every member', () => { + const draft: WorksetCreateDraft = { + name: 'feature', + tool: '', + members: [current.projectPath, '/repos/docs', '/repos/other'], + }; + expect(promoteWorksetPrimaryMember(draft, '/repos/other').members).toEqual([ + '/repos/other', + current.projectPath, + '/repos/docs', + ]); + // Promoting the current Primary or an unknown path is a no-op. + expect(promoteWorksetPrimaryMember(draft, current.projectPath).members).toEqual(draft.members); + expect(promoteWorksetPrimaryMember(draft, '/repos/unknown').members).toEqual(draft.members); + }); + + it('never removes the locked current Project member but removes others', () => { + const draft: WorksetCreateDraft = { + name: 'feature', + tool: '', + members: [current.projectPath, '/repos/docs'], + }; + expect(removeWorksetDraftMember(draft, current.projectPath, current.projectPath).members) + .toEqual([current.projectPath, '/repos/docs']); + expect(removeWorksetDraftMember(draft, '/repos/docs', current.projectPath).members) + .toEqual([current.projectPath]); + }); + + it('merges picked paths uniquely by canonical path and flags invalid entries', () => { + const draft: WorksetCreateDraft = { name: '', tool: '', members: [current.projectPath] }; + const merged = mergePickedWorksetMembers(draft, [ + '/repos/docs', + '/repos/docs', + current.projectPath, + 'relative/path', + 42, + '', + ]); + expect(merged.draft.members).toEqual([current.projectPath, '/repos/docs']); + expect(merged.droppedInvalid).toBe(true); + + const clean = mergePickedWorksetMembers(draft, ['/repos/a', 'C:\\repos\\b']); + expect(clean.draft.members).toEqual([current.projectPath, '/repos/a', 'C:\\repos\\b']); + expect(clean.droppedInvalid).toBe(false); + }); + + it('preserves the create scene across same-Project snapshot refreshes', () => { + const scene = openWorksetCreateScene(current); + const projectKey = worksetPickerProjectKey(current); + expect( + resolveWorksetPickerScene(scene, projectKey, { ...navigation, worksets: [...navigation.worksets] }), + ).toBe(scene); + }); + + it('resets the create scene when Project identity changes', () => { + const selection = { + projectKey: worksetPickerProjectKey(current), + scene: openWorksetCreateScene(current), + openerEditor: null, + }; + const otherKey = worksetPickerProjectKey({ + ...current, + id: '/projects/other', + projectPath: '/projects/other', + }); + expect(resetWorksetPickerSelectionForProject(selection, otherKey)).toEqual({ + projectKey: otherKey, + scene: { kind: 'list' }, + openerEditor: null, + }); + }); +}); + +describe('WorksetProjectPicker picker response application', () => { + const selectionIn = (scene: Parameters<typeof resetWorksetPickerSelectionForProject>[0]['scene']) => ({ + projectKey: worksetPickerProjectKey(current), + scene, + openerEditor: null, + }); + + it('applies a picker response only inside the create scene and only once per response', () => { + const next = reconcileWorksetPickerResponses( + selectionIn(openWorksetCreateScene(current)), + navigation, + { seq: 1, paths: ['/repos/docs'] }, + null, + ); + expect(next.scene).toEqual({ + kind: 'create', + draft: { name: '', tool: '', members: [current.projectPath, '/repos/docs'] }, + }); + expect(next.appliedPickedSeq).toBe(1); + // The same response never re-applies (stable reference). + expect( + reconcileWorksetPickerResponses(next, navigation, { seq: 1, paths: ['/repos/docs'] }, null), + ).toBe(next); + }); + + it('marks invalid picked paths with a recoverable notice instead of adding them', () => { + const next = reconcileWorksetPickerResponses( + selectionIn(openWorksetCreateScene(current)), + navigation, + { seq: 2, paths: ['/repos/docs', 'relative'] }, + null, + ); + expect(next.scene.kind).toBe('create'); + expect(next.createNotice).toEqual({ kind: 'invalidMembers' }); + expect(next.scene.kind === 'create' && next.scene.draft.members).toEqual([ + current.projectPath, + '/repos/docs', + ]); + }); + + it('ignores a late picker response after the surface left the create scene', () => { + const next = reconcileWorksetPickerResponses( + selectionIn(backToWorksetListScene()), + navigation, + { seq: 7, paths: ['/repos/late'] }, + null, + ); + expect(next.scene).toEqual({ kind: 'list' }); + expect(next.appliedPickedSeq).toBe(7); + expect( + reconcileWorksetPickerResponses(next, navigation, { seq: 7, paths: ['/repos/late'] }, null), + ).toBe(next); + }); + + it('keeps the create draft untouched when the picker response carries no eligible path', () => { + const next = reconcileWorksetPickerResponses( + selectionIn(openWorksetCreateScene(current)), + navigation, + { seq: 3, paths: [] }, + null, + ); + expect(next.scene).toEqual({ + kind: 'create', + draft: { name: '', tool: '', members: [current.projectPath] }, + }); + expect(next.appliedPickedSeq).toBe(3); + }); + + it('records the recoverable invalid-member notice for Host-dropped unrealpath-able picks', () => { + // All picked paths are valid; the Host still dropped one because its + // realpath could not be resolved — the notice must appear anyway. + const next = reconcileWorksetPickerResponses( + selectionIn(openWorksetCreateScene(current)), + navigation, + { seq: 4, paths: ['/repos/docs'], droppedPaths: ['/repos/gone'] }, + null, + ); + expect(next.scene.kind).toBe('create'); + expect(next.scene.kind === 'create' && next.scene.draft.members).toEqual([ + current.projectPath, + '/repos/docs', + ]); + expect(next.createNotice).toEqual({ kind: 'invalidMembers' }); + + // An empty add with only dropped paths still explains itself. + const allDropped = reconcileWorksetPickerResponses( + selectionIn(openWorksetCreateScene(current)), + navigation, + { seq: 5, paths: [], droppedPaths: ['/repos/gone-a', '/repos/gone-b'] }, + null, + ); + expect(allDropped.scene.kind === 'create' && allDropped.scene.draft.members).toEqual([ + current.projectPath, + ]); + expect(allDropped.createNotice).toEqual({ kind: 'invalidMembers' }); + }); + + it('renders the invalid-member notice for Host-dropped picks while adding the valid ones', () => { + const html = renderPicker({ + initialScene: openWorksetCreateScene(current), + pickedMembers: { seq: 9, paths: ['/repos/docs'], droppedPaths: ['/repos/gone'] }, + }); + + expect(html).toContain('data-create-notice'); + expect(html).toContain('Some selected folders were not added'); + expect(html).toContain('data-create-member="/repos/docs"'); + expect(html).not.toContain('data-create-member="/repos/gone"'); + }); +}); + +describe('WorksetProjectPicker create form rendering', () => { + it('renders name, tool, members, add-folders, cancel, and submit controls', () => { + const html = renderPicker({ initialScene: openWorksetCreateScene(current) }); + + expect(html).toContain('data-workset-scene="create"'); + expect(html).toContain('data-workset-create-form'); + expect(html).toContain('data-create-workset-name'); + expect(html).toContain('data-create-workset-tool'); + expect(html).toContain('data-create-member="/projects/current"'); + expect(html).toContain('data-create-member-state="current-project"'); + expect(html).toContain('data-create-member-primary="true"'); + expect(html).toContain('data-action="pick-workset-members"'); + expect(html).toContain('data-action="cancel-create-workset"'); + expect(html).toContain('data-action="submit-create-workset"'); + // Submit stays inert while the trimmed name is empty. + expect(html).toMatch(/data-action="submit-create-workset"[^>]*disabled/); + // The list rows are not rendered behind the create form. + expect(html).not.toContain('data-workset-row='); + }); + + it('visually mutes the disabled submit instead of saturated primary in dark/light themes', () => { + // Visual acceptance D1: with only the native disabled attribute, dark/light + // themes render the submit at full --vscode-button-background saturation, + // so the disabled state is invisible (High Contrast grays it by itself). + // The same disabled-variant muting idiom as ChangePagination (opacity + + // no-click cursor) must ride along on the submit's class string. + const submitTagOf = (markup: string) => + markup.match(/<button[^>]*data-action="submit-create-workset"[^>]*>/)?.[0] ?? ''; + + // Disabled (empty trimmed name): native disabled attribute AND the + // disabled: variant utilities together. + const disabledTag = submitTagOf(renderPicker({ initialScene: openWorksetCreateScene(current) })); + expect(disabledTag).not.toBe(''); + // The word "disabled" as an attribute (not the disabled: class variant). + expect(disabledTag).toMatch(/\bdisabled(?!:)/); + expect(disabledTag).toContain('disabled:opacity-50'); + expect(disabledTag).toContain('disabled:cursor-not-allowed'); + + // Enabled (valid trimmed name): no disabled attribute and no standalone + // muting utilities — the disabled: variants never activate outside + // :disabled, so the enabled control stays fully saturated primary. + const enabledTag = submitTagOf(renderPicker({ + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: '', members: [current.projectPath] }, + }, + })); + expect(enabledTag).not.toBe(''); + expect(enabledTag).not.toMatch(/\bdisabled(?!:)/); + // Standalone (space-prefixed) muting utilities must not leak into enabled. + expect(enabledTag).not.toMatch(/\sopacity-50/); + expect(enabledTag).not.toMatch(/\scursor-not-allowed/); + }); + + it('locks the current Project member: state text, no remove control, no promote while Primary', () => { + const html = renderPicker({ + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: '', members: [current.projectPath, '/repos/docs'] }, + }, + }); + + const lockedIdx = html.indexOf('data-create-member-state="current-project"'); + expect(lockedIdx).toBeGreaterThan(-1); + const rowStart = html.lastIndexOf('<div', lockedIdx); + const rowEnd = html.indexOf('</div>', lockedIdx); + const lockedRow = html.slice(rowStart, rowEnd); + expect(lockedRow).not.toContain('data-action="remove-create-member"'); + // Already Primary in this draft: no promote control either. + expect(lockedRow).not.toContain('data-action="promote-create-primary"'); + // Unlocked members expose both primary promotion and removal. + expect(html).toContain('data-action="promote-create-primary"'); + expect(html).toContain('data-action="remove-create-member"'); + }); + + it('offers primary promotion for a locked non-Primary member while keeping it locked', () => { + const html = renderPicker({ + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: '', members: ['/repos/docs', current.projectPath] }, + }, + }); + + const lockedIdx = html.indexOf('data-create-member-state="current-project"'); + const rowStart = html.lastIndexOf('<div', lockedIdx); + const rowEnd = html.indexOf('</div>', lockedIdx); + const lockedRow = html.slice(rowStart, rowEnd); + expect(lockedRow).toContain('data-action="promote-create-primary"'); + expect(lockedRow).not.toContain('data-action="remove-create-member"'); + }); + + it('offers a Create Workset entry from the list scene', () => { + const html = renderPicker(); + + expect(html).toContain('data-workset-scene="list"'); + expect(html).toContain('data-action="create-workset"'); + expect(html).toContain('>Create Workset<'); + }); + + it('renders an empty-list state with the Create entry when no worksets exist', () => { + const emptyNavigation: ProjectWorksetNavigationData = { + project: current, + worksets: [], + }; + const html = renderPicker({ navigation: emptyNavigation }); + + expect(html).toContain('data-workset-scene="list"'); + expect(html).toContain('data-workset-empty-list'); + expect(html).toContain('No Worksets yet'); + // The primary first-creation entry stays reachable from the empty list. + expect(html).toContain('data-action="create-workset"'); + expect(html).not.toContain('data-workset-row='); + }); + + it('hides the Create entry and shows the upgrade explanation when the Workset capability is unavailable', () => { + const emptyNavigation: ProjectWorksetNavigationData = { + project: current, + worksets: [], + }; + const html = renderPicker({ navigation: emptyNavigation, createAvailable: false }); + + expect(html).toContain('data-workset-scene="list"'); + expect(html).toContain('data-workset-empty-list'); + expect(html).not.toContain('data-action="create-workset"'); + expect(html).toContain('data-workset-capability-notice'); + // Existing upgrade copy is reused for the capability explanation. + expect(html).toContain('Stores and worksets require OpenSpec 1.5.0 or newer.'); + // The existing list/detail surface itself stays rendered. + expect(html).toContain('data-workset-project-picker'); + }); + + it('keeps the Create entry for existing worksets when the capability is explicitly available', () => { + const html = renderPicker({ createAvailable: true }); + + expect(html).toContain('data-action="create-workset"'); + expect(html).not.toContain('data-workset-capability-notice'); + expect(html).not.toContain('data-workset-empty-list'); + }); + + it('shows a recoverable notice for invalid picked members without adding them', () => { + const html = renderPicker({ + initialScene: openWorksetCreateScene(current), + pickedMembers: { seq: 9, paths: ['/repos/docs', 'relative'] }, + }); + + expect(html).toContain('data-create-notice'); + expect(html).toContain('data-create-member="/repos/docs"'); + expect(html).not.toContain('data-create-member="relative"'); + }); +}); + +describe('WorksetProjectPicker create result application', () => { + const featureNavigation: ProjectWorksetNavigationData = { + project: current, + worksets: [ + ...navigation.worksets, + { + name: 'feature', + members: [ + { + name: current.label, + path: current.projectPath, + role: 'project' as const, + selectable: true, + project: current, + }, + ], + }, + ], + }; + const selectionIn = (scene: Parameters<typeof resetWorksetPickerSelectionForProject>[0]['scene']) => ({ + projectKey: worksetPickerProjectKey(current), + scene, + openerEditor: null, + }); + + it('enters the detail scene only for a success result present in the fresh navigation', () => { + const next = reconcileWorksetPickerResponses( + selectionIn({ kind: 'create', draft: { name: 'feature', tool: '', members: [current.projectPath] } }), + featureNavigation, + null, + { seq: 4, success: true, name: 'feature' }, + ); + expect(next.scene).toEqual({ kind: 'detail', name: 'feature' }); + expect(next.appliedResultSeq).toBe(4); + }); + + it('stays in create when a success result names a Workset missing from the navigation', () => { + const next = reconcileWorksetPickerResponses( + selectionIn({ kind: 'create', draft: { name: 'ghost', tool: '', members: [current.projectPath] } }), + navigation, + null, + { seq: 4, success: true, name: 'ghost' }, + ); + expect(next.scene).toEqual({ + kind: 'create', + draft: { name: 'ghost', tool: '', members: [current.projectPath] }, + }); + expect(next.createNotice).toBeUndefined(); + }); + + it('preserves the draft and records the recoverable failure message', () => { + const draft = { name: 'feature', tool: 'cursor', members: [current.projectPath, '/repos/docs'] }; + const next = reconcileWorksetPickerResponses( + selectionIn({ kind: 'create', draft }), + featureNavigation, + null, + { seq: 5, success: false, name: 'feature', message: 'duplicate name' }, + ); + expect(next.scene).toEqual({ kind: 'create', draft }); + expect(next.createNotice).toEqual({ kind: 'createFailed', message: 'duplicate name' }); + // The same result never re-applies (stable reference). + expect( + reconcileWorksetPickerResponses(next, featureNavigation, null, { + seq: 5, + success: false, + name: 'feature', + message: 'duplicate name', + }), + ).toBe(next); + }); + + it('renders the success transition from create to the new detail', () => { + const html = renderPicker({ + navigation: featureNavigation, + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: '', members: [current.projectPath, '/repos/docs'] }, + }, + createResult: { seq: 6, success: true, name: 'feature' }, + }); + + expect(html).toContain('data-workset-scene="detail"'); + expect(html).toContain('data-workset-detail="feature"'); + expect(html).not.toContain('data-workset-create-form'); + }); + + it('renders a failure result as a notice that keeps the draft intact', () => { + const html = renderPicker({ + navigation: featureNavigation, + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: 'cursor', members: ['/repos/docs', current.projectPath] }, + }, + createResult: { seq: 7, success: false, name: 'feature', message: 'duplicate name' }, + }); + + expect(html).toContain('data-workset-scene="create"'); + expect(html).toContain('data-create-notice'); + expect(html).toContain('duplicate name'); + // The draft is untouched: ordered members and the entered name survive. + expect(html).toContain('data-create-member="/repos/docs"'); + expect(html).toContain('data-create-member-primary="true"'); + }); + + it('normalizes the submitted draft: trimmed name, ordered members, omitted empty tool', () => { + expect( + normalizeWorksetCreateSubmit({ name: ' feature ', tool: '', members: [current.projectPath, '/repos/docs'] }), + ).toEqual({ name: 'feature', members: [current.projectPath, '/repos/docs'] }); + expect( + normalizeWorksetCreateSubmit({ name: 'feature', tool: ' cursor ', members: [current.projectPath] }), + ).toEqual({ name: 'feature', members: [current.projectPath], tool: 'cursor' }); + expect( + normalizeWorksetCreateSubmit({ name: ' ', tool: '', members: [current.projectPath] }), + ).toBeUndefined(); + }); + + it('wires the create submit through normalization to exactly one Host call', () => { + const onCreateWorkset = vi.fn(); + const draft: WorksetCreateDraft = { + name: ' feature ', + tool: ' cursor ', + members: [current.projectPath, '/repos/docs'], + }; + + const sent = runWorksetCreateSubmit(draft, onCreateWorkset); + + expect(sent).toBe(true); + expect(onCreateWorkset).toHaveBeenCalledTimes(1); + expect(onCreateWorkset).toHaveBeenCalledWith('feature', [current.projectPath, '/repos/docs'], 'cursor'); + + // An unsubmittable draft performs no Host call at all. + const invalidCall = vi.fn(); + expect(runWorksetCreateSubmit({ name: ' ', tool: '', members: [current.projectPath] }, invalidCall)).toBe(false); + expect(invalidCall).not.toHaveBeenCalled(); + }); +}); + +describe('WorksetProjectPicker visual copy, accessibility, and narrow-sidebar styling', () => { + it('keeps the Back arrow glyph hidden from assistive tech while the label stays translated text', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + const backIdx = html.indexOf('data-action="back-to-worksets"'); + expect(backIdx).toBeGreaterThan(-1); + const backEnd = html.indexOf('</button>', backIdx); + const backButton = html.slice(backIdx, backEnd); + // The "←" glyph is wrapped in an aria-hidden span so screen readers never + // announce "left arrow"; the visible label is pure t() text. + expect(backButton).toMatch(/<span[^>]*aria-hidden="true"[^>]*>\u2190<\/span>/); + // The translated label is the only announced text (plain text after the + // decorative glyph, never "← Back to Worksets" as one text run). + expect(backButton).toMatch(/<\/span>Back to Worksets$/); + expect(backButton).not.toContain('>\u2190 Back to Worksets<'); + }); + + it('keeps the visible label inside the accessible name for primary and planning-root actions', () => { + // Label-in-Name (WCAG 2.5.3): the visible action text must appear verbatim + // inside the aria-label so speech-input activation matches what is seen. + const detail = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + expect(detail).toContain('aria-label="Use as planning root: team-plans"'); + expect(detail).toContain('>Use as planning root<'); + + const create = renderPicker({ + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: '', members: [current.projectPath, '/repos/docs'] }, + }, + }); + expect(create).toContain('aria-label="Make primary: /repos/docs"'); + expect(create).toContain('>Make primary<'); + + // zh-cn keeps the same containment contract with localized phrasing. + setLocale('zh-cn'); + try { + const zhDetail = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + expect(zhDetail).toContain('aria-label="设为规划根:team-plans"'); + expect(zhDetail).toContain('>设为规划根<'); + + const zhCreate = renderPicker({ + initialScene: { + kind: 'create', + draft: { name: 'feature', tool: '', members: [current.projectPath, '/repos/docs'] }, + }, + }); + expect(zhCreate).toContain('aria-label="设为主成员:/repos/docs"'); + expect(zhCreate).toContain('>设为主成员<'); + } finally { + setLocale('en'); + } + }); + + it('uses the theme-token focus-visible outline idiom instead of the default-blue ring or dead outlineColor', () => { + const html = renderPicker(); + const detail = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + const create = renderPicker({ initialScene: openWorksetCreateScene(current) }); + for (const markup of [html, detail, create]) { + expect(markup).toContain('focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--vscode-focusBorder)]'); + expect(markup).not.toContain('focus:ring'); + expect(markup).not.toContain('outline-color'); + expect(markup).not.toContain('focus-visible:ring'); + } + }); + + it('gives every button control an accessible name (no icon-only or bare controls)', () => { + for (const markup of [ + renderPicker(), + renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }), + renderPicker({ initialScene: openWorksetCreateScene(current) }), + renderToStaticMarkup(<WorksetOneTimeOpenerForm name="planning" onOpen={vi.fn()} />), + ]) { + const buttons = markup.match(/<button[^>]*>/g) ?? []; + expect(buttons.length).toBeGreaterThan(0); + for (const tag of buttons) { + expect(tag).toContain('aria-label='); + } + } + }); + + it('exposes one-time opener toggle state via aria-expanded and ties it to the form id', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + const toggleMatch = html.match(/<button[^>]*data-action="reveal-one-time-opener"[^>]*>/); + expect(toggleMatch?.[0]).toContain('aria-expanded="false"'); + expect(toggleMatch?.[0]).toContain('aria-controls="workset-one-time-opener-form"'); + + const formHtml = renderToStaticMarkup(<WorksetOneTimeOpenerForm name="planning" onOpen={vi.fn()} />); + expect(formHtml).toMatch(/<form[^>]*id="workset-one-time-opener-form"/); + // The free-form input keeps its explicit label; no heavyweight combobox + // semantics are introduced. + expect(formHtml).toContain('aria-label="Custom opener id"'); + }); + + it('shows best-effort repository identity as visible text next to the branch for git worktree members', () => { + // Pure label: repository identity is shortened to its best-effort basename + // so a ~430px row shows repo AND branch, with the full path in the tooltip. + expect(formatWorksetMemberGitLabel({ repository: '/repos/docs', branch: 'feature/docs' })) + .toBe('docs \u00b7 feature/docs'); + expect(formatWorksetMemberGitLabel({ branch: 'main' })).toBe('main'); + expect(formatWorksetMemberGitLabel({ repository: 'C:\\repos\\x' })).toBe('x'); + expect(formatWorksetMemberGitLabel(undefined)).toBe(''); + + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + // Visible text (not tooltip-only) beside the member name. + expect(html).toContain('docs \u00b7 feature/docs'); + // The full repository path stays available as tooltip metadata. + expect(html).toContain('title="/repos/docs"'); + }); + + it('pairs Project and Store identity icons with text, never color or icon alone', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + expect(html).toContain('codicon-folder'); + expect(html).toContain('codicon-library'); + expect(html).toContain('aria-hidden="true"'); + // Text identity: project member names and the store role text both render. + expect(html).toContain('Docs Worktree'); expect(html).toContain('Planning Store'); }); + + it('renders the list as one lightly-separated surface group with hover feedback and a chevron affordance', () => { + const html = renderPicker(); + + expect(html).toContain('data-workset-list'); + expect(html).toContain('divide-y'); + // Light separators use the theme panel border, not per-row card borders. + expect(html).toContain('divide-[color:var(--vscode-panel-border)]'); + expect(html).toContain('hover:bg-[var(--vscode-list-hoverBackground)]'); + expect(html).toContain('codicon-chevron-right'); + }); + + it('groups the empty Worksets state with the Create entry as one clear unit', () => { + const emptyNavigation: ProjectWorksetNavigationData = { + project: current, + worksets: [], + }; + const html = renderPicker({ navigation: emptyNavigation }); + + const groupStart = html.indexOf('data-workset-empty-group'); + expect(groupStart).toBeGreaterThan(-1); + const groupEnd = html.indexOf('</div>', groupStart); + const group = html.slice(groupStart, groupEnd); + expect(group).toContain('data-workset-empty-list'); + expect(group).toContain('data-action="create-workset"'); + }); + + it('labels the detail member group with a section header', () => { + const html = renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }); + + expect(html).toContain('data-workset-members-section'); + expect(html).toContain('>Members<'); + expect(html).toContain('3 members'); + }); + + it('orders the create form fields Name, Members, Tool and stacks full-width submit then cancel', () => { + const html = renderPicker({ initialScene: openWorksetCreateScene(current) }); + + const iName = html.indexOf('data-create-workset-name'); + const iMembers = html.indexOf('data-workset-create-members'); + const iTool = html.indexOf('data-create-workset-tool'); + const iSubmit = html.indexOf('data-action="submit-create-workset"'); + const iCancel = html.indexOf('data-action="cancel-create-workset"'); + expect([iName, iMembers, iTool, iSubmit, iCancel].every((i) => i > -1)).toBe(true); + expect(iName).toBeLessThan(iMembers); + expect(iMembers).toBeLessThan(iTool); + expect(iTool).toBeLessThan(iSubmit); + expect(iSubmit).toBeLessThan(iCancel); + expect(html).toMatch(/data-action="submit-create-workset"[^>]*class="[^"]*w-full/); + expect(html).toMatch(/data-action="cancel-create-workset"[^>]*class="[^"]*w-full/); + // The tool field keeps a helper line so the override capability is explicit. + expect(html).toContain('Can be overridden when opening.'); + }); + + it('limits animations to 120-160ms color transitions and keeps a reduced-motion escape hatch', () => { + for (const markup of [ + renderPicker(), + renderPicker({ initialScene: { kind: 'detail', name: 'planning' } }), + renderPicker({ initialScene: openWorksetCreateScene(current) }), + ]) { + const durations = markup.match(/duration-\d+/g) ?? []; + expect(durations.length).toBeGreaterThan(0); + for (const duration of durations) { + expect(duration).toBe('duration-150'); + } + const transitions = markup.match(/transition-[a-z-]+/g) ?? []; + for (const transition of transitions) { + expect(transition).toBe('transition-colors'); + } + } + // The global reduced-motion kill switch stays in place for the webview. + // Resolved from the test file itself (import.meta.url) so the read does + // not depend on the process cwd. The repo's commonjs tsc setting rejects + // import.meta syntactically while vitest executes this file as ESM; the + // directive silences only that config mismatch and resurfaces as an + // unused-directive error if the module setting ever moves to ESM. + const css = readFileSync( + new URL( + '../../../src/webview/index.css', + // @ts-expect-error -- commonjs tsc rejects import.meta; the vitest ESM runtime provides it + import.meta.url, + ), + 'utf8', + ); + expect(css).toContain('prefers-reduced-motion: reduce'); + }); + + it('bounds every fixed minimum width and keeps names truncating in both locales', () => { + const longNavigation: ProjectWorksetNavigationData = { + ...navigation, + worksets: [{ + ...navigation.worksets[0], + name: 'A very long Workset label that must remain bounded', + }], + }; + const enList = renderPicker({ navigation: longNavigation }); + for (const markup of [ + enList, + renderPicker({ navigation: longNavigation, initialScene: { kind: 'detail', name: 'planning' } }), + renderPicker({ navigation: longNavigation, initialScene: openWorksetCreateScene(current) }), + ]) { + // Whitelist-style narrow-width invariant: arbitrary fixed widths — + // Tailwind bracket widths or inline width styles — are forbidden + // outright across the list/detail/create markup because the ~430px + // surface must stay fluid. No exceptions exist today; if one ever + // becomes genuinely necessary, document it inline here instead of + // weakening the guard globally. + expect([...markup.matchAll(/(?:min-|max-)?w-\[\d+px\]/g)].map((m) => m[0])).toEqual([]); + expect(markup).not.toMatch(/style="[^"]*(?:min-|max-)?width\s*:/); + expect(markup).toContain('min-w-0'); + expect(markup).toContain('truncate'); + } + + // zh-cn renders fully localized copy with the same bounding utilities. + setLocale('zh-cn'); + try { + const zhList = renderPicker({ navigation: longNavigation }); + expect(zhList).toContain('工作集项目'); + expect(zhList).not.toContain('worksetNavigation.'); + expect(zhList).toContain('min-w-0'); + const zhDetail = renderPicker({ navigation: longNavigation, initialScene: { kind: 'detail', name: 'planning' } }); + expect(zhDetail).toContain('返回工作集列表'); + expect(zhDetail).not.toContain('worksetNavigation.'); + const zhCreate = renderPicker({ initialScene: openWorksetCreateScene(current) }); + expect(zhCreate).toContain('创建工作集'); + expect(zhCreate).not.toContain('worksetCreate.'); + } finally { + setLocale('en'); + } + }); }); diff --git a/test/webview/types/messages.test.ts b/test/webview/types/messages.test.ts new file mode 100644 index 0000000..f7a15ff --- /dev/null +++ b/test/webview/types/messages.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { sendMessage } from '../../../src/webview/types/messages'; +import type { ExtensionMessage, WebviewMessage } from '../../../src/webview/types/messages'; + +/** Compile-time guard: every asserted payload must be a valid WebviewMessage union member. */ +function asWebviewMessage(message: WebviewMessage): WebviewMessage { + return message; +} + +/** Compile-time guard: every asserted payload must be a valid ExtensionMessage union member. */ +function asExtensionMessage(message: ExtensionMessage): ExtensionMessage { + return message; +} + +describe('sendMessage workset creation contract', () => { + it('pickWorksetMembers produces a payload-free discriminator message', () => { + const message = asWebviewMessage(sendMessage.pickWorksetMembers()); + + expect(message).toStrictEqual({ type: 'pickWorksetMembers' }); + expect(Object.keys(message)).toStrictEqual(['type']); + }); + + it('createWorkset carries name and members in order without tool when omitted', () => { + const message = asWebviewMessage(sendMessage.createWorkset('platform', ['/work/primary', '/stores/team-plans'])); + + expect(message).toStrictEqual({ + type: 'createWorkset', + name: 'platform', + members: ['/work/primary', '/stores/team-plans'], + }); + expect(message).not.toHaveProperty('tool'); + }); + + it('createWorkset includes the one-time tool only when provided', () => { + const message = asWebviewMessage(sendMessage.createWorkset('platform', ['/work/primary'], 'cursor')); + + expect(message).toStrictEqual({ + type: 'createWorkset', + name: 'platform', + members: ['/work/primary'], + tool: 'cursor', + }); + expect(message).toHaveProperty('tool', 'cursor'); + }); + + it('openWorkset omits tool when not provided', () => { + const message = asWebviewMessage(sendMessage.openWorkset('platform')); + + expect(message).toStrictEqual({ type: 'openWorkset', name: 'platform' }); + expect(message).not.toHaveProperty('tool'); + }); + + it('openWorkset carries the one-time tool override when provided', () => { + const message = asWebviewMessage(sendMessage.openWorkset('platform', 'cursor')); + + expect(message).toStrictEqual({ type: 'openWorkset', name: 'platform', tool: 'cursor' }); + expect(message).not.toHaveProperty('members'); + }); + + it('selectWorksetStore carries the workset name and member path', () => { + const message = asWebviewMessage(sendMessage.selectWorksetStore('platform', '/stores/team-plans')); + + expect(message).toStrictEqual({ + type: 'selectWorksetStore', + worksetName: 'platform', + memberPath: '/stores/team-plans', + }); + }); + + it('selectProjectDefaultRoot produces a payload-free discriminator message', () => { + const message = asWebviewMessage(sendMessage.selectProjectDefaultRoot()); + + expect(message).toStrictEqual({ type: 'selectProjectDefaultRoot' }); + expect(Object.keys(message)).toStrictEqual(['type']); + }); +}); + +describe('ExtensionMessage workset result contract', () => { + it('worksetMembersPicked carries the picked folder paths verbatim', () => { + const message = asExtensionMessage({ + type: 'worksetMembersPicked', + paths: ['/work/primary', '/stores/team-plans'], + }); + + expect(message).toStrictEqual({ + type: 'worksetMembersPicked', + paths: ['/work/primary', '/stores/team-plans'], + }); + expect(Object.keys(message)).toStrictEqual(['type', 'paths']); + }); + + it('worksetMembersPicked optionally carries Host-dropped unrealpath-able paths', () => { + const message = asExtensionMessage({ + type: 'worksetMembersPicked', + paths: ['/work/primary'], + droppedPaths: ['/work/gone'], + }); + + expect(message).toStrictEqual({ + type: 'worksetMembersPicked', + paths: ['/work/primary'], + droppedPaths: ['/work/gone'], + }); + // The optional field keeps older payloads (and senders) unaffected. + const legacy = asExtensionMessage({ type: 'worksetMembersPicked', paths: [] }); + expect('droppedPaths' in legacy).toBe(false); + }); + + it('worksetCreateResult success carries name without a message field', () => { + const message = asExtensionMessage({ + type: 'worksetCreateResult', + success: true, + name: 'platform', + }); + + expect(message).toStrictEqual({ type: 'worksetCreateResult', success: true, name: 'platform' }); + expect(message).not.toHaveProperty('message'); + }); + + it('worksetCreateResult failure carries the diagnostic message', () => { + const message = asExtensionMessage({ + type: 'worksetCreateResult', + success: false, + name: 'platform', + message: 'workset create failed', + }); + + expect(message).toStrictEqual({ + type: 'worksetCreateResult', + success: false, + name: 'platform', + message: 'workset create failed', + }); + }); +}); + +describe('workset message union discrimination', () => { + it('narrows openWorkset messages on type without touching setContext paths', () => { + const message: WebviewMessage = sendMessage.openWorkset('platform', 'cursor'); + + if (message.type === 'openWorkset') { + expect(message.name).toBe('platform'); + expect(message.tool).toBe('cursor'); + } else { + throw new Error(`unexpected message type: ${(message as { type: string }).type}`); + } + }); + + it('narrows createWorkset messages on type', () => { + const message: WebviewMessage = sendMessage.createWorkset('platform', ['/work/primary']); + + if (message.type === 'createWorkset') { + expect(message.name).toBe('platform'); + expect(message.members).toStrictEqual(['/work/primary']); + expect(message.tool).toBeUndefined(); + } else { + throw new Error(`unexpected message type: ${(message as { type: string }).type}`); + } + }); + + it('narrows worksetMembersPicked and worksetCreateResult on type', () => { + const membersPicked: ExtensionMessage = { type: 'worksetMembersPicked', paths: [] }; + if (membersPicked.type === 'worksetMembersPicked') { + expect(membersPicked.paths).toStrictEqual([]); + } else { + throw new Error('unexpected message type'); + } + + const createResult: ExtensionMessage = { + type: 'worksetCreateResult', + success: true, + name: 'platform', + }; + if (createResult.type === 'worksetCreateResult') { + expect(createResult.success).toBe(true); + expect(createResult.name).toBe('platform'); + expect(createResult.message).toBeUndefined(); + } else { + throw new Error('unexpected message type'); + } + }); +});