From 3ae1579ef116c4d7c0e81973bf15c73237e211ad Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 20 Oct 2026 02:31:35 +0900 Subject: [PATCH 1/7] feat: simplify and optimize module graph navigation (0.9.0) --- ...le-graph-interaction-performance-design.md | 282 ++++++++++++++++++ .../2026-10-20-module-graph-validation.md | 35 +++ .agents/docs/README.md | 3 +- .agents/docs/architecture.md | 6 +- .github/workflows/ci.yml | 19 ++ .github/workflows/release.yml | 20 +- CHANGELOG.md | 9 + README.md | 5 +- README.zh-CN.md | 6 +- data/i18n/zh-cn.json | 28 +- docs/architecture.md | 4 + docs/commands.md | 4 + docs/dependency-graphs.md | 23 ++ l10n/bundle.l10n.zh-cn.json | 28 +- media/dependencyGraph.css | 20 ++ media/graph.js | 198 ++++++++---- package-lock.json | 34 ++- package.json | 6 +- src/cli/moduleGraph.ts | 4 +- src/cli/process.ts | 2 + src/graphs/export.ts | 18 ++ src/graphs/model.ts | 148 +++++---- src/graphs/panelHtml.ts | 25 +- src/graphs/refresh.ts | 44 +++ src/projects/dependencyGraphPanel.ts | 174 +++++++++-- src/projects/dependencyState.ts | 29 ++ src/projects/moduleGraph.ts | 71 ++++- src/views/models.ts | 12 +- src/views/projectView.ts | 46 ++- test/architecture.test.ts | 3 + test/artifacts.test.ts | 10 +- test/graphs/export.test.ts | 12 + test/graphs/navigation.test.ts | 46 +++ test/graphs/refresh.test.ts | 25 ++ test/projects/dependencyState.test.ts | 19 ++ test/projects/graphPanel.test.ts | 85 ++++++ test/projects/moduleGraph.test.ts | 17 ++ test/views/models.test.ts | 14 +- tools/benchmark-dependency-graphs.mjs | 66 ++-- tools/test-graphs-ui.mjs | 118 ++++++++ tools/test-libraries-ui.mjs | 42 ++- 41 files changed, 1524 insertions(+), 236 deletions(-) create mode 100644 .agents/docs/2026-10-20-module-graph-interaction-performance-design.md create mode 100644 .agents/docs/2026-10-20-module-graph-validation.md create mode 100644 docs/dependency-graphs.md create mode 100644 src/graphs/export.ts create mode 100644 src/graphs/refresh.ts create mode 100644 src/projects/dependencyState.ts create mode 100644 test/graphs/export.test.ts create mode 100644 test/graphs/navigation.test.ts create mode 100644 test/graphs/refresh.test.ts create mode 100644 test/projects/dependencyState.test.ts create mode 100644 test/projects/graphPanel.test.ts create mode 100644 tools/test-graphs-ui.mjs diff --git a/.agents/docs/2026-10-20-module-graph-interaction-performance-design.md b/.agents/docs/2026-10-20-module-graph-interaction-performance-design.md new file mode 100644 index 0000000..2c6646d --- /dev/null +++ b/.agents/docs/2026-10-20-module-graph-interaction-performance-design.md @@ -0,0 +1,282 @@ +# C++ Modules 可视化交互、性能与 Dependencies 状态设计 + +状态:0.9.0 实施与验证中。日期:2026-10-20。 + +范围:Modules 图、共用画布、Project 侧栏 Dependencies 的空态与刷新。本文记录现状实测与建议行为;Packages 图共用组件需做回归,不直接套用 Modules 的入口分类。 + +## 1. 结论与优先级 + +1. 首屏单列已有确定原因:测试源文件作为独立根,先耗尽 150 节点预算,依赖节点无法进入画布。应先建立入口分组,再按活动入口分配可见预算。 +2. 区分选择、焦点、定位、打开文件;局部展开/折叠作用于当前选择,修改深度作用于当前焦点视图。增加两层父节点上下文。 +3. 开图性能同时受 mcppls 查询和 Mermaid 主线程布局影响。先消除重复查询/布局并降低默认子图复杂度,测量后再决定是否更换布局器。 +4. Dependencies 有数据时显示折叠分组;无数据时在相同位置显示一行原因/操作提示。数据到达后自动更新,重复事件不产生重复查询或布局。 +5. SVG/PNG 导出当前可见子图,明确折叠与截断状态;导出不触发全图展开或重新查询。 + +## 2. 分析与实测 + +### 2.1 基线和方法 + +- 插件仓库:`mcpp-vscode`,commit `f667b5b`。 +- 基准项目:相邻的 `mcpp-community/mcpp` checkout,commit `cdc8daa7`。 +- `npm run compile` 通过;图模型、Modules provider/CLI、Packages provider 共 15 个测试通过;Project 模型/布局共 30 个测试通过。 +- 用真实项目运行 CLI JSON 查询,然后调用当前 `moduleEntries → moduleOwnership → buildModuleGraph → visibleGraph → mermaidGraph`。布局在 Google Chrome headless 中加载仓库打包的 Mermaid,采用 `media/graph.js` 的配置,每种视图连续测 3 次。 +- 用真实 `graphPanelHtml` + `media/graph.js`、模拟 VS Code 消息桥验证事件行为。这是浏览器组件测试,不是完整 VS Code E2E。 +- 查询结果均为一次观测,未清理索引缓存;不能将两种可执行文件的耗时差异解释成冷/热性能差异。浏览器布局数据不包含 VS Code 桥接和最终绘制开销。 + +### 2.2 查询与投影 + +| 项目 | 实测 | +| --- | --- | +| PATH 的 `mcppls` 查询 | wall 13.28 s,user 22.72 s,sys 1.54 s,峰值 RSS 993,608 KiB | +| 扩展优先使用的 bundled `mcppls` 0.0.16 查询 | wall 6.19 s,user 47.15 s,sys 2.61 s,峰值 RSS 1,329,976 KiB | +| bundled 原始 JSON | 207 个 modules,1,794 条 imports;`truncated=false`,`snapshot.indexing=true` | +| 本地 provider 后 | 343 节点、1,648 边、151 根,其中 149 个 `tests/` 根;识别到的入口为 `src/main.cpp` | +| 文件存在性与归属准备 | 约 11.0 ms,199 次存在性检查 | +| `buildModuleGraph` | 约 4.4 ms | + +RSS 来自 `/usr/bin/time` 的查询进程统计,不能当作 VS Code 整体内存。多核 CPU 时间和查询进程内存说明首次索引/查询值得优先排查,但没有对风扇或功耗直接测量,也没有证明查询内部哪个阶段占比最大。 + +| 视图,默认下游深度 3 | 可见节点/边 | 投影 | Mermaid render 三次 | +| --- | --- | --- | --- | +| 项目概览 | 150 / 0 | 0.79 ms | 281 / 266 / 249 ms | +| `src/main.cpp` 焦点 | 78 / 300,边预算截断 | 0.72 ms | 646 / 605 / 599 ms | +| `mcpp.pm` 焦点 | 7 / 6 | 0.34 ms | 28 / 25 / 28 ms | + +所有视图均应显示“索引尚未完成”,因为 provider 将 `indexing=true` 合并进了 `truncated`。此处不是完整、稳定的索引快照。 + +### 2.3 已确认的问题与边界 + +| 现象 | 代码/测试证据 | 判断 | +| --- | --- | --- | +| 首屏一列且 `+N` 很多 | `src/graphs/model.ts:46` 的投影先加入全部 roots 再 BFS;真实项目 151 根挤满预算,得到 0 边;另用 150 根共享一个依赖的确定性样本复现 | 必修复预算调度;调大深度不能解决 | +| 单击打开文件 | `media/graph.js` 的 click 调用 `activateNode → open`,组件测试确认 | 单击改选择,双击打开 | +| Focus 看似与选择混淆 | 当前 Focus 已发送选中 ID,host 写入 `current.focus`;选中高亮 CSS 叫 `is-focused` | 不应误诊为 Focus 总操作图根;需分别表达 selection/focus/camera | +| 选中后展开无变化 | `expanded` 是 Set;已在默认深度内的内部节点展开可能没有效果;同一节点重复展开幂等 | 改为可连续推进的每节点局部深度 | +| 父节点上下文不足 | 焦点仅加入直接 importers,并且在遍历下游前全部加入预算 | 增加独立上游深度与配额,防止父节点再次耗尽预算 | +| 不变数据重复布局 | 每次 `publish` 都重发 source、完整 search,draw 总调用 Mermaid;相同消息两次触发两次 render,组件测试确认 | 数据、拓扑、选择、镜头分通道去重 | +| 查询过期但仍消耗 CPU | generation 丢弃旧结果,`runProcess` 无取消参数;刷新可再启动查询 | 丢结果不等于取消任务,需 single-flight/取消 | +| Dependencies 消失 | `src/views/models.ts:649` 在无声明时返回空数组;`models.test.ts` 明确断言隐藏;清单读取失败时 `buildProjectTree` 只返回错误行 | 数据状态不应通过删除入口表达 | +| 是否必须构建才有 Dependencies | 真实清单解析得 12 条声明,传入空 lock 仍有 12 个子项 | 已声明依赖不依赖构建;间歇消失的具体触发尚未完整复现 | + +可能相关但尚未复现的路径:manifest 原子替换/短暂读取失败、活动文件切换导致项目发现改变、workspace 继承声明缺失。应通过状态日志和测试进一步定位,不将其写成已确定原因。 + +复测命令: + +```bash +npm run compile +node --test dist/test/graphs/model.test.js dist/test/projects/moduleGraph.test.js dist/test/projects/moduleGraphCli.test.js dist/test/projects/dependencyGraph.test.js +node --test dist/test/views/models.test.js dist/test/views/projectLayout.test.js +mcppls query module --graph --root --timeout 20 --format json +``` + +现有 `tools/benchmark-dependency-graphs.mjs` 只覆盖链、宽图、共享图,缺少多入口/测试根耗尽预算的样本。实施时应将本次真实快照统计、根洪泛和交互断言纳入可复用 harness;临时测量脚本不作为长期测试资产。 + +## 3. 根与首次进入 + +### 3.1 数据图与展示根分离 + +真实 import 图保持身份和方向:`A → B` 表示 A 导入 B。展示结构另设 `root group` 与虚拟包含边,不写回 import 数据、不计入 imports 数量、不占语义深度。 + +- Main:默认 `src/main.cpp` 与显式 `[targets.] kind=bin/main`。多个 main 放入 Main 虚拟组,保留目标名称。 +- Library:显式 `[lib].path` 优先;默认路径遵循 `src/<包名末段>.cppm`。只将实际存在/被 provider 提供的模块作为真实根;声明存在但文件缺失时显示诊断。库目标 `kind=lib/shared` 按实际配置关联,不能一概标为可执行入口。 +- Tests:按 `[test].discover` 分类(默认 `tests/**/*.cpp`,含排除 glob、`[]`);一个测试可直接作为入口,多个测试使用 Tests 虚拟根并显示计数。 +- Other:未被入口覆盖的组件、孤立模块、循环组件有单独入口列表,按需打开,保证可达;不要重新把全部组件作为首屏 roots 注入。 +- 外部/path 包保持现有一跳边界。Tests 可引用任意模块,不能把“测试目录下的每个源文件”武断当作语法上的 main。 + +当前 mcppls JSON 未提供测试目标/入口分类。V1 根据清单 glob 与已知文件分类,标注为“测试入口(按配置)”;复用并对齐 mcpp 的 glob 契约,不自行解析 C++ main。零 imports 测试可能不在 JSON 中,可按 discover 有界发现文件作为候选;扩展仅处理文件/配置元数据,C++ 语义继续归 mcppls。后续若需准确目标元数据,先协商上游接口。 + +### 3.2 默认视图 + +首次无恢复状态:选用当前编辑器所属的本项目入口(能可靠关联时),否则 Main → Library → Tests → Other。展开该活动入口下游 3 层;上游默认 2 层。旁边常驻入口选择器,显示其他入口/组及计数。默认 3 层仍受预算约束。 + +虚拟组进入后先显示有界入口批次(建议 20 个),选中某测试后按该测试展示依赖。切换到 Tests 不一次布局 149 个测试及其全部边。概览显示紧凑的 Main/Library/Tests/Other 入口组;有 Main/Library 时同时展开活动入口首层,避免概览只有孤立分类块。大型多个 Main/Library 同样分页。 + +Library 默认文件不存在时不创建假库。本项目没有默认 `src/mcpp.cppm`,`mcpp.pm` 可从 Other 访问,不能凭名称将其当作默认 lib 根。 + +保存各项目、模式及入口/焦点的导航状态;重开优先恢复。恢复 ID 不存在时退回可用入口,并给出简短提示。 + +## 4. 选择、焦点与局部操作 + +状态拆分为 `selectedId`(操作对象)、`focusId`(当前子图中心)、`camera`(缩放/位置)、`activeEntryId`(入口)、`upstreamDepth/downstreamDepth`、每节点方向性的展开/折叠覆盖。工具栏同时标明“焦点:A / 选中:B”。 + +| 操作 | 行为 | 是否重新布局 | +| --- | --- | --- | +| 单击节点 / Space | 选择节点,同步 host 选择状态 | 否 | +| 双击节点 / Enter / 打开文件按钮 | 打开节点关联文件,沿用现有只读旁开行为;多文件仍选择 | 否 | +| 定位选中(建议 C) | 将当前选中节点移入视口,保持合理缩放 | 否,节点可见时仅改镜头 | +| 定位焦点 | 镜头回到现有 `focusId` | 否 | +| 设为焦点(建议 F) | `focusId = selectedId`,生成其上下游子图,居中 | 拓扑变化时 | +| 展开下一层 / 节点 `+N` | 只推进选中节点该方向的可见边界一层;可连续操作 | 拓扑变化时 | +| 折叠 | 只关闭选中节点该方向分支,保留节点 | 拓扑变化时 | +| 下游深度 / 上游深度 | 调整当前焦点/活动入口的基线深度;覆盖规则见下文 | 拓扑变化时 | +| 返回项目概览 / 后退 | 恢复原入口/焦点、折叠及镜头,而不是每次清空状态 | 按缓存恢复 | +| 搜索命中 | 可见节点仅选择并定位;隐藏节点显示“以此为焦点”动作后进入 | 仅必要时 | + +键盘快捷键仅在画布有焦点且未输入搜索时响应。拖拽抑制 click/dblclick;折叠按钮阻止冒泡,不打开文件。双击产生的 click 仅做选择,无需延时单击。 + +### 4.1 局部展开的精确定义 + +- `展开下一层` 从所选节点当前已展示的连续方向边界继续一跳,不仅把节点加入 Set。已显示 3 层时再点可展示第 4 层,再点可展示第 5 层。 +- 用每节点/方向的局部深度覆盖或 frontier 记录表达进度;全局深度与局部覆盖取较大有效深度,显式折叠优先。修改深度只重置当前焦点的局部覆盖,UI 明示;其他焦点历史不受影响。 +- DAG 共享节点由其他未折叠分支仍可达时保留;从被折叠节点出发的该方向边隐藏。不按整棵树递归删除共享节点。 +- 上游沿反向索引走 2 跳,下游沿正向索引走 3 跳,分别搜索。上游节点只扩展祖先链,不顺带展开其兄弟依赖;禁止将两次遍历结果做无条件诱导边补全而显示无关分支。 +- 环使用 visited/剩余深度防止无限遍历,保留原始边方向。上游在左、下游在右;环布局可偏离严格左右层级,应仍保留可辨箭头。 +- 顶部明确写“父节点(导入者)2 层”“依赖 3 层”,避免“向前”方向歧义。 + +### 4.2 预算与进度 + +实施默认采用 60 节点/80 边,模型硬上限保留 150/300。原候选 80/120 在真实密集图上仍超过布局目标,按实测缩小默认预算,并去除重复的 import 边标签。焦点和选中节点优先纳入,先保留一条有意义的连接路径,才补其他节点;不允许 roots 或大量父节点一次耗尽预算。 + +父/子方向设独立软配额(建议父侧最多 20 节点,其余给焦点和下游,空闲额度可借用)。按层、入口/分支轮转,稳定 ID 排序;高出度节点分页追加邻居。节点与连接边成对进入预算,防止边截断留下大量无连接节点。 + +区分 `隐藏 N 项`(深度折叠)、`还有 N 项`(分页/预算)、`索引尚未完成`(数据不全)。达到硬预算时,展开须提供可执行出口:以该节点为焦点 / 隐藏其他分支 / 下一批;不能让按钮一直无效果。处于预算边界时不承诺每次展开都能增加一层。 + +## 5. Dependencies 始终有明确状态 + +### 5.1 显示规则 + +保留 Basics、依赖位置、Common commands 的顺序。有项目上下文时该位置始终占一行,使用稳定树节点 ID;无内容时使用普通信息行,不显示空“库依赖”下拉菜单。 + +| 状态 | 展示 | 可执行动作 | +| --- | --- | --- | +| 有清单声明,lock 缺失 | `Dependencies (12)`,子项显示声明约束或 path;说明尚未解析 | 查看图;按需构建/解析以补齐版本 | +| 声明读取中 | `正在读取依赖…`,已有结果保留 | 无需手动操作 | +| 确认无声明 | `此项目未声明依赖`,无展开箭头 | 可提供添加依赖动作 | +| 元数据未生成且无法获取依赖 | `依赖数据尚未生成,构建后重试`,无展开箭头 | 点击运行已有构建命令,仅在构建确实能生成所需数据时显示 | +| 清单读取失败 | `暂时无法读取依赖,点击重试`,无展开箭头;已有结果则保留并标记过期 | 重试;详情写 tooltip/日志 | +| Modules 索引进行中 | `模块索引中,完成后自动更新` | 不要求重复构建 | +| 未信任 / provider 不可用 | 对应的信任/安装/重试提示 | 仅提供适用动作 | + +包依赖以清单声明为基础,lock/索引补版本和关系,不能以是否构建决定入口存在。测试依赖、build 依赖、条件依赖保持分类标记。有效数据到达后信息行自动替换成 Dependencies 分组;确认清单删除依赖后反向切换为无声明信息行。 + +保留“没有项目”的整页空态;清单短暂失败但已有项目上下文时保留项目与依赖状态。只有确实移除/切换项目才清理旧上下文,不能让原子写入的一瞬间被误判成新项目无依赖。 + +### 5.2 精确的数据状态 + +`undefined` 和 `[]` 不再同时承载“未读取/失败/真为空”。增加带类型的状态,例如 `loading | ready | empty | stale | unavailable | error`,附 root、revision、原因和上次有效数据。无数据与有旧数据的 loading 分别渲染。 + +一次读取拿到完整快照后原子发布。先读 summary、再读依赖而分别刷新会造成中间空态,应避免。清单损坏/不可读不能被 tolerant parser 静默当成 ready-empty;成功解析为空才清除旧列表。权限错误不显示“请构建”。 + +## 6. 及时刷新而不重复工作 + +### 6.1 一条刷新管线,按成本分层 + +```mermaid +flowchart LR + E[清单 / lock / 源文件 / 构建完成 / 索引完成事件] --> M[按项目合并与标脏] + M --> Q[版本检查与 single-flight 数据读取] + Q --> S[发布完整快照 revision] + S --> T[侧栏模型去重] + S --> P[可见子图投影去重] + P --> L[拓扑或标签变化才布局] + C[选择 / 定位 / 缩放] --> V[仅更新状态与镜头] +``` + +| 变化源 | 最少必要工作 | +| --- | --- | +| manifest 新增/修改/删除 | 重读清单和入口/测试规则,更新侧栏;Modules 图仅在分类/语义可能改变时标脏 | +| lock 改变 | 补齐依赖版本/Packages 模型;不因此启动 Modules 查询 | +| C++ 文件增删改 | 合并成一次 Modules dirty;当前图保持可交互 | +| mcpp 构建/解析任务结束 | 同项目仅安排一次刷新;与随后 lock/索引事件合并;先判断结果是否变化 | +| mcppls 索引完成/新 generation | 获取新快照;只接受当前请求/项目的版本 | +| active editor 改变 | 更新项目选择;同 root 无数据变化不重读依赖、不递归数源文件 | +| bridge 状态更新 | 仅更新服务状态子树,不对所有依赖重新做同步 IO | +| 展开/折叠/深度 | 复用缓存图与邻接索引,只投影和必要布局 | +| 单击/定位/缩放/相同消息 | 无查询、无投影、无布局 | + +合并建议:文件事件 trailing debounce 200 ms,连续事件最大等待 1 s;事件到达立即记录 dirty,避免“等下一事件”才刷新。大规模写入时按最大等待处理最新快照,后续若仍变化再次合并,不把每个事件排成任务。以上为初始参数,需实测调整。 + +### 6.2 单任务、版本与去重 + +- 每 `(root, provider)` 最多一个进行中查询。新事件只标记 `dirtyDuringFlight`;结束后比对 revision/文件指纹,必要时补一次,补跑期间新变更仍会被记录。 +- 手动刷新加入同一队列;快速点击合并,不另开进程。切换项目/关闭可取消旧查询;超时/出错后保持可重试。generation 继续防止旧结果覆盖,但增加 AbortSignal/子进程终止,不能只丢弃结果。 +- 记录三层版本:source revision(快照与配置)、visible signature(节点、边、标签和折叠标记)、camera/selection state。source 更新但可见图不变时无需布局,选择更新无需全量图消息。 +- mcppls 的 generation 不能假设跨进程单调可比。缓存键加入项目、provider 可执行文件版本/会话、配置签名;跨会话以内容摘要确认变化。 +- `graphSnapshot`、`projection`、`selection`、`camera` 分开发布;搜索数据仅随 source revision 更新,查询结果最多显示 50 项,避免每次重建全部 datalist。 +- Webview 对相同 visible signature 直接复用 SVG;只保留最新待布局请求,不排队渲染每个中间状态。布局结果提交前检查 viewKey + revision;重开视图时 host 只发布一次,修复目前 `load/publish` 后又 `publish(true)` 的重复路径。 +- 普通数据刷新保留镜头和所选节点位置;只有初次打开、显式 Fit、设为焦点时调整镜头。主题修改优先更新 CSS;确需重绘则合并一次。 +- 缓存邻接/反向邻接/节点索引;文件归属检查以目录缓存、有界并发进行。实测当前 IO 约 11 ms,优先级低于查询和布局。 + +### 6.3 隐藏视图与索引未完成 + +侧栏可见时更新依赖展示;图隐藏时只记 dirty,不布局、不启动仅服务该图的重查询。返回可见时立即读最新缓存并至多启动一个必要查询。缓存按项目/模式做 LRU 与容量约束;保留上下文不应导致无限保留全部项目 SVG/图。 + +现有 mcppls CLI 只有拉取查询,尚未验证可订阅的图完成事件。因此分两级实现: + +1. 优先接现有任务完成、文件变化,以及已有 bridge 中可验证的索引完成信号;不能假定 bridge 暴露图 revision。 +2. 对 `snapshot.indexing/preparing=true`,在图可见时用低频退避探测(建议前次结束后 2/5/10/20 秒,最多自动 4 次;失败停止自动循环并显示重试)。若探测本身会重新启动全量索引或每次都返回同一未完成状态,不继续靠轮询解决:保留部分图,推进上游复用常驻索引/完成通知契约。 + +无事件、重试预算耗尽时无法保证索引完成瞬间自动可见,应明确显示“部分数据,可刷新”,并将常驻快照/完成通知列为实现完整及时性的上游依赖。不得用高频昂贵 CLI 查询冒充实时更新。退避时间从上次查询完成计算,始终遵守 single-flight。 + +### 6.4 性能策略与布局器 + +V1 保留 Mermaid/SVG,减小默认图并按 signature 缓存;render 串行合并、关闭多余自动 fit。不能直接将 `mermaid.render` 搬进 Web Worker:当前布局依赖 DOM/字体度量。 + +若有界可见图仍超出交互目标,再评估“独立图布局 worker + 自有 SVG renderer”,先做相同真实图基准比较。SVG DOM 是导出、键盘访问与当前节点规模的合适起点;全图 Canvas/WebGL 和完整替换渲染栈不作为首轮前提。 + +## 7. SVG / PNG 导出 + +增加“导出”菜单,默认导出**当前可见子图的完整画布**,包括当前折叠、父/子层数、标签与主题,使用内容包围盒,不受当前 pan/zoom 裁切;明确不是整个未展开工程图。未完成/预算受限时附“部分视图”说明与元数据。未来若需“当前视口截图”单独命名。 + +- SVG:克隆当前已完成布局的 SVG,去掉 CSS transform,写入 viewBox/尺寸,将 VS Code CSS 变量、必要计算样式内联;避免外部样式、脚本与网络字体依赖,背景支持主题色/透明。保留文本和箭头,不添加编辑器按钮。 +- PNG:从同一 SVG Blob 解码到 canvas,默认 2×,可选 1×;先校验尺寸/像素数(建议上限 16 MP、单边 8192,超出时降低倍率并提示或改导 SVG),toBlob 后释放 object URL/canvas。 +- 复用当前已提交 revision;布局过程中可等最新一次结束,导出期间固定快照,避免 A 版本 SVG 混入 B 版本标题。导出不触发查询、展开或额外布局。 +- Webview 只传生成的受限载荷/请求 ID,host 校验格式、尺寸与总大小,再走 `showSaveDialog + workspace.fs.writeFile`;取消保存无副作用。PNG 需要 CSP 允许本地 Blob 解码,调整范围仅限该用途。 +- 文件名如 `mcpp-modules-main.svg/png`。字体就绪后生成;测试离线打开、暗/亮主题、中文、箭头 marker、透明背景、多行标签和超限降级。 + +## 8. 实施分期与验收 + +### P0:确定性问题与基本交互 + +- `src/graphs/model.ts`:入口/虚拟组投影、父子双向遍历、连接预算、每节点展开进度。 +- `src/projects/moduleGraph.ts`:入口分类元数据;保留真实 ID/一跳外部边界;测试规则匹配。 +- `src/graphs/panelHtml.ts`、`media/graph.js`、CSS:单击选择/双击打开、定位与焦点区分、上下游深度与导航恢复。 +- `src/views/models.ts`、`projectView.ts`:明确依赖状态,有数据分组、无数据单行提示;更新旧的“无依赖必须隐藏”测试。 + +验收:真实 mcpp 首次可见 Main 的连接依赖;Tests 显示 149 的观测计数但不整批布局(计数随当前快照变动);入口可达、无假 lib;单击 0 次 open,双击 1 次 open;Focus(B) 使用选中的 B;折叠 B 不误折叠焦点 A;父链默认显示两跳。 + +### P1:刷新、查询与布局去重 + +- host 引入每 provider 的刷新协调器、dirty/revision、取消与缓存;图 payload 分层;侧栏避免 bridge 每次变化都同步重读整个项目。 +- Webview 做 signature 去重,去掉重复 publish 路径;隐藏页面暂停布局;对未完成快照实行有上限的重试。 +- 增加查询、投影、布局次数与耗时调试计数,日志按 root/revision/requestId 关联,默认不输出全图。 + +验收:100 个连续文件/任务事件合并处理;同版本只发布一次;进行中事件最多触发必要的一次补跑,后续新事件不丢失。lock 改变不查询 Modules;单击/定位不布局;相同消息两次仅布局一次;项目切换旧结果不能覆盖新图;隐藏图布局次数为 0;索引由 partial 变 ready 时更新,不要求关闭重开。 + +### P2:导出与真实项目压力回归 + +- 完成 SVG/PNG 导出、离线打开与图像校验。 +- 扩充基准:150+ 测试入口、宽扇出、密集共享 DAG、深链、循环、高上游扇入、10k 节点/50k 边纯模型;真实 mcpp 同时测试未构建、构建后、索引中与索引完成。 +- UI 集成回归 Packages / Modules 切换、已有依赖行定位、只读源码旁开、多项目状态与主题切换。现有 UI 脚本“单击打开”的断言改为双击,并添加单击不打开断言。 + +### 8.1 初始性能目标(待实现后验证,不是本次已达成) + +| 指标 | 目标/测量方式 | +| --- | --- | +| 单击选择、定位、缩放 | 0 查询、0 Mermaid render;本机输入处理尽量 < 50 ms | +| 数据就绪后的常规可见图布局 | 默认预算下 median ≤ 200 ms,p95 ≤ 400 ms;至少 20 次,记录节点/边与浏览器版本 | +| 热缓存重开且可见图未变 | 0 查询、0 新布局;必要时复挂缓存 SVG | +| 完成事件到 UI 更新 | 快速数据读取时 ≤ 1 s;需要 CLI 时为合并窗口 + 一次查询 + 布局,分别记录 | +| 冷开数据加载 | UI 立即可操作并显示阶段;不承诺压缩现有 6–13 s 查询,需上游索引复用后再定数值目标 | +| 资源稳定性 | 每 provider 同项目最多 1 查询,任务结束后无持续重绘;重复 20 次进入/离开后缓存不无界增长 | + +Dependencies 回归矩阵必须覆盖:无 lock 但有声明、真零声明、读取失败、临时无数据后变有、数据有后清单清空、原子写入、workspace 成员切换、构建结束与 lock 事件重叠。空态与错误都要保留稳定位置,不能重新变成“直接不显示”。 + +## 9. 实施决策 + +采用:默认活动入口下游 3 层/父节点 2 层;多个测试使用虚拟根并按 20 项分页;选择与焦点显式分离,提供定位选中/定位焦点;实测调整后的 60/80 默认预算与 150/300 硬预算;无依赖数据使用同位置单行提示;自动刷新采用事件合并和内容版本去重;导出默认可见子图全画布。 + +首轮顺序建议 P0 → P1 → P2。若索引完成通知/复用不可用,单独推进上游契约,不将“风扇/CPU 开销已解决”作为仅修改前端布局即可达成的承诺。 + +## 10. 实施验证补充 + +- 默认视图保留完整数据,投影为 60 节点/80 边以内;Main 优先,Tests 按 20 项分页。语义图不包含虚拟分组边。 +- 实现单击选择、双击打开、独立定位、上下游深度、局部推进、返回导航,以及 SVG/PNG 导出。 +- 文件事件按项目合并;lock 只刷新包数据;隐藏图不发起新查询/布局;未完成且变化中的结果有限重试,结果不变则停止。 +- 查询与可见内容分别缓存,Webview 以 source/theme 复用最多八份 SVG;选择与镜头操作不触发布局。临时清单失败保留旧依赖,成功空声明显示单行信息。 +- 真实项目新投影为 51 节点/80 边;20 次 Chromium 布局 median 163 ms、p95 171 ms,投影约 0.63 ms。对照旧 main 视图 78 节点/300 边的约 600 ms,这一比较包含预算和标签简化的共同收益,不是相同图大小的布局器性能比较。 +- 浏览器组件、host 调度、单元和 Extension Host 验证均独立执行。最终 CI、Release 与真实 VS Code 验收记录随发布补齐;首轮冷查询仍受上游索引开销影响。 + +### 紧凑工具栏 + +导出合并为一个下拉按钮,包含 SVG、PNG、Copy Mermaid。默认仅展示入口、搜索和视图菜单;上下游深度及定位焦点放在视图菜单内。选择栏保留 Focus、Locate selected,将展开、折叠、父节点和打开源码收进节点操作菜单,节点上的加号与双击仍可直接操作。相同的焦点与选中身份只显示一次;身份不同才同时显示。菜单支持键盘、Escape 关闭并恢复焦点,以及点击外部关闭。功能均保持可达,不新增默认输入步骤。 diff --git a/.agents/docs/2026-10-20-module-graph-validation.md b/.agents/docs/2026-10-20-module-graph-validation.md new file mode 100644 index 0000000..6665dbc --- /dev/null +++ b/.agents/docs/2026-10-20-module-graph-validation.md @@ -0,0 +1,35 @@ +# Modules graph 0.9.0 validation and review + +Status: implementation and large-project verification complete locally; CI and released-artifact verification pending. + +## Review perspectives + +| Perspective | Findings addressed | Evidence | +| --- | --- | --- | +| Graph semantics | Test-root budget starvation; parent traversal bringing sibling dependencies; edge limits stranding nodes; library classification based on target spelling | Graph/model and module provider regressions; large-project projection | +| Interaction | Click opening files; repeated expansion doing nothing; selected/focus/camera ambiguity; initial root centering overriding Fit | Chromium canvas tests; real VS Code fixture navigation | +| Refresh and concurrency | Event bursts starting duplicate reads; lock changes querying Modules; hidden refreshes; old view publications arriving after a project switch | Refresh coordinator tests and the real host module under a controlled message transport | +| Project state | Empty declarations removing the row; incomplete manifests clearing usable dependencies; delayed project switching exposing old actions | Dependency state tests; immediate cross-project update; real VS Code inspection | +| Cache correctness | Identical SVGs in different projects retaining old node identities | Chromium test proves layout reuse with new event bindings | +| Export | Theme variables missing offline; transformed/cropped drawing; excessive raster size | Light/dark standalone SVG and PNG, decoded dimensions, Unicode and marker inspection | +| Compatibility | Shared Packages canvas, read-only source documents, old language service commands | Existing unit and five service-variant Extension Host suites; real source read-only checks | +| Release and privacy | Local paths in the initial design; accidental Marketplace publication; packaged local evidence | Sanitized design, manual Marketplace workflow, archive inclusion/size gates | + +## Local measurements + +The mcpp baseline snapshot contains 343 retained nodes and 1,648 imports. Its roots include 149 test files. The old initial projection has 150 nodes and zero edges. The new Main projection has 51 nodes and 80 edges; complete source data remains available behind focus and paging. + +Twenty fresh layouts using shipped Mermaid in Chromium measured median 163 ms and p95 171 ms for that real projection. These measurements include smaller budgets and removal of repeated import labels; they do not claim an equally sized layout algorithm became faster. Wide, shared and many-test synthetic samples also stay within the initial layout target. + +The simplified toolbar combines formats and Mermaid copy under Export, moves depth controls into View options, and groups advanced branch/source operations under Node actions. Chromium checks cover menu reachability, dismissal and keyboard focus restoration. + +Cold CLI queries still depend on mcppls indexing. No fan-speed or whole-editor power claim is made. Incomplete snapshots remain explicit; unchanged partial results stop automatic retrying. + +## Required final gates + +- Full repository consistency/unit suite and the new Chromium job. +- Existing Linux/macOS/Windows Extension Host and isolated VSIX install jobs, package guards and upstream drift check. +- Real VS Code large-project entry/test paging and selected-node focus: passed locally with the installed VSIX; repeat on the released artifact. +- Squash merge followed by successful tag release, GitHub/Open VSX artifact verification and local validation of the exact released VSIX. + +Local screenshots and raw fixtures remain ignored development artifacts. Public evidence uses fixture names and aggregate statistics, without machine paths or editor profile data. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index c2d029b..53cfda1 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -8,7 +8,8 @@ | [`architecture.md`](architecture.md) | mcpp-vscode 的分层结构、模块职责与扩展点 | 现状 | | [`mcpp-integration.md`](mcpp-integration.md) | 与 `mcpp-community/mcpp`(mcpp CLI)的接口契约 | 现状 | | [`mcppls-integration.md`](mcppls-integration.md) | 与 `sunrisepeak.mcpp-language-server`(mcppls)的依赖与命令桥接契约 | 现状 | - +| [`2026-10-20-module-graph-interaction-performance-design.md`](2026-10-20-module-graph-interaction-performance-design.md) | Modules 根分组、选择/焦点、局部展开、性能、Dependencies 状态与刷新、SVG/PNG 导出 | 0.9.0 实施中 | +| [`2026-10-20-module-graph-validation.md`](2026-10-20-module-graph-validation.md) | 0.9.0 多视角 review、性能与发布验收记录 | 验证中 | 边界: diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md index 9e8174a..7abc6e9 100644 --- a/.agents/docs/architecture.md +++ b/.agents/docs/architecture.md @@ -95,9 +95,13 @@ extension.ts VS Code 装配层:激活、命令注册、provide ## 8. Shared dependency canvas and C++ Modules graph -- `graphs/model.ts` defines a provider-independent graph, component roots, depth/branch projection, 150-node/300-edge layout budgets and escaped Mermaid output. Complete module data is retained independently of the visible subgraph. +- `graphs/model.ts` defines a provider-independent graph, component roots, depth/branch projection, 60-node/80-edge default layout budgets (150/300 model ceiling), importer/dependency traversal, virtual entry groups and escaped Mermaid output. Complete module data is retained independently of the visible subgraph. - `projects/dependencyGraphPanel.ts` owns one persistent Webview with separate per-project/per-kind graph and navigation states. `graphs/panelHtml.ts` and `media/graph.js` share rendering, wheel/pointer navigation, folding, focus, search and theme handling. Layouts are serialized; obsolete provider requests are discarded. - `cli/moduleGraph.ts` is a trust-gated, time/output-bounded consumer of mcppls CLI JSON. This extends the integration boundary with structured queries, without taking over C++ parsing, resolution, LSP ownership or mcppls settings. Prefer the installed dependency extension's payload, with PATH fallback when absent. - `projects/moduleGraph.ts` validates the result, reads manifest entry conventions and determines file ownership by the nearest project manifest. Preserve all internal edges and just the first hop into another package; `external=false` alone cannot identify project ownership. Generated sources outside the project boundary are conservatively treated as external. - `webview/readOnlyDocuments.ts` is the shared content-addressed virtual-document store used by examples and graph navigation; graph messages contain node identities, and the host opens only provider files attached to those identities. - Basics contains C++ Modules Deps; declared package Dependencies follow as a collapsed sibling before Common commands. Existing language-server module-graph forwarding commands remain compatible. + +- `graphs/refresh.ts` coalesces events with a bounded debounce, serializes cancellable reads and defers hidden work. File and task events invalidate provider snapshots; content signatures suppress redundant layouts. Partial CLI snapshots retry only while changing and within a finite budget. +- `graphs/export.ts` bounds and validates SVG/PNG bytes before the host saves them. The browser inlines computed SVG styles and rasterizes the same drawing, independent of the camera. +- `projects/dependencyState.ts` rejects incomplete manifests and retains the last usable dependency rows on transient errors. Successful empty declarations produce an information row instead of removing the Dependencies position. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20ad12d..2638c3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -230,3 +230,22 @@ jobs: VERSION=$(printf '%s\n' "$LIST" | sed -n 's/^sunrisepeak\.mcpp-language-server@//p') echo "resolved mcppls $VERSION" echo "resolved mcppls \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" + + graph-browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - name: Render and interact with the shipped graph in Chromium + run: npm run test:graphs-ui + - uses: actions/upload-artifact@v4 + if: always() + with: + name: graph-browser-evidence + path: .dev-profile/graph-ui-artifacts + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 188e7e4..e80dca9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,10 +84,8 @@ jobs: --generate-notes fi - # 两个市场收到的都是刚上传给 GitHub Release 的同一只 VSIX(SHA-256 对得上)。 - # 分工是作者定的:Open VSX 是本流水线的必发目标——缺 token 显式失败,不静默跳过; - # Marketplace 机会发布——没配 token 就打一行说明跳过,Release 本身不因此失败。 - # secret 名即仓库已配置的 OPENVSX_TOKEN;--pat 显式传入,不依赖 CLI 的环境变量约定。 + # Open VSX receives the exact VSIX attached to the GitHub Release. + # Marketplace upload uses that same artifact through the publisher portal. - name: 发布到 Open VSX shell: bash env: @@ -102,14 +100,6 @@ jobs: fi npx ovsx publish "mcpp-vscode-${PACKAGE_VERSION}.vsix" --pat "$OPENVSX_TOKEN" - - name: 发布到 Visual Studio Marketplace(未配置 token 则跳过) - shell: bash - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - run: | - set -euo pipefail - if [ -z "${VSCE_PAT:-}" ]; then - echo "未配置 VSCE_PAT:跳过 Marketplace 发布(Open VSX 与 GitHub Release 不受影响)。" - exit 0 - fi - npx vsce publish --packagePath "mcpp-vscode-${PACKAGE_VERSION}.vsix" + # The signed-off VSIX is uploaded manually to Visual Studio Marketplace. + - name: Marketplace upload artifact + run: echo "Upload the GitHub Release VSIX manually to Visual Studio Marketplace." diff --git a/CHANGELOG.md b/CHANGELOG.md index 426dcd5..47a2d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Unreleased +## 0.9.0 - 2026-10-20 + +- Group module entries into Main, Library, Tests and Other, with paged test entries and a connected default view for large projects. +- Separate selection, focus and camera navigation: click to select, double-click to open source, locate either selection or focus, and explore two importer levels alongside three dependency levels. Local expansion advances repeatedly and preserves shared branches. +- Coalesce graph refresh events, cancel obsolete queries, reuse unchanged layouts and pause hidden graph work. Preserve dependency rows through transient manifest failures and show a status line when no declarations are available. +- Keep entry/search controls visible and group export formats, view settings and node actions into compact menus. +- Export the visible graph as offline SVG or bounded PNG without expanding or querying the full project. Add Chromium coverage for graph interaction, layout reuse and exports. +- Release VSIX remains available on GitHub and Open VSX; Visual Studio Marketplace upload is manual. + ## 0.8.0 - 2026-10-19 - Package details use the available editor width on fullscreen displays, with bounded side gutters and a compact metadata column. diff --git a/README.md b/README.md index bbcdb4e..620facd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ its own and only **forwards** to it ([docs/architecture.md](docs/architecture.md | [mcpp-language-server](https://github.com/Sunrisepeak/mcpp-language-server) | The C++ Modules language service; a hard dependency of this extension | [Marketplace](https://marketplace.visualstudio.com/items?itemName=sunrisepeak.mcpp-language-server) · [Open VSX](https://open-vsx.org/extension/sunrisepeak/mcpp-language-server) | | mcpp-vscode (this extension) | The VS Code front end | [Marketplace](https://marketplace.visualstudio.com/items?itemName=mcpp-community.mcpp-vscode) · [Open VSX](https://open-vsx.org/extension/mcpp-community/mcpp-vscode) · [Releases](https://github.com/mcpp-community/mcpp-vscode/releases) | +See [dependency graph navigation and exports](docs/dependency-graphs.md) for entry groups, importer depth, refresh behavior and SVG/PNG export. + ## Install Any of the three channels installs the same extension: @@ -73,8 +75,7 @@ that never touches your real one. Release: bump the version in `package.json` and `package-lock.json`, commit, and push a tag matching the version exactly. The workflow runs the tests, packages one VSIX, and publishes that -same artifact to GitHub Releases and Open VSX — and to the Marketplace when its token is -configured. +same artifact to GitHub Releases and Open VSX. Upload that VSIX manually through the Visual Studio Marketplace publisher portal. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index cf422be..70a1035 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,8 @@ C++ 模块语义——诊断、补全、悬停、定义、引用、模块图— | [mcpp-language-server](https://github.com/Sunrisepeak/mcpp-language-server) | C++ Modules 语言服务;本扩展的硬依赖 | [Marketplace](https://marketplace.visualstudio.com/items?itemName=sunrisepeak.mcpp-language-server) · [Open VSX](https://open-vsx.org/extension/sunrisepeak/mcpp-language-server) | | mcpp-vscode(本扩展) | VS Code 前端 | [Marketplace](https://marketplace.visualstudio.com/items?itemName=mcpp-community.mcpp-vscode) · [Open VSX](https://open-vsx.org/extension/mcpp-community/mcpp-vscode) · [Releases](https://github.com/mcpp-community/mcpp-vscode/releases) | +模块入口分组、父节点深度、局部展开、刷新和 SVG/PNG 导出见 [依赖图使用说明](docs/dependency-graphs.md)。 + ## 安装 三个渠道装到的是同一个扩展: @@ -71,8 +73,8 @@ C++ 模块语义——诊断、补全、悬停、定义、引用、模块图— 不会碰你自己的)。 发布:把 `package.json` 与 `package-lock.json` 的版本号改好并提交,推送与版本完全一致的 -tag。工作流会跑测试、打包同一只 VSIX,并把它发到 GitHub Releases 与 Open VSX——Marketplace -在配置了 token 时同样自动发布。 +tag。工作流会跑测试、打包同一只 VSIX,并发布到 GitHub Releases 与 Open VSX。官方 Marketplace +使用 Release 中的同一 VSIX 手动上传。 ## 许可证 diff --git a/data/i18n/zh-cn.json b/data/i18n/zh-cn.json index 020afb3..fe18bbb 100644 --- a/data/i18n/zh-cn.json +++ b/data/i18n/zh-cn.json @@ -588,5 +588,31 @@ "Packages show declarations; modules show mcppls imports. External modules stop after one hop.": "包图显示声明关系;模块图来自 mcppls,外部模块只显示一层。", "Source is unavailable for this module.": "此模块的源码不可用。", "Choose a module source file": "选择模块源码文件", - "The source graph is incomplete. Some modules may be omitted.": "源图数据不完整,部分模块可能尚未包含。" + "The source graph is incomplete. Some modules may be omitted.": "源图数据不完整,部分模块可能尚未包含。", + "Could not export this graph. Try SVG or a smaller view.": "无法导出此图,请尝试 SVG 或更小的视图。", + "Entry group": "入口分组", + "Selected": "选中", + "Export SVG": "导出 SVG", + "Export PNG": "导出 PNG", + "Dependencies depth": "依赖深度", + "Importers depth": "父节点深度", + "Previous entries": "上一批入口", + "Next entries": "下一批入口", + "Back": "后退", + "Locate focus": "定位焦点", + "Locate selected": "定位选中", + "Open source": "打开源码", + "Expand importers": "展开父节点", + "Collapse importers": "折叠父节点", + "Focus this result": "以此结果为焦点", + "Dependencies unavailable — click to retry": "暂时无法读取依赖,点击重试", + "Reading dependencies…": "正在读取依赖…", + "This project declares no dependencies": "此项目未声明依赖", + "Refresh pending": "等待刷新", + "Main": "主程序", + "Tests": "测试", + "Other": "其他", + "Export": "导出", + "View options": "视图", + "Node actions": "节点操作" } diff --git a/docs/architecture.md b/docs/architecture.md index 895d6b1..b7f8854 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,3 +128,7 @@ mismatch, an untranslated runtime string or a stale snapshot fails before any te `docs/` is excluded from the VSIX by `.vscodeignore`, so these links work on GitHub and in the repository but not inside an installed extension. + +## Graph canvas + +Graph providers retain data separately from bounded visible projections. Entry grouping, two-way traversal and local folds live in `src/graphs/model.ts`; file/build events use a cancellable refresh coordinator. The canvas caches layouts and keeps camera/selection updates independent. SVG/PNG exports use the committed visible drawing. See [Dependency graphs](dependency-graphs.md). diff --git a/docs/commands.md b/docs/commands.md index b3d7902..c89e14d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -129,3 +129,7 @@ title. `src/cli/controller.ts` and is shown only while a project is found. - `mcpp.internal.refreshProjectView` is registered by the project view but is **not** contributed, so it never appears in the palette. + +## Project graph navigation + +The project package/module graph canvas supports independent selection and focus, importer depth, entry groups and SVG/PNG export. See [Dependency graphs](dependency-graphs.md). Existing forwarded mcppls graph commands remain separate. diff --git a/docs/dependency-graphs.md b/docs/dependency-graphs.md new file mode 100644 index 0000000..d2114c7 --- /dev/null +++ b/docs/dependency-graphs.md @@ -0,0 +1,23 @@ +# Dependency graphs + +Open **Project → Basics → C++ Modules Deps** for imports, or use the graph action on the **Dependencies** row for package declarations. C++ parsing and resolution come from mcppls; package relationships come from manifests, with lock/index metadata supplementing them. + +The toolbar keeps entry selection and search visible. **View options** contains the two depth controls, **Locate focus** and **Back to project**. **Node actions** contains branch expansion/collapse, importer actions and source opening; double-click and the node plus control remain shortcuts. **Export** combines SVG, PNG and Mermaid copy in one menu. Menus close after an action, on outside click or with Escape. + +Modules opens an active entry with three dependency levels and two importer levels. Main and Library entries follow explicit target/main and lib paths or the package defaults. Multiple tests are grouped under Tests using `[test].discover` (default `tests/**/*.cpp`); entry groups show twenty entries per page. Other keeps disconnected components accessible. External package modules stop after one hop. The importer control follows arrows backwards; dependency depth follows imports forwards. + +- Click a node to select it; double-click or press Enter to open its read-only source beside the graph. Space selects without opening. +- **Focus** uses the selected node as the center of a new subgraph. **Locate selected** and **Locate focus** move the camera without changing the graph. C and F work while the canvas has keyboard focus. +- **Expand next level** advances the selected branch repeatedly. Importers have separate expand/collapse controls. Collapsing a shared branch preserves nodes still reachable from another branch. Changing a depth control resets that direction's local overrides in the current view. +- Search selects and locates a visible match. A hidden match offers **Focus this result**. **Back** restores prior navigation; **Back to project** shows entry groups. +- Scroll to zoom, drag to pan and use **Fit** to show the complete visible drawing. Changing selection, locating and zooming reuse the current layout. + +The normal view is bounded to 60 nodes and 80 edges before layout. A plus count indicates omitted outgoing neighbors; the view notice distinguishes a layout limit from incomplete provider data. When a branch reaches the limit, focus that node to continue exploring. “All” removes the depth limit while retaining the drawing budget. + +Refresh merges nearby file/build events and keeps at most one query active for a project/provider. Hidden graphs defer rendering. Unchanged snapshots reuse layouts; an incomplete snapshot gets bounded background retries while visible, stopping when results stop changing. If the provider remains incomplete, its notice stays visible and Refresh is available. First-time mcppls indexing can still take several seconds; this extension does not run another C++ indexer. + +Project Dependencies remains visible without a lock file: declarations are enough to list direct dependencies. A project with none shows a single information row. Transient manifest read failures preserve the last usable rows and mark them pending; without usable data a retry row appears. Successful manifest/lock changes update the row automatically. Building is useful for generating resolved metadata, but is not required to show declarations. + +**Export → Export SVG** and **Export → Export PNG** save the complete visible drawing, independent of pan and zoom. They preserve current labels, colors and folding, and include a partial-view description when appropriate. Exports do not expand the full project or query mcppls. PNG starts at 2× and reduces resolution to stay within 16 megapixels and 8192 pixels per side; SVG remains scalable. Both use the standard save dialog. + +For contributors, `npm run test:graphs-ui` runs the shipped canvas and Mermaid in Chromium, covering interaction, unchanged-layout reuse and offline SVG/PNG export. Install the browser once with `npx playwright install chromium`. The normal CI also runs this test and retains fixture screenshots and exports as artifacts. diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 020afb3..fe18bbb 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -588,5 +588,31 @@ "Packages show declarations; modules show mcppls imports. External modules stop after one hop.": "包图显示声明关系;模块图来自 mcppls,外部模块只显示一层。", "Source is unavailable for this module.": "此模块的源码不可用。", "Choose a module source file": "选择模块源码文件", - "The source graph is incomplete. Some modules may be omitted.": "源图数据不完整,部分模块可能尚未包含。" + "The source graph is incomplete. Some modules may be omitted.": "源图数据不完整,部分模块可能尚未包含。", + "Could not export this graph. Try SVG or a smaller view.": "无法导出此图,请尝试 SVG 或更小的视图。", + "Entry group": "入口分组", + "Selected": "选中", + "Export SVG": "导出 SVG", + "Export PNG": "导出 PNG", + "Dependencies depth": "依赖深度", + "Importers depth": "父节点深度", + "Previous entries": "上一批入口", + "Next entries": "下一批入口", + "Back": "后退", + "Locate focus": "定位焦点", + "Locate selected": "定位选中", + "Open source": "打开源码", + "Expand importers": "展开父节点", + "Collapse importers": "折叠父节点", + "Focus this result": "以此结果为焦点", + "Dependencies unavailable — click to retry": "暂时无法读取依赖,点击重试", + "Reading dependencies…": "正在读取依赖…", + "This project declares no dependencies": "此项目未声明依赖", + "Refresh pending": "等待刷新", + "Main": "主程序", + "Tests": "测试", + "Other": "其他", + "Export": "导出", + "View options": "视图", + "Node actions": "节点操作" } diff --git a/media/dependencyGraph.css b/media/dependencyGraph.css index 492c281..4fab7bf 100644 --- a/media/dependencyGraph.css +++ b/media/dependencyGraph.css @@ -41,3 +41,23 @@ button:hover { background:var(--vscode-toolbar-hoverBackground);color:var(--vsco #graph .fold-control,#graph .fold-control tspan { fill:var(--vscode-textLink-foreground);cursor:pointer;font-weight:600; } #graph .fold-control:hover { text-decoration:underline; } #graph .fold-control:focus-visible { outline:1px solid var(--vscode-focusBorder); } @media(max-width:600px) { .graph-selection { flex-wrap:wrap; } .graph-tools { gap:8px; } #graph { height:calc(100vh - 290px); } } +#focus-node { color:var(--vscode-charts-purple);white-space:nowrap; } +#graph .node.is-graph-focus rect { stroke-dasharray:5 2;stroke-width:2px; } +.graph-selection { flex-wrap:wrap; } .graph-selection>div { display:flex;flex-wrap:wrap; } +#graph-entries { max-width:170px; } #entry-page { white-space:nowrap; } +.graph-menu { position:relative;display:inline-block;flex:none;color:var(--vscode-descriptionForeground);font-size:12px; } +.graph-menu>summary { display:flex;align-items:center;gap:7px;list-style:none;border:1px solid transparent;border-radius:5px;padding:5px 8px;white-space:nowrap; } +.graph-menu>summary::-webkit-details-marker { display:none; } +.graph-menu>summary::after { content:'⌄';font-size:11px; } +.graph-menu>summary:hover,.graph-menu[open]>summary { background:var(--vscode-toolbar-hoverBackground);color:var(--vscode-foreground); } +.graph-menu>summary:focus-visible { outline:1px solid var(--vscode-focusBorder);outline-offset:2px; } +.graph-menu>summary[aria-disabled=true] { opacity:.4;cursor:default; } +.graph-menu-items { position:absolute;right:0;top:calc(100% + 4px);z-index:20;min-width:190px;padding:5px;display:flex;flex-direction:column;align-items:stretch;gap:2px;border:1px solid var(--vscode-menu-border,var(--vscode-panel-border));border-radius:6px;background:var(--vscode-menu-background,var(--vscode-editorWidget-background));box-shadow:0 4px 14px #0004; } +.graph-menu-items>button { justify-content:flex-start;padding:7px 10px;color:var(--vscode-menu-foreground,var(--vscode-foreground)); } +.graph-menu-items>label { display:flex;align-items:center;justify-content:space-between;gap:16px;padding:7px 10px;white-space:nowrap; } +.graph-tools { gap:8px; } .graph-tools input { flex:1;max-width:260px; } +.graph-context { display:flex;gap:14px;align-items:center;min-width:0;overflow:hidden; } +.graph-context>span { overflow:hidden;text-overflow:ellipsis; } +.graph-selection>.graph-node-actions { display:flex;flex-wrap:nowrap;align-items:center;gap:2px; } +.graph-header nav { flex:none; } #graph-view-menu .graph-menu-items { left:0;right:auto; } +@media(max-width:700px) { .graph-context { flex:1 1 100%; } .graph-menu-items { max-width:calc(100vw - 60px); } } diff --git a/media/graph.js b/media/graph.js index 36b5176..7cc6544 100644 --- a/media/graph.js +++ b/media/graph.js @@ -1,13 +1,16 @@ -/* Shared package/module graph canvas. Providers send bounded subgraphs; the canvas owns navigation. */ +/* Bounded SVG canvas: data, selection and camera have separate update paths. */ (() => { const vscode = acquireVsCodeApi(), labels = JSON.parse(document.getElementById('graph-labels').textContent); const graph = document.getElementById('graph'), status = document.getElementById('graph-state'); - const views = new Map(); + const views = new Map(), snapshots = new Map(), layouts = new Map(); let data, currentKey, selected, zoom = 1, panX = 0, panY = 0, baseWidth = 0, baseHeight = 0; - let drag, suppressClick = false, pending, drawing = false, renderId = 0, busy = true; + let drag, suppressClick = false, pending, drawing = false, renderId = 0, busy = true, committed, committedBinding; const send = (action, extra = {}) => vscode.postMessage({ type: 'action', action, viewKey: currentKey, ...extra }); + const cameraKey = next => next && `${next.viewKey}:${next.focus || next.group || 'overview'}:${next.offset || 0}`; + const snapshot = () => snapshots.get(currentKey) || { search: [], groups: [] }; + function trim(map, max = 8) { while (map.size > max) map.delete(map.keys().next().value); } function applyView() { const svg = graph.querySelector('svg'); if (svg) svg.style.transform = `translate(${panX}px,${panY}px) scale(${zoom})`; } - function saveView() { if (currentKey && baseWidth) views.set(currentKey, { zoom, panX, panY }); } + function saveView() { if (data && baseWidth) { views.delete(cameraKey(data)); views.set(cameraKey(data), { zoom, panX, panY }); trim(views, 40); } } function zoomAt(next, x = graph.clientWidth / 2, y = graph.clientHeight / 2) { next = Math.max(.02, Math.min(4, next)); const ratio = next / zoom; panX = x - (x - panX) * ratio; panY = y - (y - panY) * ratio; zoom = next; applyView(); saveView(); @@ -17,31 +20,66 @@ zoom = Math.max(.001, Math.min(1, (graph.clientWidth - 48) / baseWidth, (graph.clientHeight - 48) / baseHeight)); panX = (graph.clientWidth - baseWidth * zoom) / 2; panY = (graph.clientHeight - baseHeight * zoom) / 2; applyView(); saveView(); } + function locate(id) { + const node = [...graph.querySelectorAll('.node')].find(node => node.dataset.nodeKey === id); + if (!node || busy) return; + if (zoom < .85) zoomAt(.85); + const viewport = graph.getBoundingClientRect(), box = node.getBoundingClientRect(); + panX += viewport.left + viewport.width / 2 - box.left - box.width / 2; + panY += viewport.top + viewport.height / 2 - box.top - box.height / 2; applyView(); saveView(); + } + const menus = [...document.querySelectorAll('.graph-menu')]; + function closeMenus(except) { for (const menu of menus) if (menu !== except) menu.open = false; } + for (const menu of menus) menu.addEventListener('toggle', () => { if (menu.open) closeMenus(menu); }); + document.addEventListener('click', event => { if (!event.target.closest('.graph-menu')) closeMenus(); }); + document.addEventListener('keydown', event => { if (event.key === 'Escape') { const active = menus.find(menu => menu.open); closeMenus(); active?.querySelector('summary').focus(); } }); + document.getElementById('graph-node-menu').querySelector('summary').addEventListener('click', event => { if (!selected || busy) event.preventDefault(); }); function selectNode(id, center = false) { selected = id; graph.querySelectorAll('.node').forEach(node => { const active = node.dataset.nodeKey === id; node.classList.toggle('is-focused', active); node.setAttribute('aria-pressed', String(active)); - if (active && center) { - if (zoom < .85) zoomAt(.85); - const viewport = graph.getBoundingClientRect(), box = node.getBoundingClientRect(); - panX += viewport.left + viewport.width / 2 - box.left - box.width / 2; - panY += viewport.top + viewport.height / 2 - box.top - box.height / 2; applyView(); saveView(); - } + node.classList.toggle('is-graph-focus', node.dataset.nodeKey === data?.focus); }); - document.getElementById('selected-node').textContent = data?.nodes.find(node => node.id === id)?.label || labels.selected; - for (const button of document.querySelectorAll('.graph-selection button')) button.disabled = !id || busy; + const item = data?.nodes.find(node => node.id === id); + document.getElementById('selected-node').textContent = item ? `${labels.selectedPrefix}: ${item.label}` : labels.selected; + document.getElementById('selected-node').hidden = !!data?.focus && id === data.focus; + document.getElementById('graph-node-menu').querySelector('summary').setAttribute('aria-disabled', String(!id || busy)); + document.getElementById('focus-node').textContent = `${labels.focus}: ${snapshot().search.find(node => node.id === data?.focus)?.label || labels.group}`; + for (const button of document.querySelectorAll('.graph-selection button')) button.disabled = !id || busy || (button.dataset.action === 'open' && item?.virtual); + if (center) locate(id); } - function activateNode(id) { selectNode(id); send('open', { id }); } + function choose(id) { selectNode(id); send('select', { id }); } function setStatus(text, block = false) { status.textContent = text; status.hidden = !text; graph.classList.toggle('is-loading', block); } document.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', () => { - if (button.dataset.action === 'copy') return send('copy'); - send(button.dataset.action, { id: selected }); + closeMenus(); + const action = button.dataset.action; + if (action === 'locate') return locate(selected); + if (action === 'locateFocus') return locate(data?.focus); + send(action, { id: selected }); })); document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => send('mode', { mode: button.dataset.mode }))); - document.getElementById('graph-depth').addEventListener('change', event => send('depth', { depth: event.target.value })); + document.getElementById('graph-depth').addEventListener('change', event => { closeMenus(); send('depth', { depth: event.target.value }); }); + document.getElementById('graph-upstream').addEventListener('change', event => { closeMenus(); send('upstreamDepth', { depth: event.target.value }); }); + document.getElementById('graph-entries').addEventListener('change', event => send('group', { group: event.target.value })); + document.getElementById('entry-previous').addEventListener('click', () => send('page', { next: false })); + document.getElementById('entry-next').addEventListener('click', () => send('page', { next: true })); const search = document.getElementById('graph-search'); - function find() { const node = data?.search.find(node => node.id === search.value || node.label === search.value); if (node) send('search', { id: node.id }); } + function options() { + const list = document.getElementById('graph-options'); list.replaceChildren(); + for (const node of snapshot().search.filter(node => node.label.toLowerCase().includes(search.value.toLowerCase())).slice(0, 50)) { + const option = document.createElement('option'); option.value = node.label; list.append(option); + } + } + let hiddenResult; + document.getElementById('graph-search-focus').addEventListener('click', () => { if (hiddenResult) send('search', { id: hiddenResult }); }); + function find() { + const node = snapshot().search.find(node => node.id === search.value || node.label === search.value); + if (!node) return; + if (data?.nodes.some(item => item.id === node.id)) { choose(node.id); locate(node.id); } + else { hiddenResult = node.id; document.getElementById('graph-search-focus').hidden = false; } + } + search.addEventListener('input', () => { hiddenResult = undefined; document.getElementById('graph-search-focus').hidden = true; options(); }); search.addEventListener('change', find); search.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); find(); } }); document.querySelectorAll('[data-zoom]').forEach(button => button.addEventListener('click', () => button.dataset.zoom === 'fit' ? fitGraph() : zoomAt(zoom * (button.dataset.zoom === '+' ? 1.2 : 1 / 1.2)))); graph.addEventListener('wheel', event => { @@ -59,78 +97,136 @@ function endDrag(event) { if (!drag || event.pointerId !== drag.id) return; suppressClick = drag.moved; if (graph.hasPointerCapture(event.pointerId)) graph.releasePointerCapture(event.pointerId); - graph.classList.remove('is-grabbing'); drag = undefined; setTimeout(() => suppressClick = false, 0); + graph.classList.remove('is-grabbing'); drag = undefined; setTimeout(() => suppressClick = false, 300); } graph.addEventListener('pointerup', endDrag); graph.addEventListener('pointercancel', endDrag); - graph.addEventListener('click', event => { if (suppressClick || busy) { event.preventDefault(); event.stopPropagation(); } }, true); + for (const type of ['click', 'dblclick']) graph.addEventListener(type, event => { if (suppressClick || busy) { event.preventDefault(); event.stopPropagation(); } }, true); graph.addEventListener('keydown', event => { + if (busy) return; if (['+', '=', '-'].includes(event.key)) { event.preventDefault(); zoomAt(zoom * (event.key === '-' ? 1 / 1.2 : 1.2)); } if (event.key === '0') { event.preventDefault(); fitGraph(); } + if (event.key.toLowerCase() === 'c') { event.preventDefault(); locate(selected); } + if (event.key.toLowerCase() === 'f' && selected) { event.preventDefault(); send('focus', { id: selected }); } }); + function controls(next) { + document.getElementById('project-name').textContent = next.name; + document.getElementById('graph-depth').value = next.depth; + document.getElementById('graph-upstream').value = next.upstreamDepth || '0'; + document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === next.mode))); + document.querySelector('[data-action=overview]').hidden = !next.focus; + document.querySelector('[data-action=back]').disabled = !next.back; + document.querySelector('[data-action=locateFocus]').disabled = !next.focus; + if (next.nodes.some(node => node.id === hiddenResult)) { hiddenResult = undefined; document.getElementById('graph-search-focus').hidden = true; } + const entries = document.getElementById('graph-entries'); entries.replaceChildren(); + const placeholder = document.createElement('option'); placeholder.textContent = labels.group; placeholder.value = ''; entries.append(placeholder); + for (const group of snapshot().groups) { const option = document.createElement('option'); option.value = group.id; option.textContent = `${group.label} (${group.nodes.length})`; entries.append(option); } + entries.value = next.group || ''; entries.hidden = !snapshot().groups.length; + const group = snapshot().groups.find(group => group.id === next.group); + for (const id of ['entry-previous', 'entry-page', 'entry-next']) document.getElementById(id).hidden = !group; + document.getElementById('entry-previous').disabled = !next.offset; + document.getElementById('entry-next').disabled = !group || (next.offset || 0) + 20 >= group.nodes.length; + document.getElementById('entry-page').textContent = group ? `${(next.offset || 0) + 1}–${Math.min((next.offset || 0) + 20, group.nodes.length)} / ${group.nodes.length}` : ''; + document.getElementById('graph-source').textContent = next.source; + const notice = document.getElementById('graph-notice'); notice.textContent = [next.limited ? labels.limited : '', next.truncated ? labels.partial : ''].filter(Boolean).join(' '); notice.hidden = !notice.textContent; + } async function draw() { - if (drawing) return; + if (drawing || document.hidden) return; drawing = true; - while (pending) { + while (pending && !document.hidden) { const next = pending; pending = undefined; - const request = ++renderId, viewChanged = currentKey !== next.viewKey; - saveView(); currentKey = next.viewKey; data = next; busy = true; - document.getElementById('project-name').textContent = next.name; - document.getElementById('graph-depth').value = next.depth; - document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === next.mode))); - document.querySelector('[data-action=overview]').hidden = !next.focus; - const options = document.getElementById('graph-options'); options.replaceChildren(); - for (const node of next.search) { const option = document.createElement('option'); option.value = node.label; option.label = node.label; options.append(option); } - if (viewChanged) search.value = ''; - document.getElementById('graph-source').textContent = next.source; - const notice = document.getElementById('graph-notice'); notice.textContent = [next.limited ? labels.limited : '', next.truncated ? labels.partial : ''].filter(Boolean).join(' '); notice.hidden = !notice.textContent; - if (!next.nodes.length) { graph.replaceChildren(); setStatus(labels.empty); busy = false; selectNode(undefined); continue; } + const viewChanged = currentKey !== next.viewKey; + saveView(); currentKey = next.viewKey; data = next; controls(next); + if (viewChanged) { search.value = ''; hiddenResult = undefined; document.getElementById('graph-search-focus').hidden = true; options(); } const dark = document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast'); + const signature = `${dark}:${next.source}`, binding = JSON.stringify([next.viewKey, next.nodes.map(node => node.id)]); + if (committed === signature && committedBinding === binding && graph.querySelector('svg')) { + busy = false; setStatus(''); if (next.fit) fitGraph(); selectNode(next.selected, next.center); continue; + } + if (!next.nodes.length) { graph.replaceChildren(); committed = undefined; setStatus(labels.empty); busy = false; selectNode(undefined); continue; } + busy = true; mermaid.initialize({ startOnLoad: false, htmlLabels: false, securityLevel: 'strict', suppressErrorRendering: true, theme: dark ? 'dark' : 'default', fontFamily: 'system-ui', markdownAutoWrap: false, flowchart: { htmlLabels: false, useMaxWidth: true, wrappingWidth: 1000, nodeSpacing: 30, rankSpacing: 90, padding: 18 }, maxEdges: 310 }); try { - const result = await mermaid.render(`dependency-svg-${request}`, next.source); + const layoutStarted = performance.now(), cached = layouts.has(signature); + const markup = layouts.get(signature) || (await mermaid.render(`dependency-svg-${++renderId}`, next.source)).svg; if (pending || data !== next) continue; - graph.innerHTML = result.svg; const svg = graph.querySelector('svg'); + if (next.debug) console.debug('mcpp graph', { ...next.debug, cacheHit: cached, layoutMs: performance.now() - layoutStarted }); + layouts.delete(signature); layouts.set(signature, markup); trim(layouts); + graph.innerHTML = markup; committed = signature; committedBinding = binding; const svg = graph.querySelector('svg'); baseWidth = svg.viewBox.baseVal.width; baseHeight = svg.viewBox.baseVal.height; svg.style.width = baseWidth + 'px'; svg.style.maxWidth = 'none'; - const saved = views.get(currentKey); + const saved = views.get(cameraKey(next)); if (!saved || next.fit) fitGraph(); else { ({ zoom, panX, panY } = saved); applyView(); } - graph.querySelectorAll('.edgeLabel .label').forEach(label => { - const text = label.querySelector('text'), background = label.querySelector('rect.background'); - if (text && background) { const box = text.getBBox(); for (const [key, value] of Object.entries({ x: box.x - 4, y: box.y - 3, width: box.width + 8, height: box.height + 6, rx: 3 })) background.setAttribute(key, String(value)); } - }); graph.querySelectorAll('.node').forEach(node => { const match = /flowchart-(n[0-9]+)-/.exec(node.id); if (!match) return; const item = next.nodes[Number(match[1].slice(1))]; if (!item) return; node.dataset.nodeKey = item.id; node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button'); node.setAttribute('aria-label', node.textContent); node.querySelectorAll('rect').forEach(rect => { rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8'); }); - node.addEventListener('click', () => activateNode(item.id)); - node.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activateNode(item.id); } }); + node.addEventListener('click', () => choose(item.id)); + node.addEventListener('dblclick', () => { choose(item.id); send(item.virtual ? 'focus' : 'open', { id: item.id }); }); + node.addEventListener('keydown', event => { + if (event.target !== node) return; + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(item.id); if (event.key === 'Enter') send(item.virtual ? 'focus' : 'open', { id: item.id }); } + }); if (item.hidden) { const line = node.querySelector('text')?.lastElementChild; if (line) { line.classList.add('fold-control'); line.setAttribute('role', 'button'); line.setAttribute('tabindex', '0'); line.setAttribute('aria-label', labels.expand + ': ' + item.hidden); - const expand = event => { event.preventDefault(); event.stopPropagation(); selectNode(item.id); send('expand', { id: item.id }); }; - line.addEventListener('click', expand); line.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') expand(event); }); + const expand = event => { event.preventDefault(); event.stopPropagation(); choose(item.id); send('expand', { id: item.id }); }; + line.addEventListener('click', expand); line.addEventListener('dblclick', event => event.stopPropagation()); + line.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') expand(event); }); } } }); busy = false; setStatus(''); selectNode(next.selected, next.center); saveView(); - } catch { busy = false; graph.replaceChildren(); setStatus(labels.error); } + } catch { busy = false; committed = undefined; graph.replaceChildren(); setStatus(labels.error); } } drawing = false; } + async function exportGraph(format) { + if (busy || !committed) return; + const original = graph.querySelector('svg'); if (!original) return; + const fixedData = data, clone = original.cloneNode(true), sourceNodes = [original, ...original.querySelectorAll('*')], targetNodes = [clone, ...clone.querySelectorAll('*')]; + const properties = ['fill','stroke','stroke-width','stroke-dasharray','opacity','font-family','font-size','font-weight','text-anchor','dominant-baseline','marker-end','marker-start','paint-order']; + sourceNodes.forEach((node, index) => { + const style = getComputedStyle(node), target = targetNodes[index]; target.removeAttribute('style'); target.removeAttribute('class'); + for (const property of properties) { + const value = style.getPropertyValue(property).replace(/url\(["']?[^)#]*#([^)'" ]+)["']?\)/g, 'url(#$1)'); + if (value) target.style.setProperty(property, value); + } + for (const attribute of [...target.attributes]) if (/^on|^data-|^tabindex$|^role$|^aria-/.test(attribute.name)) target.removeAttribute(attribute.name); + }); + clone.querySelectorAll('style,script,foreignObject').forEach(node => node.remove()); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(baseWidth)); clone.setAttribute('height', String(baseHeight)); + const background = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + const box = original.viewBox.baseVal; background.setAttribute('x', box.x); background.setAttribute('y', box.y); background.setAttribute('width', box.width); background.setAttribute('height', box.height); background.setAttribute('fill', getComputedStyle(document.body).backgroundColor || '#fff'); clone.insertBefore(background, clone.firstChild); + const description = document.createElementNS('http://www.w3.org/2000/svg', 'desc'); description.textContent = [fixedData.name, fixedData.limited ? labels.limited : '', fixedData.truncated ? labels.partial : ''].filter(Boolean).join(' · '); clone.prepend(description); + const svgText = new XMLSerializer().serializeToString(clone); + const deliver = payload => vscode.postMessage({ type: 'action', action: 'export', viewKey: fixedData.viewKey, format, data: payload }); + if (format === 'svg') return deliver(svgText); + let url; + try { + const ratio = Math.min(2, 8192 / baseWidth, 8192 / baseHeight, Math.sqrt(16000000 / (baseWidth * baseHeight))); + const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.floor(baseWidth * ratio)); canvas.height = Math.max(1, Math.floor(baseHeight * ratio)); + url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' })); + const img = new Image(); img.src = url; await img.decode(); canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + const png = canvas.toDataURL('image/png').split(',')[1]; canvas.width = canvas.height = 0; + deliver(png); + } catch { setStatus(labels.exportError); } finally { if (url) URL.revokeObjectURL(url); } + } + document.querySelectorAll('[data-export]').forEach(button => button.addEventListener('click', () => { closeMenus(); void exportGraph(button.dataset.export); })); window.addEventListener('message', event => { const message = event.data; - if (message.type === 'graph') { pending = message; draw(); } + if (message.type === 'snapshot') { snapshots.delete(message.viewKey); snapshots.set(message.viewKey, message); trim(snapshots); if (message.viewKey === currentKey) options(); } + if (message.type === 'graph') { pending = message; void draw(); } if (message.type === 'loading') { - saveView(); currentKey = message.viewKey; baseWidth = 0; data = undefined; pending = undefined; busy = true; + saveView(); currentKey = message.viewKey; committed = undefined; baseWidth = 0; data = undefined; pending = undefined; busy = true; document.getElementById('project-name').textContent = message.name; - document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === message.mode))); search.value = ''; selectNode(undefined); setStatus(labels.loading, true); } - if (message.type === 'error') { busy = false; setStatus(message.error, true); } + if (message.type === 'error') { busy = false; setStatus(message.error, !data); } if (message.type === 'copied') { const feedback = document.getElementById('feedback'); feedback.textContent = labels.copied; setTimeout(() => feedback.textContent = '', 1800); } }); - new MutationObserver(() => { if (data) { pending = data; draw(); } }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); - window.addEventListener('resize', () => applyView()); + new MutationObserver(() => { if (data) { pending = data; void draw(); } }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + document.addEventListener('visibilitychange', () => { if (!document.hidden) void draw(); }); + window.addEventListener('resize', applyView); selectNode(undefined); setStatus(labels.loading, true); vscode.postMessage({ type: 'ready' }); })(); diff --git a/package-lock.json b/package-lock.json index 0c4291b..6438c3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.8.0", + "version": "0.9.0", "license": "Apache-2.0", "devDependencies": { "@mermaid-js/tiny": "12.2.0", @@ -18,6 +18,7 @@ "@vscode/vsce": "^3.9.2", "mocha": "^11.8.0", "ovsx": "^1.2.0", + "playwright": "1.64.1", "typescript": "^5.9.0" }, "engines": { @@ -4861,6 +4862,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 36a6050..545a67f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "%displayName%", "description": "%description%", - "version": "0.8.0", + "version": "0.9.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "media/logo.png", @@ -1184,7 +1184,8 @@ "check:icon": "node tools/generate-activitybar-icon.mjs --check && node tools/generate-quick-menu-icons.mjs --check", "check:generated": "node tools/check-generators.mjs", "test:e2e:one": "node dist/test/e2e/runTest.js", - "gen:examples": "node tools/generate-library-examples.mjs" + "gen:examples": "node tools/generate-library-examples.mjs", + "test:graphs-ui": "npm run compile && node tools/test-graphs-ui.mjs" }, "devDependencies": { "@mermaid-js/tiny": "12.2.0", @@ -1196,6 +1197,7 @@ "@vscode/vsce": "^3.9.2", "mocha": "^11.8.0", "ovsx": "^1.2.0", + "playwright": "1.64.1", "typescript": "^5.9.0" } } diff --git a/src/cli/moduleGraph.ts b/src/cli/moduleGraph.ts index 9f43c03..c7c60d6 100644 --- a/src/cli/moduleGraph.ts +++ b/src/cli/moduleGraph.ts @@ -2,9 +2,9 @@ import { runProcess, type ProcessRunner } from "./process"; import { parseModuleGraph, type ModuleGraphJson } from "../projects/moduleGraph"; /** Use mcppls's resolved graph; never launch a second C++ parser in this extension. */ -export async function queryModuleGraph(trusted: boolean, executable: string, root: string, runner: ProcessRunner = runProcess): Promise { +export async function queryModuleGraph(trusted: boolean, executable: string, root: string, runner: ProcessRunner = runProcess, signal?: AbortSignal): Promise { if (!trusted) { throw new Error("Trust this workspace before reading its module graph."); } - const result = await runner(executable, ["query", "module", "--graph", "--root", root, "--timeout", "20", "--format", "json"], root, { timeoutMs: 30_000, maxBufferMiB: 8 }); + const result = await runner(executable, ["query", "module", "--graph", "--root", root, "--timeout", "20", "--format", "json"], root, { timeoutMs: 30_000, maxBufferMiB: 8, ...(signal ? { signal } : {}) }); if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || "mcppls module graph query failed"); } return parseModuleGraph(result.stdout); } diff --git a/src/cli/process.ts b/src/cli/process.ts index a0c58a8..304d999 100644 --- a/src/cli/process.ts +++ b/src/cli/process.ts @@ -10,6 +10,7 @@ export interface ProcessResult { } export interface ProcessRunOptions { + signal?: AbortSignal; timeoutMs?: number; /** `mcpp.runtime.maxOutputMiB`; the default matches what the extension shipped before it was configurable. */ maxBufferMiB?: number; @@ -68,6 +69,7 @@ export async function runProcess( encoding: "utf8", maxBuffer: Math.max(1, options.maxBufferMiB ?? 16) * 1024 * 1024, timeout: options.timeoutMs, + signal: options.signal, ...(needsShell ? { shell: true } : {}), }, ); diff --git a/src/graphs/export.ts b/src/graphs/export.ts new file mode 100644 index 0000000..4693dc7 --- /dev/null +++ b/src/graphs/export.ts @@ -0,0 +1,18 @@ +/** Validate untrusted webview bytes before writing a user-selected destination. */ +export function decodeGraphExport(format: unknown, data: unknown): { extension: string; bytes: Uint8Array } { + if (typeof data !== "string" || data.length > 32 * 1024 * 1024) { throw new Error("Invalid graph export size"); } + if (format === "svg") { + const normalized = data.replace(/"|&#(?:34|x22);/gi, '\"').replace(/'|&#(?:39|x27);/gi, "'"); + if (!/^ 8192 || height > 8192 || width * height > 16_000_000) { throw new Error("PNG export exceeds pixel limit"); } + return { extension: "png", bytes }; + } + throw new Error("Unsupported graph export format"); +} diff --git a/src/graphs/model.ts b/src/graphs/model.ts index a998b59..06d0ef5 100644 --- a/src/graphs/model.ts +++ b/src/graphs/model.ts @@ -1,30 +1,33 @@ -/** Shared presentation model; providers own package/module semantics and source access. */ +/** Provider-independent graph. Containment edges belong only to presentation. */ export interface VisualNode { - id: string; - label: string; - lines: string[]; + id: string; label: string; lines: string[]; kind: "project" | "manifest" | "index" | "unknown"; - files?: string[]; - entry?: boolean; + files?: string[]; entry?: boolean; virtual?: boolean; +} +export interface VisualEdge { from: string; to: string; label?: string; dashed?: boolean; containment?: boolean; } +export interface GraphGroup { id: string; label: string; nodes: string[]; } +export interface VisualGraph { nodes: VisualNode[]; edges: VisualEdge[]; roots: string[]; groups?: GraphGroup[]; truncated?: boolean; } +export interface GraphExpansion { + depth: number; expanded: ReadonlySet; collapsed: ReadonlySet; focus?: string; + upstreamDepth?: number; localDepth?: ReadonlyMap; + upstreamLocalDepth?: ReadonlyMap; upstreamCollapsed?: ReadonlySet; +} +export interface VisibleGraph extends VisualGraph { + hidden: Map; hiddenParents: Map; remainingDepth: Map; + upstreamRemaining: Map; limited: boolean; } -export interface VisualEdge { from: string; to: string; label?: string; dashed?: boolean; } -export interface VisualGraph { nodes: VisualNode[]; edges: VisualEdge[]; roots: string[]; truncated?: boolean; } -export interface GraphExpansion { depth: number; expanded: ReadonlySet; collapsed: ReadonlySet; focus?: string; } -export interface VisibleGraph extends VisualGraph { hidden: Map; limited: boolean; } export const GRAPH_BUDGET = { nodes: 150, edges: 300 } as const; +export const INITIAL_GRAPH_BUDGET = { nodes: 60, edges: 80 } as const; -/** Choose a root for every directed component, including disconnected cycles. */ export function graphRoots(nodes: readonly VisualNode[], edges: readonly VisualEdge[], preferred: string[] = []): string[] { - const adj = adjacency(edges), incoming = new Set(edges.map(edge => edge.to)); + const adj = new Map(), incoming = new Set(edges.map(edge => edge.to)); + for (const edge of edges) { const list = adj.get(edge.from) ?? []; list.push(edge.to); adj.set(edge.from, list); } const roots: string[] = [], seen = new Set(), known = new Set(nodes.map(node => node.id)); const visit = (id: string): void => { if (!known.has(id) || seen.has(id)) { return; } - roots.push(id); - const stack = [id]; + roots.push(id); const stack = [id]; while (stack.length) { - const next = stack.pop()!; - if (seen.has(next)) { continue; } - seen.add(next); + const next = stack.pop()!; if (seen.has(next)) { continue; } seen.add(next); for (const child of adj.get(next) ?? []) { stack.push(child); } } }; @@ -33,51 +36,92 @@ export function graphRoots(nodes: readonly VisualNode[], edges: readonly VisualE for (const node of nodes) { visit(node.id); } return roots; } -function adjacency(edges: readonly VisualEdge[]): Map { - const result = new Map(); - for (const edge of edges) { - const list = result.get(edge.from) ?? []; - list.push(edge.to); result.set(edge.from, list); + +/** Cached per immutable provider snapshot; camera/selection never rebuild these indexes. */ +const indexes = new WeakMap; out: Map; incoming: Map }>(); +function graphIndex(graph: VisualGraph) { + let index = indexes.get(graph); + if (!index) { + index = { nodes: new Map(graph.nodes.map(node => [node.id, node])), out: new Map(), incoming: new Map() }; + for (const edge of graph.edges) { + const out = index.out.get(edge.from) ?? []; out.push(edge); index.out.set(edge.from, out); + const incoming = index.incoming.get(edge.to) ?? []; incoming.push(edge); index.incoming.set(edge.to, incoming); + } + indexes.set(graph, index); } - return result; + return index; } -/** Bound the subgraph BEFORE layout. Breadth first keeps wide and deep graphs navigable. */ +/** Nodes enter with their connecting edge, so an edge budget cannot strand new nodes. */ export function visibleGraph(graph: VisualGraph, state: GraphExpansion, budget: { nodes: number; edges: number } = GRAPH_BUDGET): VisibleGraph { - const adj = adjacency(graph.edges), known = new Set(graph.nodes.map(node => node.id)); - const seen = new Set(), remaining = new Map(); - const queue: Array<[string, number]> = []; + const index = graphIndex(graph), seen = new Set(), accepted = new Set(); + const remainingDepth = new Map(), upstreamRemaining = new Map(); let limited = false; - const add = (id: string, depth: number): void => { - if (!known.has(id)) { return; } - if (!seen.has(id)) { - if (seen.size >= budget.nodes) { limited = true; return; } - seen.add(id); + const addRoot = (id: string): boolean => { + if (!index.nodes.has(id)) { return false; } + if (!seen.has(id) && seen.size >= budget.nodes) { limited = true; return false; } + seen.add(id); return true; + }; + const walk = (seeds: string[], upstream: boolean, base: number, nodeLimit: number, edgeLimit = budget.edges): void => { + const remaining = upstream ? upstreamRemaining : remainingDepth; + const local = upstream ? state.upstreamLocalDepth : state.localDepth; + const collapsed = upstream ? state.upstreamCollapsed : state.collapsed; + const queue: Array<[string, number]> = seeds.map(id => [id, base]); + for (let cursor = 0; cursor < queue.length; cursor++) { + const [id, depth] = queue[cursor]; + let effective = Math.max(depth, local?.get(id) ?? 0); + if (!upstream && state.expanded.has(id)) { effective = Math.max(effective, 1); } + if ((remaining.get(id) ?? -1) >= effective) { continue; } + remaining.set(id, effective); + if (collapsed?.has(id)) { continue; } + for (const edge of (upstream ? index.incoming : index.out).get(id) ?? []) { + if (upstream && edge.containment) { continue; } + if (effective <= 0 && !edge.containment) { continue; } + const next = upstream ? edge.from : edge.to; + if (!index.nodes.has(next)) { continue; } + if ((!accepted.has(edge) && accepted.size >= edgeLimit) || (!seen.has(next) && seen.size >= nodeLimit)) { limited = true; continue; } + accepted.add(edge); seen.add(next); + queue.push([next, edge.containment ? effective : Math.max(0, effective - 1)]); + } } - if ((remaining.get(id) ?? -1) >= depth) { return; } - remaining.set(id, depth); queue.push([id, depth]); }; - if (state.focus && known.has(state.focus)) { - add(state.focus, state.depth); - // Context includes direct importers without recursively expanding their other dependencies. - for (const edge of graph.edges) { if (edge.to === state.focus) { add(edge.from, 0); } } - } else { for (const root of graph.roots) { add(root, state.depth); } } - for (let cursor = 0; cursor < queue.length; cursor++) { - const [id, depth] = queue[cursor]; - if (state.collapsed.has(id) || (depth <= 0 && !state.expanded.has(id))) { continue; } - for (const child of adj.get(id) ?? []) { add(child, Math.max(0, depth - 1)); } + const focused = state.focus && index.nodes.has(state.focus) ? state.focus : undefined; + if (focused) { + addRoot(focused); + // Reserve parent context, but never let a high fan-in consume the whole view. + const parentBudget = Math.min(20, Math.max(0, Math.floor((budget.nodes - 1) / 3))); + walk([focused], true, state.upstreamDepth ?? 1, 1 + parentBudget, Math.floor(budget.edges / 3)); + walk([focused], false, state.depth, budget.nodes); + // Explicit local exploration also works on nodes first reached from the other direction. + for (const id of state.localDepth?.keys() ?? []) { if (seen.has(id)) { walk([id], false, 0, budget.nodes); } } + for (const id of state.upstreamLocalDepth?.keys() ?? []) { if (seen.has(id)) { walk([id], true, 0, budget.nodes); } } + } else { + // Process a component before admitting more roots; many tests cannot starve every edge. + if (graph.roots.every(id => index.nodes.get(id)?.virtual)) { for (const root of graph.roots) { addRoot(root); } } + for (const root of graph.roots) { if (addRoot(root)) { walk([root], false, state.depth, budget.nodes); } } } - const candidates = graph.edges.filter(edge => seen.has(edge.from) && seen.has(edge.to) && !state.collapsed.has(edge.from)); - const edges = candidates.slice(0, budget.edges); - limited ||= candidates.length > edges.length; - const rendered = new Map>(); - for (const edge of edges) { const list = rendered.get(edge.from) ?? new Set(); list.add(edge.to); rendered.set(edge.from, list); } - const hidden = new Map(); + const hidden = new Map(), hiddenParents = new Map(); for (const id of seen) { - const missing = new Set((adj.get(id) ?? []).filter(child => !rendered.get(id)?.has(child))); - if (missing.size) { hidden.set(id, missing.size); } + const children = new Set((index.out.get(id) ?? []).filter(edge => !accepted.has(edge)).map(edge => edge.to)); + const parents = new Set((index.incoming.get(id) ?? []).filter(edge => !edge.containment && !accepted.has(edge)).map(edge => edge.from)); + if (children.size) { hidden.set(id, children.size); } + if (parents.size) { hiddenParents.set(id, parents.size); } + } + return { nodes: graph.nodes.filter(node => seen.has(node.id)), edges: graph.edges.filter(edge => accepted.has(edge)), roots: focused ? [focused] : graph.roots, + truncated: graph.truncated, hidden, hiddenParents, remainingDepth, upstreamRemaining, limited }; +} + +/** Presentation groups page entries without consuming import depth. */ +export function groupedGraph(graph: VisualGraph, activeGroup?: string, offset = 0): VisualGraph { + if (!graph.groups?.length) { return graph; } + const groups = graph.groups; + const nodes: VisualNode[] = groups.map(group => ({ id: group.id, label: group.label, lines: [String(group.nodes.length)], kind: "manifest", virtual: true })); + const edges: VisualEdge[] = []; + for (const group of groups) { + if (activeGroup ? group.id !== activeGroup : group.id !== groups[0].id) { continue; } + for (const id of group.nodes.slice(offset, offset + 20)) { edges.push({ from: group.id, to: id, dashed: true, containment: true }); } } - return { nodes: graph.nodes.filter(node => seen.has(node.id)), edges, roots: graph.roots, truncated: graph.truncated, hidden, limited }; + return { ...graph, nodes: [...nodes, ...graph.nodes], edges: [...edges, ...graph.edges], roots: groups.map(group => group.id) }; } export function mermaidGraph(graph: VisibleGraph): { source: string; nodeIds: string[] } { diff --git a/src/graphs/panelHtml.ts b/src/graphs/panelHtml.ts index 6b9e34c..73d3d24 100644 --- a/src/graphs/panelHtml.ts +++ b/src/graphs/panelHtml.ts @@ -8,20 +8,35 @@ export function graphPanelHtml(assets: WebviewAssets, renderer: string, script: loading: t("Reading project dependencies…"), empty: t("No module relationships were found for this project."), limited: t("View limit reached. Focus a node to explore its dependencies."), partial: t("The source graph is incomplete. Some modules may be omitted."), + exportError: t("Could not export this graph. Try SVG or a smaller view."), focus: t("Focus"), + group: t("Entry group"), selectedPrefix: t("Selected"), expand: t("Expand next level"), selected: t("Selected node"), }; const button = (action: string, label: string): string => ``; return ` - +

${escapeHtml(t("Dependency graph"))}

-
+
- + - + +
${escapeHtml(t("View options"))}
+ + + ${button("locateFocus", t("Locate focus"))} +
-
${escapeHtml(t("Select a node to explore its relationships."))}
${button("expand", t("Expand next level"))}${button("collapse", t("Collapse"))}${button("focus", t("Focus"))}
+
${escapeHtml(t("Select a node to explore its relationships."))}
+
${button("focus", t("Focus"))}${button("locate", t("Locate selected"))} +
${escapeHtml(t("Node actions"))}
+ ${button("expand", t("Expand next level"))}${button("collapse", t("Collapse"))}${button("expandParents", t("Expand importers"))}${button("collapseParents", t("Collapse importers"))}${button("open", t("Open source"))} +
+
diff --git a/src/graphs/refresh.ts b/src/graphs/refresh.ts new file mode 100644 index 0000000..ce8e100 --- /dev/null +++ b/src/graphs/refresh.ts @@ -0,0 +1,44 @@ +/** Event bursts coalesce into one cancellable query and at most one pending rerun. */ +export class RefreshCoordinator { + private timer?: ReturnType; + private started = 0; + private dirty = false; + private flight?: Promise; + private controller?: AbortController; + private disposed = false; + public visible = true; + public constructor(private readonly refresh: (signal: AbortSignal) => Promise, private readonly delay = 200, private readonly maxWait = 1000) {} + public request(immediate = false): Promise { + if (this.disposed) { return Promise.resolve(); } + this.dirty = true; + if (!this.visible) { return Promise.resolve(); } + if (this.flight) { return this.flight; } + if (!this.started) { this.started = Date.now(); } + if (this.timer) { clearTimeout(this.timer); } + if (immediate) { return this.run(); } + this.timer = setTimeout(() => { void this.run(); }, Math.min(this.delay, Math.max(0, this.maxWait - (Date.now() - this.started)))); + return Promise.resolve(); + } + public setVisible(visible: boolean): void { + this.visible = visible; + if (!visible && this.timer) { clearTimeout(this.timer); this.timer = undefined; } + if (visible && this.dirty) { void this.request(true); } + } + private run(): Promise { + if (this.flight || this.disposed || !this.visible) { return this.flight ?? Promise.resolve(); } + if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } + this.started = 0; this.dirty = false; + const controller = new AbortController(); this.controller = controller; + this.flight = Promise.resolve().then(() => this.refresh(controller.signal)).finally(() => { + this.flight = undefined; + if (this.controller === controller) { this.controller = undefined; } + if (this.dirty && !this.disposed && this.visible) { void this.request(); } + }); + return this.flight; + } + public cancel(): void { + this.controller?.abort(); this.dirty = false; this.started = 0; + if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } + } + public dispose(): void { this.disposed = true; this.cancel(); } +} diff --git a/src/projects/dependencyGraphPanel.ts b/src/projects/dependencyGraphPanel.ts index 1b0c54e..4097a40 100644 --- a/src/projects/dependencyGraphPanel.ts +++ b/src/projects/dependencyGraphPanel.ts @@ -1,5 +1,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; +import { createHash } from "node:crypto"; import * as vscode from "vscode"; import { TOOL_COMMANDS } from "../commands/ids"; import { t } from "../i18n/t"; @@ -10,21 +11,28 @@ import type { ProjectSummary } from "../views/models"; import { WebviewDocument } from "../webview/document"; import { ReadOnlyDocuments } from "../webview/readOnlyDocuments"; import { graphPanelHtml } from "../graphs/panelHtml"; -import { mermaidGraph, visibleGraph, type VisualGraph } from "../graphs/model"; +import { groupedGraph, INITIAL_GRAPH_BUDGET, mermaidGraph, visibleGraph, type VisibleGraph, type VisualGraph } from "../graphs/model"; +import { RefreshCoordinator } from "../graphs/refresh"; +import { decodeGraphExport } from "../graphs/export"; import { readProjectSummary } from "./summary"; import { buildDependencyGraph } from "./dependencyGraph"; -import { buildModuleGraph, moduleEntries, moduleOwnership, sourcePath } from "./moduleGraph"; +import { buildModuleGraph, moduleEntries, moduleOwnership, sourcePath, testDiscovery, isTestSource } from "./moduleGraph"; interface ViewState { root: string; mode: "packages" | "modules"; name: string; graph?: VisualGraph; dependencyTargets?: Map; dependency?: string; dependencyKind?: string; + metrics: { reads: number; projections: number }; + coordinator?: RefreshCoordinator; dirty?: boolean; revision?: string; sentRevision?: string; retry?: ReturnType; attempts?: number; + visible?: VisibleGraph; group?: string; offset: number; initialized?: boolean; + history: Array<{ focus?: string; group?: string; offset: number; selected?: string; depth: number; upstreamDepth: number; localDepth: Map; collapsed: Set; upstreamLocalDepth: Map; upstreamCollapsed: Set }>; + upstreamDepth: number; localDepth: Map; upstreamLocalDepth: Map; upstreamCollapsed: Set; depth: number; expanded: Set; collapsed: Set; selected?: string; focus?: string; } const SOURCE_SCHEME = "mcpp-graph-source"; /** Two providers, one persistent graph document. Expansion never re-runs the CLI. */ export function registerDependencyGraph(context: vscode.ExtensionContext, project: () => ProjectSummary | undefined): void { - let panel: vscode.WebviewPanel | undefined, current: ViewState | undefined, source = "", generation = 0, ready = false, loading = false; + let panel: vscode.WebviewPanel | undefined, current: ViewState | undefined, source = "", generation = 0, publication = 0, ready = false, loading = false; const views = new Map(); const document = new WebviewDocument("dependencyGraph.css"), media = vscode.Uri.joinPath(context.extensionUri, "media"), vendor = vscode.Uri.joinPath(context.extensionUri, "dist", "vendor"); const sourceChanged = new vscode.EventEmitter(); @@ -35,21 +43,34 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec const read = (file: string): Promise => fs.readFile(file, "utf8").catch(() => undefined); const publish = async (center = false, fit = false): Promise => { - if (!ready || !panel || !current?.graph) { return; } - const graph = visibleGraph(current.graph, current), rendered = mermaidGraph(graph); + const view = current, target = panel, version = ++publication; + if (!ready || !target?.visible || !view?.graph) { return; } + view.metrics.projections++; + const presentation = view.focus ? view.graph : groupedGraph(view.graph, view.group, view.offset); + const graph = visibleGraph(presentation, view, INITIAL_GRAPH_BUDGET), rendered = mermaidGraph(graph); + view.visible = graph; + if (view.sentRevision !== view.revision) { + await target.webview.postMessage({ type: "snapshot", viewKey: viewKey(view), + search: view.graph.nodes.map(node => ({ id: node.id, label: node.label })), + groups: view.graph.groups ?? [] }); + view.sentRevision = view.revision; + } + if (current !== view || panel !== target || version !== publication) { return; } source = rendered.source; - await panel.webview.postMessage({ type: "graph", viewKey: viewKey(current), mode: current.mode, name: current.name, depth: current.depth === Infinity ? "all" : String(current.depth), - source, nodes: graph.nodes.map(node => ({ id: node.id, label: node.label, hidden: graph.hidden.get(node.id) ?? 0 })), - search: current.graph.nodes.map(node => ({ id: node.id, label: node.label })), - selected: current.selected, focus: current.focus, center, fit, limited: graph.limited, truncated: graph.truncated }); + await target.webview.postMessage({ type: "graph", viewKey: viewKey(view), mode: view.mode, name: view.name, + depth: view.depth === Infinity ? "all" : String(view.depth), upstreamDepth: String(view.upstreamDepth), + source, nodes: graph.nodes.map(node => ({ id: node.id, label: node.label, hidden: graph.hidden.get(node.id) ?? 0, parents: graph.hiddenParents.get(node.id) ?? 0, virtual: !!node.virtual })), + selected: view.selected, focus: view.focus, group: view.group, offset: view.offset, + debug: vscode.workspace.getConfiguration?.("mcpp").get("log.level") === "debug" ? view.metrics : undefined, + back: !!view.history.length, center, fit, limited: graph.limited, truncated: graph.truncated }); }; - const load = async (refresh = false): Promise => { - if (!current || !panel || !ready) { return; } - const view = current, target = panel, request = ++generation; - if (view.graph && !refresh) { loading = false; await publish(); return; } - loading = true; - await target.webview.postMessage({ type: "loading", viewKey: viewKey(view), mode: view.mode, name: view.name }); + const readGraph = async (view: ViewState, signal?: AbortSignal): Promise => { + if (view !== current || !panel || !ready || signal?.aborted) { return; } + const target = panel, request = ++generation; + loading = !view.graph; + if (!view.graph) { await target.webview.postMessage({ type: "loading", viewKey: viewKey(view), mode: view.mode, name: view.name }); } try { + view.metrics.reads++; const manifest = await read(path.join(view.root, "mcpp.toml")); if (manifest === undefined) { throw new Error(t("The project manifest is missing or unreadable.")); } view.name = readProjectSummary(view.root, manifest.split(/\r?\n/)).name ?? path.basename(view.root); @@ -58,8 +79,16 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec const extension = vscode.extensions.getExtension("sunrisepeak.mcpp-language-server"); const bundled = extension ? path.join(extension.extensionPath, "payload", "bin", process.platform === "win32" ? "mcppls.exe" : "mcppls") : undefined; const executable = bundled && await exists(bundled) ? bundled : "mcppls"; - const raw = await queryModuleGraph(vscode.workspace.isTrusted, executable, view.root); - const entries = moduleEntries(view.root, manifest, view.name); + const raw = await queryModuleGraph(vscode.workspace.isTrusted, executable, view.root, undefined, signal); + const patterns = testDiscovery(manifest); + const discovered = new Set(); let discoveryLimited = false; + for (const pattern of patterns.filter(pattern => !pattern.startsWith("!"))) { + // Search is only a candidate generator; the pure mcpp matcher decides membership. + const candidates = await vscode.workspace.findFiles(new vscode.RelativePattern(view.root, pattern), "**/{.git,.mcpp,node_modules,build}/**", 10000, signal ? { isCancellationRequested: signal.aborted, onCancellationRequested: listener => { const abort = () => listener(undefined); signal.addEventListener("abort", abort); return { dispose: () => signal.removeEventListener("abort", abort) }; } } : undefined); + discoveryLimited ||= candidates.length >= 10000; + for (const file of candidates) { if (isTestSource(path.relative(view.root, file.fsPath).split(path.sep).join("/"), patterns)) { discovered.add(file.fsPath); } } + } + const entries = moduleEntries(view.root, manifest, view.name, [...discovered]); const files = new Set(raw.modules.flatMap(node => node.files.map(file => sourcePath(view.root, file)))); const names = new Set(raw.modules.map(node => node.name)); const importers = new Set(raw.imports.filter(edge => !names.has(edge.from)).map(edge => sourcePath(view.root, edge.from))); @@ -68,6 +97,7 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec for (const file of importers) { if (await exists(file)) { files.add(file); } } for (const file of entries.keys()) { if (await exists(file)) { files.add(file); } } graph = buildModuleGraph(view.root, raw, await moduleOwnership(view.root, [...files], exists), entries); + graph.truncated ||= discoveryLimited; } else { const [snapshot, lock] = await Promise.all([loadSnapshot({ projectRoot: view.root }).catch(() => undefined), read(path.join(view.root, "mcpp.lock"))]); const locked = lockPackageVersions(lock ?? ""), entries = snapshot?.entries ?? []; @@ -92,22 +122,52 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec }) }; } if (panel !== target || current !== view || request !== generation) { return; } - view.graph = graph; + if (graph.groups) { graph.groups = graph.groups.map(group => ({ ...group, label: t(group.label) })); } + if (signal?.aborted) { return; } + view.dirty = false; + const revision = createHash("sha256").update(JSON.stringify(graph)).digest("hex"); + const unchanged = revision === view.revision; + view.graph = unchanged ? view.graph : graph; view.revision = revision; + if (!view.initialized) { + const activeFile = vscode.window.activeTextEditor?.document.uri.fsPath; + const entry = graph.groups?.flatMap(group => group.nodes).find(id => graph.nodes.find(node => node.id === id)?.files?.includes(activeFile ?? "")); + view.focus ??= entry ?? graph.groups?.[0]?.nodes[0]; + view.selected ??= view.focus; view.initialized = true; + } + if (view.focus && !graph.nodes.some(node => node.id === view.focus)) { view.focus = graph.groups?.[0]?.nodes[0]; } + if (view.selected && !graph.nodes.some(node => node.id === view.selected)) { view.selected = view.focus; } + if (view.retry) { clearTimeout(view.retry); view.retry = undefined; } + if (graph.truncated && view.mode === "modules" && !unchanged && (view.attempts ?? 0) < 4) { + const wait = [2000, 5000, 10000, 20000][view.attempts ?? 0]; view.attempts = (view.attempts ?? 0) + 1; + view.retry = setTimeout(() => { view.dirty = true; if (current === view && panel?.visible) { void load(true); } }, wait); + } if (view.dependency) { view.selected = view.dependencyTargets?.get(`${view.dependency}:${view.dependencyKind ?? "normal"}`) ?? view.selected; view.focus = view.selected; view.dependency = undefined; } - loading = false; await publish(!!view.selected); + loading = false; await publish(); } catch (error) { if (panel !== target || current !== view || request !== generation) { return; } + if (signal?.aborted) { return; } loading = false; await target.webview.postMessage({ type: "error", error: t("Could not read project dependencies: {0}", String(error)) }); } }; + const load = async (refresh = false): Promise => { + const view = current; + if (!view || !ready || !panel?.visible) { return; } + if (view.graph && !refresh && !view.dirty) { await publish(); return; } + view.coordinator ??= new RefreshCoordinator(async signal => { await readGraph(view, signal); }); + view.coordinator.visible = true; + await view.coordinator.request(true); + }; const chooseView = (root: string, mode: ViewState["mode"]): ViewState => { const key = `${mode}:${root}`; let view = views.get(key); - if (!view) { view = { root, mode, name: path.basename(root), depth: 3, expanded: new Set(), collapsed: new Set() }; views.set(key, view); } + if (!view) { view = { root, mode, name: path.basename(root), depth: 3, upstreamDepth: mode === "modules" ? 2 : 0, offset: 0, metrics: { reads: 0, projections: 0 }, history: [], localDepth: new Map(), upstreamLocalDepth: new Map(), upstreamCollapsed: new Set(), expanded: new Set(), collapsed: new Set() }; views.set(key, view); + if (views.size > 8) { const oldest = views.keys().next().value!; views.get(oldest)?.coordinator?.dispose(); const retry = views.get(oldest)?.retry; if (retry) { clearTimeout(retry); } views.delete(oldest); } + } + views.delete(key); views.set(key, view); return view; }; const openSource = async (view: ViewState, id: string): Promise => { @@ -120,6 +180,7 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec const file = files.length === 1 ? files[0] : (await vscode.window.showQuickPick(files.map(file => ({ label: path.basename(file), description: path.relative(view.root, file), file })), { placeHolder: t("Choose a module source file") }))?.file; if (!file || current !== view) { return; } const text = await read(file); + if (current !== view) { return; } if (text === undefined) { void vscode.window.showInformationMessage(t("Source is unavailable for this module.")); return; } const uri = vscode.Uri.parse(sources.capture({ path: file, text })); await vscode.window.showTextDocument(uri, { viewColumn: vscode.ViewColumn.Beside, preview: true, preserveFocus: true }); @@ -129,22 +190,55 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec const value = message as Record; if (value.type === "ready") { ready = true; await load(); return; } if (value.type !== "action" || !current || value.viewKey !== viewKey(current)) { return; } - if (value.action === "mode" && (value.mode === "packages" || value.mode === "modules")) { current = chooseView(current.root, value.mode); await load(); return; } + if (value.action === "mode" && (value.mode === "packages" || value.mode === "modules")) { current.coordinator?.cancel(); current = chooseView(current.root, value.mode); await load(); return; } if (value.action === "refresh") { await load(true); return; } if (value.action === "manifest") { await vscode.window.showTextDocument(vscode.Uri.file(path.join(current.root, "mcpp.toml"))); return; } if (loading || !current.graph) { return; } if (value.action === "copy") { await vscode.env.clipboard.writeText(source); await panel?.webview.postMessage({ type: "copied" }); return; } - if (value.action === "depth" && ["3", "5", "all"].includes(String(value.depth))) { - current.depth = value.depth === "all" ? Infinity : Number(value.depth); current.expanded.clear(); current.collapsed.clear(); await publish(); return; + if (value.action === "depth" && ["0", "1", "2", "3", "5", "all"].includes(String(value.depth))) { + current.depth = value.depth === "all" ? Infinity : Number(value.depth); current.expanded.clear(); current.collapsed.clear(); current.localDepth.clear(); await publish(); return; + } + if (value.action === "upstreamDepth" && ["0", "1", "2", "3", "5"].includes(String(value.depth))) { current.upstreamDepth = Number(value.depth); current.upstreamLocalDepth.clear(); current.upstreamCollapsed.clear(); await publish(); return; } + const remember = (): void => { + current!.history.push({ focus: current!.focus, group: current!.group, offset: current!.offset, + selected: current!.visible?.nodes.some(node => node.id === current!.selected) ? current!.selected : current!.focus, depth: current!.depth, upstreamDepth: current!.upstreamDepth, + localDepth: new Map(current!.localDepth), collapsed: new Set(current!.collapsed), upstreamLocalDepth: new Map(current!.upstreamLocalDepth), upstreamCollapsed: new Set(current!.upstreamCollapsed) }); + if (current!.history.length > 20) { current!.history.shift(); } + }; + if (value.action === "back") { const previous = current.history.pop(); if (previous) { Object.assign(current, previous); await publish(); } return; } + if (value.action === "overview" || value.action === "group") { + remember(); current.focus = undefined; + current.group = typeof value.group === "string" && current.graph.groups?.some(group => group.id === value.group) ? value.group : undefined; + current.offset = 0; await publish(false, true); return; + } + if (value.action === "page") { + const group = current.graph.groups?.find(group => group.id === current!.group); + if (group) { current.offset = Math.max(0, Math.min(Math.floor((group.nodes.length - 1) / 20) * 20, current.offset + (value.next ? 20 : -20))); await publish(false, true); } return; + } + if (value.action === "export") { + const payload = decodeGraphExport(value.format, value.data); + const destination = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(path.join(current.root, `modules-${current.mode}.${payload.extension}`)), filters: { [payload.extension.toUpperCase()]: [payload.extension] } }); + if (destination) { await vscode.workspace.fs.writeFile(destination, payload.bytes); } return; } - if (value.action === "overview") { current.focus = undefined; await publish(false, true); return; } const id = typeof value.id === "string" ? value.id : undefined; - if (!id || !current.graph.nodes.some(node => node.id === id)) { return; } + if (!id) { return; } + if (current.graph.groups?.some(group => group.id === id)) { + if (["focus", "expand", "open"].includes(String(value.action))) { remember(); current.focus = undefined; current.group = id; current.offset = 0; await publish(false, true); } + return; + } + if (!current.graph.nodes.some(node => node.id === id)) { return; } current.selected = id; + if (value.action === "select") { return; } if (value.action === "open") { await openSource(current, id); return; } - if (value.action === "expand") { current.expanded.add(id); current.collapsed.delete(id); } - else if (value.action === "collapse") { current.collapsed.add(id); current.expanded.delete(id); } - else if (value.action === "focus" || value.action === "search") { current.focus = id; current.collapsed.delete(id); } + if (value.action === "expand" || value.action === "expandParents") { + const upstream = value.action === "expandParents", overrides = upstream ? current.upstreamLocalDepth : current.localDepth; + const remaining = upstream ? current.visible?.upstreamRemaining : current.visible?.remainingDepth; + const depth = Math.max(0, remaining?.get(id) ?? 0, overrides.get(id) ?? 0); + overrides.set(id, Number.isFinite(depth) ? depth + 1 : 1); + (upstream ? current.upstreamCollapsed : current.collapsed).delete(id); + } + else if (value.action === "collapse" || value.action === "collapseParents") { (value.action === "collapseParents" ? current.upstreamCollapsed : current.collapsed).add(id); } + else if (value.action === "focus" || value.action === "search") { remember(); current.focus = id; current.localDepth = new Map(); current.collapsed = new Set(); current.upstreamLocalDepth = new Map(); current.upstreamCollapsed = new Set(); } else { return; } await publish(value.action === "search", value.action === "focus"); }; @@ -152,6 +246,7 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec const requested = argument && typeof argument === "object" ? argument as { root?: unknown; dependency?: unknown; path?: unknown; kind?: unknown } : undefined; const root = typeof requested?.root === "string" ? requested.root : project()?.root; if (!root) { void vscode.window.showInformationMessage(t("Open an mcpp project first.")); return; } + if (current && (current.root !== root || current.mode !== mode)) { current.coordinator?.cancel(); } current = chooseView(root, mode); if (typeof requested?.dependency === "string") { current.dependency = requested.dependency; @@ -163,11 +258,30 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec panel = vscode.window.createWebviewPanel("mcpp.dependencyGraph", t("Project dependency graph"), vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [media, vendor] }); ready = false; panel.webview.onDidReceiveMessage(message => { void receive(message).catch(error => vscode.window.showErrorMessage(String(error))); }); - panel.onDidDispose(() => { panel = undefined; ready = false; generation++; document.invalidate(); }); + panel.onDidChangeViewState(() => { + for (const view of views.values()) { view.coordinator?.setVisible(panel?.visible === true && view === current); } + if (panel?.visible) { void load(); } + }); + panel.onDidDispose(() => { for (const view of views.values()) { view.coordinator?.cancel(); view.sentRevision = undefined; if (view.retry) { clearTimeout(view.retry); } } panel = undefined; ready = false; generation++; document.invalidate(); }); const assets = document.assets(media, panel.webview); document.paint(panel.webview, graphPanelHtml(assets, panel.webview.asWebviewUri(vscode.Uri.joinPath(vendor, "mermaid.js")).toString(), panel.webview.asWebviewUri(vscode.Uri.joinPath(media, "graph.js")).toString())); - } else { panel.reveal(); await load(); if (current?.selected) { await publish(true); } } + } else { panel.reveal(); await load(); } + }; + const invalidate = (uri?: vscode.Uri): void => { + for (const view of views.values()) { + if (uri) { const relative = path.relative(view.root, uri.fsPath); if (relative.startsWith("..") || path.isAbsolute(relative)) { continue; } } + if (uri?.fsPath.endsWith("mcpp.lock") && view.mode === "modules") { continue; } + view.dirty = true; view.attempts = 0; + if (view === current && panel?.visible && ready) { + view.coordinator ??= new RefreshCoordinator(async signal => { await readGraph(view, signal); }); + void view.coordinator.request(); + } + } }; + const watcher = vscode.workspace.createFileSystemWatcher("**/{mcpp.toml,mcpp.lock,*.cpp,*.cc,*.cxx,*.cppm,*.ixx,*.mpp,*.ccm}"); + context.subscriptions.push(watcher, watcher.onDidChange(uri => invalidate(uri)), watcher.onDidCreate(uri => invalidate(uri)), watcher.onDidDelete(uri => invalidate(uri)), + vscode.tasks.onDidEndTaskProcess(event => { const scope = event.execution.task.scope; if (scope && typeof scope === "object") { invalidate(scope.uri); } }), + { dispose: () => { for (const view of views.values()) { view.coordinator?.dispose(); if (view.retry) { clearTimeout(view.retry); } } } }); context.subscriptions.push(vscode.commands.registerCommand(TOOL_COMMANDS.dependencyGraph, argument => open("packages", argument)), vscode.commands.registerCommand(TOOL_COMMANDS.moduleDependencyGraph, argument => open("modules", argument)), { dispose: () => panel?.dispose() }); } diff --git a/src/projects/dependencyState.ts b/src/projects/dependencyState.ts new file mode 100644 index 0000000..ec5856c --- /dev/null +++ b/src/projects/dependencyState.ts @@ -0,0 +1,29 @@ +import { parseMcppToml, type TomlValueNode } from "../toml/parser"; +import type { ProjectSummary } from "../views/models"; + +function completeValue(value: TomlValueNode | undefined): boolean { + if (!value || value.open) { return false; } + if (value.kind === "array") { return (value.elements ?? []).every(completeValue); } + if (value.kind === "inlineTable") { return (value.entries ?? []).every(entry => completeValue(entry.value)); } + // The editor parser models floats, dates and non-decimal integers as unknown scalars. + if (value.kind === "unknown") { return /^(?:[+-]?(?:inf|nan|\d[\d_]*(?:\.[\d_]+)?(?:[eE][+-]?[\d_]+)?)|0[xob][\da-fA-F_]+|\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.test(value.text ?? ""); } + return true; +} + +/** Incomplete edits must not masquerade as a successfully empty dependency list. */ +export function assertCompleteManifest(text: string): void { + const lines = text.split(/\r?\n/), document = parseMcppToml(lines), covered = new Set(); + for (const node of document.nodes) { + if (node.type === "section" ? node.open : !completeValue(node.value)) { throw new Error("The manifest is incomplete"); } + for (let line = node.range.startLine; line <= node.range.endLine; line++) { covered.add(line); } + } + for (let line = 0; line < lines.length; line++) { + if (lines[line].trim() && !lines[line].trimStart().startsWith("#") && !covered.has(line)) { throw new Error("The manifest is incomplete"); } + } +} +export function retainProjectDependencies(next: ProjectSummary | undefined, previous?: ProjectSummary): ProjectSummary | undefined { + if (next?.error && previous?.root === next.root && !previous.error) { + return { ...previous, dependencyState: "stale", dependencyError: next.error }; + } + return next ? { ...next, dependencyState: next.error ? "error" : (next.dependencies?.length ? "ready" : "empty") } : undefined; +} diff --git a/src/projects/moduleGraph.ts b/src/projects/moduleGraph.ts index 350b5dd..089ba3f 100644 --- a/src/projects/moduleGraph.ts +++ b/src/projects/moduleGraph.ts @@ -23,12 +23,18 @@ export function sourcePath(root: string, file: string): string { return file.startsWith("file:") ? fileURLToPath(file) : path.resolve(root, file); } /** Read entry configuration only; C++ syntax and module resolution remain owned by mcppls. */ -export function moduleEntries(root: string, manifest: string, packageName: string): Map { - const entries = new Map(); +export interface ModuleEntries extends Map { roles: Map; } +export function moduleEntries(root: string, manifest: string, packageName: string, candidates: readonly string[] = []): ModuleEntries { + const entries = new Map() as ModuleEntries; entries.roles = new Map(); + let explicitLib = false; + const add = (file: string, label: string, role: "main" | "library" | "test"): void => { + const previous = entries.get(file); + entries.set(file, previous && previous !== label ? `${previous} / ${label}` : label); entries.roles.set(file, role); + }; let hasTargets = false; let section: string[] = [], target: string | undefined, kind: string | undefined, main: string | undefined; const flush = (): void => { - if (target && (!kind || kind === "bin")) { entries.set(path.resolve(root, main ?? "src/main.cpp"), target); } + if (target && (!kind || kind === "bin")) { add(path.resolve(root, main ?? "src/main.cpp"), target, "main"); } target = kind = main = undefined; }; for (const node of parseMcppToml(manifest.split(/\r?\n/)).nodes) { @@ -37,17 +43,51 @@ export function moduleEntries(root: string, manifest: string, packageName: strin if (section.length === 2 && section[0] === "targets") { target = section[1]; hasTargets = true; } } else if (node.value?.kind === "string") { const key = node.keyPath.map(part => part.name).join("."); - if (section.length === 1 && section[0] === "lib" && key === "path") { entries.set(path.resolve(root, node.value.text!), "lib"); } + if (section.length === 1 && section[0] === "lib" && key === "path") { explicitLib = true; add(path.resolve(root, node.value.text!), "lib", "library"); } if (target && key === "kind") { kind = node.value.text; } if (target && key === "main") { main = node.value.text; } } } flush(); - if (![...entries.values()].includes("lib")) { entries.set(path.resolve(root, `src/${packageName.split(".").at(-1)}.cppm`), "lib · default"); } - if (!hasTargets) { entries.set(path.resolve(root, "src/main.cpp"), "default"); } + if (!explicitLib) { add(path.resolve(root, `src/${packageName.split(".").at(-1)}.cppm`), "lib · default", "library"); } + if (!hasTargets) { add(path.resolve(root, "src/main.cpp"), "default", "main"); } + const patterns = testDiscovery(manifest); + for (const file of candidates) { + const relative = path.relative(root, file).split(path.sep).join("/"); + if (isTestSource(relative, patterns)) { add(file, "test · configuration", "test"); } + } return entries; } +/** mcpp path-glob vocabulary: * stays within a segment; ** crosses segments. */ +export function matchesSourceGlob(relative: string, pattern: string): boolean { + let expression = "^"; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] === "*" && pattern[i + 1] === "*") { + expression += ".*"; i++; if (pattern[i + 1] === "/") { i++; } + } else if (pattern[i] === "*") { expression += "[^/]*"; } + else { expression += pattern[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + } + return new RegExp(expression + "$").test(relative); +} +export function testDiscovery(manifest: string): string[] { + let section = ""; + for (const node of parseMcppToml(manifest.split(/\r?\n/)).nodes) { + if (node.type === "section") { section = node.segments.map(segment => segment.name).join("."); } + else if (section === "test" && node.keyPath.map(part => part.name).join(".") === "discover") { + if (node.value?.kind !== "array" || node.value.open || node.value.elements?.some(value => value.kind !== "string" || !value.text)) { + throw new Error("Invalid test discovery patterns"); + } + return node.value.elements?.map(value => value.text!) ?? []; + } + } + return ["tests/**/*.cpp"]; +} +export function isTestSource(relative: string, patterns: readonly string[]): boolean { + return !relative.startsWith("../") && patterns.some(pattern => !pattern.startsWith("!") && matchesSourceGlob(relative, pattern)) + && !patterns.some(pattern => pattern.startsWith("!") && matchesSourceGlob(relative, pattern.slice(1))); +} + /** Map already-resolved source paths to project ownership, including nested path packages. */ export async function moduleOwnership(root: string, files: readonly string[], exists: (file: string) => Promise): Promise> { const owned = new Set(), cache = new Map>(); @@ -87,13 +127,14 @@ export function buildModuleGraph(root: string, raw: ModuleGraphJson, owned: Read nodes.set(edge.from, { id: edge.from, label: path.relative(root, file), lines: [entry ? `entry · ${entry}` : "source"], kind: "project", files: [file], entry: !!entry }); internal.add(edge.from); } + const representedFiles = new Set([...nodes.values()].flatMap(node => node.files ?? [])); for (const [file, entry] of entries) { - if (!owned.has(file) || [...nodes.values()].some(node => node.files?.includes(file))) { continue; } + if (!owned.has(file) || representedFiles.has(file)) { continue; } const id = path.relative(root, file); nodes.set(id, { id, label: id, lines: [`entry · ${entry}`], kind: "project", files: [file], entry: true }); internal.add(id); } - const edges = raw.imports.filter(edge => internal.has(edge.from)).map(edge => ({ from: edge.from, to: edge.to, label: "import" })); + const edges = raw.imports.filter(edge => internal.has(edge.from)).map(edge => ({ from: edge.from, to: edge.to })); for (const edge of edges) { if (!nodes.has(edge.to)) { nodes.set(edge.to, { id: edge.to, label: edge.to, lines: ["unavailable"], kind: "unknown" }); } } const needed = new Set([...internal, ...edges.map(edge => edge.to)]); const retained = [...nodes.values()].filter(node => needed.has(node.id)); @@ -101,5 +142,17 @@ export function buildModuleGraph(root: string, raw: ModuleGraphJson, owned: Read const counts = new Map>(); for (const edge of edges) { const targets = counts.get(edge.from) ?? new Set(); targets.add(edge.to); counts.set(edge.from, targets); } for (const node of retained) { node.lines.push(`imports ${counts.get(node.id)?.size ?? 0}`); } - return { nodes: retained, edges, roots: graphRoots(retained, edges, retained.filter(node => node.entry).map(node => node.id)), truncated: !!raw.truncated || !!raw.snapshot?.indexing || !!raw.snapshot?.preparing }; + const buckets = new Map([["Main", []], ["Library", []], ["Tests", []], ["Other", []]]); + for (const node of retained) { + const entry = node.files?.map(file => entries.get(file)).find(Boolean); + if (entry) { + const role = node.files?.map(file => (entries as Partial).roles?.get(file)).find(Boolean); + buckets.get(role === "test" || (!role && entry.startsWith("test ·")) ? "Tests" : role === "library" || (!role && (entry === "lib" || entry === "lib · default")) ? "Library" : "Main")!.push(node.id); + } + } + const roots = graphRoots(retained, edges, retained.filter(node => node.entry).map(node => node.id)); + const classified = new Set([...buckets.values()].flat()); + buckets.set("Other", roots.filter(id => !classified.has(id))); + const groups = [...buckets].filter(([, ids]) => ids.length).map(([label, ids]) => ({ id: `@group/${label.toLowerCase()}`, label, nodes: ids.sort() })); + return { nodes: retained, edges, roots, groups, truncated: !!raw.truncated || !!raw.snapshot?.indexing || !!raw.snapshot?.preparing }; } diff --git a/src/views/models.ts b/src/views/models.ts index 24040e2..82c1d74 100644 --- a/src/views/models.ts +++ b/src/views/models.ts @@ -110,6 +110,8 @@ export interface ProjectSummary { hasTests?: boolean; /** Declared in `[dependencies]` / `[dev-dependencies]`; absent when there are none. */ dependencies?: readonly DependencyDeclaration[]; + dependencyState?: "loading" | "ready" | "empty" | "stale" | "error"; + dependencyError?: string; /** Source files counted under the project root; absent when not measured. */ sourceFiles?: number; /** Set when `mcpp.toml` could not be read; the tree then says so instead of lying. */ @@ -526,6 +528,7 @@ export function buildProjectTree(project: ProjectSummary | undefined, options: P icon: "error", contextValue: "mcppProjectError", }, + ...dependencyNodes(project, options), ]; } @@ -649,7 +652,10 @@ function commandNodes(options: ProjectTreeOptions): TreeNode[] { function dependencyNodes(project: ProjectSummary, options: ProjectTreeOptions): TreeNode[] { const declared = project.dependencies ?? []; if (declared.length === 0) { - return []; + const failed = project.dependencyState === "error" || project.dependencyState === "stale" || !!project.error; + return [{ id: "project.dependencies", icon: failed ? "warning" : "info", + label: plain(failed ? "Dependencies unavailable — click to retry" : project.dependencyState === "loading" ? "Reading dependencies…" : "This project declares no dependencies"), + ...(failed ? { command: { command: "mcpp.internal.refreshProjectView", title: plain("Refresh") }, tooltip: plain(project.dependencyError ?? project.error ?? "") } : {}) }]; } const lock = options.lock ?? []; const dev = declared.filter((entry) => entry.dev === true).length; @@ -658,9 +664,9 @@ function dependencyNodes(project: ProjectSummary, options: ProjectTreeOptions): id: "project.dependencies", contextValue: "mcppProjectDependencies", label: { key: "Dependencies ({0})", args: [declared.length] }, - ...(dev === 0 ? {} : { description: { key: "{0} dev", args: [dev] } }), + ...(project.dependencyState === "stale" ? { description: plain("Refresh pending") } : dev === 0 ? {} : { description: { key: "{0} dev", args: [dev] } }), icon: "library", - tooltip: plain("Declared in mcpp.toml; the resolved version comes from mcpp.lock."), + tooltip: plain(project.dependencyError ?? "Declared in mcpp.toml; the resolved version comes from mcpp.lock."), expanded: false, children: declared.map((entry) => dependencyNode(entry, lock, project.root)), }, diff --git a/src/views/projectView.ts b/src/views/projectView.ts index 59e5f6f..c6d499c 100644 --- a/src/views/projectView.ts +++ b/src/views/projectView.ts @@ -13,6 +13,8 @@ import path from "node:path"; import * as vscode from "vscode"; +import { RefreshCoordinator } from "../graphs/refresh"; +import { assertCompleteManifest, retainProjectDependencies } from "../projects/dependencyState"; import { readProjectSummary } from "../projects/summary"; import { MCPP_MANIFEST_GLOB } from "../projects/context"; import type { McppProjectDiscovery } from "../projects/discovery"; @@ -52,6 +54,7 @@ export function summariseProject(project: McppProjectDiscovery | undefined): Pro } try { const text = readFileSync(project.manifestPath, "utf8"); + assertCompleteManifest(text); const lines = text.split(/\r?\n/); const summary = readProjectSummary(project.root, lines); const dependencies = readDependencies(lines).map((entry) => { @@ -116,33 +119,52 @@ export function registerProjectView(context: vscode.ExtensionContext, deps: Proj const reload = (freshSources: boolean): void => { const active = vscode.window.activeTextEditor; - const project = (!active || active.document.uri.scheme !== "file") && current.summary + let project = (!active || active.document.uri.scheme !== "file") && current.summary ? { root: current.summary.root, manifestPath: path.join(current.summary.root, "mcpp.toml") } : deps.currentProject(); - current = snapshot(project, freshSources); - view.provider.refresh(); + if (!project && current.summary && active?.document.uri.scheme === "file") { + const relative = path.relative(current.summary.root, active.document.uri.fsPath); + if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { + project = { root: current.summary.root, manifestPath: path.join(current.summary.root, "mcpp.toml") }; + } + } + const next = snapshot(project, freshSources); + next.summary = retainProjectDependencies(next.summary, current.summary); + const changed = JSON.stringify(next) !== JSON.stringify(current); + current = next; + if (changed) { view.provider.refresh(); } }; + let freshSourcesRequested = false; + const refresh = new RefreshCoordinator(async () => { const fresh = freshSourcesRequested; freshSourcesRequested = false; reload(fresh); }); + const schedule = (fresh = false): void => { freshSourcesRequested ||= fresh; void refresh.request(); }; + context.subscriptions.push(refresh); const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(LOCKFILE_GLOB); context.subscriptions.push( manifestWatcher, lockWatcher, - manifestWatcher.onDidCreate(() => reload(true)), - manifestWatcher.onDidChange(() => reload(true)), - manifestWatcher.onDidDelete(() => reload(true)), - lockWatcher.onDidCreate(() => reload(false)), - lockWatcher.onDidChange(() => reload(false)), - lockWatcher.onDidDelete(() => reload(false)), - vscode.window.onDidChangeActiveTextEditor(() => reload(false)), + manifestWatcher.onDidCreate(() => schedule(true)), + manifestWatcher.onDidChange(() => schedule(true)), + manifestWatcher.onDidDelete(() => schedule(true)), + lockWatcher.onDidCreate(() => schedule()), + lockWatcher.onDidChange(() => schedule()), + lockWatcher.onDidDelete(() => schedule()), + vscode.tasks.onDidEndTaskProcess(() => schedule()), + vscode.window.onDidChangeActiveTextEditor(() => { + const active = vscode.window.activeTextEditor; + if (active?.document.uri.scheme !== "file") { return; } + const project = deps.currentProject(); + if (project?.root !== current.summary?.root) { reload(false); } + }), vscode.workspace.onDidChangeWorkspaceFolders(() => { current = snapshot(deps.currentProject(), true); view.provider.refresh(); }), - vscode.workspace.onDidGrantWorkspaceTrust(() => reload(true)), + vscode.workspace.onDidGrantWorkspaceTrust(() => schedule(true)), ); if (bridge !== undefined) { const tree = view.view; context.subscriptions.push( - bridge.onDidChange(() => reload(false)), + bridge.onDidChange(() => view.provider.refresh()), // The folded block's poller only runs while this tree is on screen. tree.onDidChangeVisibility(() => bridge.setVisible(tree.visible)), ); diff --git a/test/architecture.test.ts b/test/architecture.test.ts index a07876f..13fd3fb 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -42,6 +42,9 @@ const PURE_MODULES = [ "src/projects/summary.ts", "src/projects/moduleGraph.ts", "src/graphs/model.ts", + "src/graphs/refresh.ts", + "src/graphs/export.ts", + "src/projects/dependencyState.ts", "src/graphs/panelHtml.ts", "src/cli/moduleGraph.ts", "src/webview/readOnlyDocuments.ts", diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 5f94740..d8af770 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -627,18 +627,16 @@ test("the release workflow guards the published artefact against local leakage", assert.match(workflow, /unzip -t/); }); -test("tag release 同时发布两个市场:Open VSX 必发,Marketplace 未配 token 则跳过", () => { +test("tag release publishes Open VSX and leaves Marketplace upload manual", () => { const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); // 两个市场收到的都是 GitHub Release 上的那只 VSIX,不重新打包。Open VSX 的 // secret 名就是仓库实际配置的 OPENVSX_TOKEN(曾写成 OVSX_PAT,首版必然失败)。 assert.match(workflow, /npx ovsx publish "mcpp-vscode-\$\{PACKAGE_VERSION\}\.vsix" --pat "\$OPENVSX_TOKEN"/); - assert.match(workflow, /npx vsce publish --packagePath "mcpp-vscode-\$\{PACKAGE_VERSION\}\.vsix"/); + assert.doesNotMatch(workflow, /npx vsce publish/); // Open VSX 是必发目标:缺 token 显式失败。 assert.match(workflow, /OPENVSX_TOKEN: \$\{\{ secrets\.OPENVSX_TOKEN \}\}/); assert.match(workflow, /if \[ -z "\$\{OPENVSX_TOKEN:-\}" \]; then[\s\S]*?exit 1/); - // Marketplace 机会发布:缺 token 打一行说明就过,Release 不失败。 - assert.match(workflow, /VSCE_PAT: \$\{\{ secrets\.VSCE_PAT \}\}/); - assert.match(workflow, /if \[ -z "\$\{VSCE_PAT:-\}" \]; then[\s\S]*?exit 0/); + assert.match(workflow, /Upload the GitHub Release VSIX manually/); // 发布用的是仓库锁定的 ovsx:npx 不在发布中途去网络解析。 const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; assert.ok(manifest.devDependencies?.ovsx, "ovsx must be a devDependency the release resolves offline"); @@ -680,6 +678,8 @@ test("CI runs the gates, the package checks, the drift check and every e2e varia assert.match(workflow, /repository: mcpp-community\/mcpp/); assert.match(workflow, /MCPP_REPO: \$\{\{ github.workspace \}\}\/\.mcpp-source/); // The end-to-end dependency resolution check. + assert.match(workflow, /graph-browser:/); + assert.match(workflow, /npm run test:graphs-ui/); assert.match(workflow, /isolated-install:/); assert.match(workflow, /sunrisepeak.mcpp-language-server@/); // The ubuntu runner ships no `code` on PATH, so the job downloads the CLI via diff --git a/test/graphs/export.test.ts b/test/graphs/export.test.ts new file mode 100644 index 0000000..41dc4e1 --- /dev/null +++ b/test/graphs/export.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decodeGraphExport } from "../../src/graphs/export"; +test("export validates offline SVG and bounds untrusted payloads", () => { + assert.equal(decodeGraphExport("svg", '').extension, "svg"); + for (const data of ['', '', '', '']) assert.throws(() => decodeGraphExport("svg", data)); + assert.throws(() => decodeGraphExport("svg", "x".repeat(32 * 1024 * 1024 + 1))); + assert.throws(() => decodeGraphExport("png", "broken")); + const png = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(png); png.write("IHDR",12); png.writeUInt32BE(20,16); png.writeUInt32BE(10,20); + assert.equal(decodeGraphExport("png", png.toString("base64")).extension, "png"); + png.writeUInt32BE(8193,16); assert.throws(() => decodeGraphExport("png", png.toString("base64"))); +}); diff --git a/test/graphs/navigation.test.ts b/test/graphs/navigation.test.ts new file mode 100644 index 0000000..30f89d8 --- /dev/null +++ b/test/graphs/navigation.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { groupedGraph, visibleGraph, type VisualGraph, INITIAL_GRAPH_BUDGET } from "../../src/graphs/model"; +const node = (id: string) => ({ id, label: id, lines: [], kind: "project" as const }); +const state = { depth: 3, upstreamDepth: 2, expanded: new Set(), collapsed: new Set() }; +test("150 test roots cannot consume the budget before their imports", () => { + const graph = { nodes: [...Array.from({ length: 151 }, (_, i) => node(`test${i}`)), node("dep")], + edges: Array.from({ length: 151 }, (_, i) => ({ from: `test${i}`, to: "dep" })), roots: Array.from({ length: 151 }, (_, i) => `test${i}`) }; + const visible = visibleGraph(graph, state, INITIAL_GRAPH_BUDGET); + assert.ok(visible.edges.length > 0); assert.ok(visible.nodes.some(node => node.id === "dep")); + assert.ok(visible.nodes.length <= INITIAL_GRAPH_BUDGET.nodes && visible.edges.length <= INITIAL_GRAPH_BUDGET.edges); +}); +test("two parent hops exclude sibling imports and high fan-in leaves downstream capacity", () => { + const graph: VisualGraph = { nodes: ["focus", "parent", "grandparent", "sibling", "child", ...Array.from({length: 100}, (_, i) => `parent${i}`)].map(node), + edges: [{ from: "grandparent", to: "parent" }, { from: "parent", to: "focus" }, { from: "parent", to: "sibling" }, { from: "focus", to: "child" }, ...Array.from({length: 100}, (_, i) => ({from: `parent${i}`, to: "focus"}))], roots: ["grandparent"] }; + const visible = visibleGraph(graph, { ...state, focus: "focus" }, INITIAL_GRAPH_BUDGET); + assert.ok(visible.nodes.some(node => node.id === "child")); + assert.ok(!visible.nodes.some(node => node.id === "sibling")); + const small = visibleGraph({ ...graph, edges: graph.edges.slice(0, 4) }, { ...state, focus: "focus" }); + assert.ok(small.nodes.some(node => node.id === "grandparent")); +}); +test("successive local depths extend the selected interior node; collapse preserves shared reachability", () => { + const graph: VisualGraph = { nodes: ["a", "b", "c", "d", "e", "f", "other"].map(node), edges: ["a", "b", "c", "d", "e"].map((from, i) => ({ from, to: ["b", "c", "d", "e", "f"][i] })), roots: ["a"] }; + const one = visibleGraph(graph, { ...state, focus: "a", localDepth: new Map([["b", 3]]) }); + assert.ok(one.nodes.some(node => node.id === "e")); assert.ok(!one.nodes.some(node => node.id === "f")); + const two = visibleGraph(graph, { ...state, focus: "a", localDepth: new Map([["b", 4]]) }); + assert.ok(two.nodes.some(node => node.id === "f")); + const shared = { ...graph, edges: [...graph.edges, { from: "a", to: "other" }, { from: "other", to: "c" }] }; + const folded = visibleGraph(shared, { ...state, focus: "a", collapsed: new Set(["b"]) }); + assert.ok(folded.nodes.some(node => node.id === "c")); assert.ok(!folded.edges.some(edge => edge.from === "b")); +}); +test("group pages contain twenty entries and virtual edges cost no import depth", () => { + const ids = Array.from({length: 149}, (_, i) => `test${i}`); + const graph: VisualGraph = { nodes: [...ids.map(node), node("dep")], edges: ids.map(from => ({from, to: "dep"})), roots: ids, + groups: [{id: "@group/tests", label: "Tests", nodes: ids}] }; + const grouped = groupedGraph(graph, "@group/tests", 20); + assert.equal(grouped.edges.filter(edge => edge.containment).length, 20); + const visible = visibleGraph(grouped, {...state, depth: 1}); + assert.ok(visible.nodes.some(node => node.id === "dep")); assert.ok(!visible.nodes.some(node => node.id === "test0")); +}); +test("edge budget cannot strand nodes and large cyclic graphs terminate", () => { + const graph = {nodes: Array.from({length: 10000}, (_, i) => node(String(i))), edges: Array.from({length: 50000}, (_, i) => ({from: String(i % 10000), to: String((i + 1 + Math.floor(i / 10000)) % 10000)})), roots:["0"]}; + const visible = visibleGraph(graph, {...state, depth: Infinity}, {nodes: 80, edges: 20}); + assert.ok(visible.nodes.length <= 21); assert.equal(visible.edges.length, 20); + for (const node of visible.nodes.filter(node => node.id !== "0")) assert.ok(visible.edges.some(edge => edge.from === node.id || edge.to === node.id)); +}); diff --git a/test/graphs/refresh.test.ts b/test/graphs/refresh.test.ts new file mode 100644 index 0000000..9d9a6cb --- /dev/null +++ b/test/graphs/refresh.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { RefreshCoordinator } from "../../src/graphs/refresh"; +const tick = (ms = 15) => new Promise(resolve => setTimeout(resolve, ms)); +test("100 event bursts coalesce, in-flight events produce one followup, hidden events wait", async () => { + let calls = 0, finish: (() => void) | undefined, active = 0, maxActive = 0; + const coordinator = new RefreshCoordinator(async () => { calls++; active++; maxActive = Math.max(maxActive, active); await new Promise(resolve => { finish = resolve; }); active--; }, 5, 20); + try { + for (let i = 0; i < 100; i++) void coordinator.request(); + await tick(); assert.equal(calls, 1); + for (let i = 0; i < 100; i++) void coordinator.request(); + finish!(); await tick(); assert.equal(calls, 2); assert.equal(maxActive, 1); + finish!(); await tick(); assert.equal(calls, 2); + coordinator.setVisible(false); void coordinator.request(); await tick(); assert.equal(calls, 2); + coordinator.setVisible(true); await tick(); assert.equal(calls, 3); finish!(); await tick(); + } finally { coordinator.dispose(); } +}); +test("cancellation aborts the active query and disposal cancels deferred refresh", async () => { + let aborted = false, calls = 0; + const coordinator = new RefreshCoordinator(signal => new Promise(resolve => { + calls++; signal.addEventListener("abort", () => { aborted = true; resolve(); }); + }), 5); + const flight = coordinator.request(true); await tick(); coordinator.cancel(); await flight; assert.equal(aborted, true); + void coordinator.request(); coordinator.dispose(); await tick(); assert.equal(calls, 1); +}); diff --git a/test/projects/dependencyState.test.ts b/test/projects/dependencyState.test.ts new file mode 100644 index 0000000..525c69d --- /dev/null +++ b/test/projects/dependencyState.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertCompleteManifest, retainProjectDependencies } from "../../src/projects/dependencyState"; +import { buildProjectTree, type ProjectSummary } from "../../src/views/models"; +test("temporary read failures preserve dependencies; successful removal clears them", () => { + const previous: ProjectSummary = {root: "/app", dependencies: [{name: "lib"}]}; + const stale = retainProjectDependencies({root: "/app", error: "temporarily unavailable"}, previous)!; + assert.equal(stale.dependencyState, "stale"); assert.equal(stale.dependencies?.length, 1); + const row = buildProjectTree(stale).find(node => node.id === "project.dependencies"); assert.equal(row?.children?.length, 1); + const empty = retainProjectDependencies({root: "/app", dependencies: []}, stale)!; + assert.equal(empty.dependencyState, "empty"); assert.equal(buildProjectTree(empty).find(node => node.id === "project.dependencies")?.children, undefined); + const other = retainProjectDependencies({root: "/other", error: "missing"}, stale)!; + assert.equal(other.dependencies, undefined); assert.equal(other.dependencyState, "error"); + assert.ok(buildProjectTree(other).find(node => node.id === "project.dependencies")?.command); +}); +test("incomplete edits cannot clear the last valid list, complete multiline manifests remain valid", () => { + for (const text of ['[dependencies', '[dependencies]\nlib = "', '[dependencies]\nlib = ', 'garbage', '[dependencies]\nlib={version=}']) assert.throws(() => assertCompleteManifest(text)); + for (const text of ['', '# comment', '[dependencies]\nlib="1"', '[test]\ndiscover = [\n"tests/**/*.cpp",\n]\n', '[custom]\nratio=1.5\nwhen=2026-10-20']) assert.doesNotThrow(() => assertCompleteManifest(text)); +}); diff --git a/test/projects/graphPanel.test.ts b/test/projects/graphPanel.test.ts new file mode 100644 index 0000000..b390da6 --- /dev/null +++ b/test/projects/graphPanel.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const tick = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms)); +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 200; i++) { if (predicate()) { return; } await tick(); } + assert.ok(predicate(), "host state did not settle"); +} + +test("real graph host coalesces file events, ignores lock-only events, defers hidden reads and rejects cross-view publications", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "graph-panel-")); + const roots = [path.join(directory, "a"), path.join(directory, "b")]; + for (const root of roots) { + await mkdir(path.join(root, "src"), {recursive:true}); + await writeFile(path.join(root, "mcpp.toml"), `[package]\nname="${path.basename(root)}"\n`); + await writeFile(path.join(root, "src/main.cpp"), "int main(){}\n"); + await writeFile(path.join(root, "src/lib.cppm"), "export module app;\n"); + } + const messages: Array> = [], commands = new Map Promise>(); + const fileEvents: Array<(uri: any) => void> = []; + let receive: (message: any) => void = () => {}, visibility: () => void = () => {}, queries = 0; + let holdSnapshot = false, releaseSnapshot: (() => void) | undefined; + const disposable = {dispose(){}}; + const uri = (file: string) => ({fsPath:file, toString:()=>file}); + const panel = { + visible:true, + reveal(){}, onDidChangeViewState(listener: () => void){visibility=listener;return disposable;}, onDidDispose(){return disposable;}, dispose(){}, + webview:{cspSource:"test",asWebviewUri:(value: any)=>value, + onDidReceiveMessage(listener: (message: any) => void){receive=listener;return disposable;}, + async postMessage(message: Record){messages.push(message); if(message.type==="snapshot"&&holdSnapshot){holdSnapshot=false;await new Promise(resolve=>{releaseSnapshot=resolve;});} return true;},html:""}, + }; + const vscode = { + Uri:{file:uri,parse:uri,joinPath:(base: any,...parts: string[])=>uri(path.join(base.fsPath,...parts))}, + EventEmitter:class{event=()=>disposable;fire(){}dispose(){}}, + RelativePattern:class{},ViewColumn:{Active:1,Beside:2}, + workspace:{isTrusted:true,findFiles:async()=>[],registerTextDocumentContentProvider:()=>disposable, + createFileSystemWatcher:()=>({dispose(){},onDidChange(listener: (uri: any)=>void){fileEvents.push(listener);return disposable;},onDidCreate:()=>disposable,onDidDelete:()=>disposable})}, + window:{createWebviewPanel:()=>panel,showInformationMessage(){},showErrorMessage(error: string){throw new Error(error);}}, + extensions:{getExtension:()=>undefined},tasks:{onDidEndTaskProcess:()=>disposable}, + commands:{registerCommand:(id: string,handler: (...args: any[])=>Promise)=>{commands.set(id,handler);return disposable;}}, + }; + const filename = path.resolve(__dirname,"../../src/projects/dependencyGraphPanel.js"), nativeRequire=createRequire(filename); + const exports: Record = {}; + const context = {extensionUri:uri(directory),subscriptions:[] as Array<{dispose():void}>}; + const customRequire = (id: string) => { + if(id==="vscode")return vscode; + if(id==="../cli/moduleGraph")return {queryModuleGraph:async(_trusted: boolean,_executable: string,root: string)=>{ + queries++;return {modules:[{name:`app.${path.basename(root)}`,files:["src/lib.cppm"],external:false}],imports:[{from:"src/main.cpp",to:`app.${path.basename(root)}`} ]}; + }}; + if(id==="../library/indexLocator")return {}; + if(id==="../webview/document")return {WebviewDocument:class{assets(){return {nonce:"test",styleUri:"test",cspSource:"test"};}paint(){}invalidate(){}}}; + return nativeRequire(id); + }; + runInNewContext(await readFile(filename,"utf8"),{exports,require:customRequire,setTimeout,clearTimeout,AbortController,process,Buffer}); + try { + exports.registerDependencyGraph(context,()=>({root:roots[0]})); + const open=commands.get("mcpp.project.showModuleDependencyGraph")!; + await open({root:roots[0]});receive({type:"ready"}); + await until(()=>messages.some(message=>message.type==="graph"));assert.equal(queries,1); + assert.equal(messages.find(message=>message.type==="graph")?.center,false,"initial view keeps Fit instead of centering the root"); + const viewKey=`modules:${roots[0]}`; + receive({type:"action",action:"select",viewKey,id:"app.a"});await tick();assert.equal(queries,1); + for(let i=0;i<100;i++)fileEvents[0](uri(path.join(roots[0],"src/lib.cppm"))); + await until(()=>queries===2);await tick(300);assert.equal(queries,2); + fileEvents[0](uri(path.join(roots[0],"mcpp.lock")));await tick(250);assert.equal(queries,2); + panel.visible=false;visibility();for(let i=0;i<100;i++)fileEvents[0](uri(path.join(roots[0],"src/lib.cppm"))); + await tick(250);assert.equal(queries,2);panel.visible=true;visibility();await until(()=>queries===3);await tick(50);assert.equal(queries,3); + // Reopening a disposed webview requires a fresh snapshot. Pause its delivery and switch projects. + holdSnapshot=true; + const switching=open({root:roots[1]});await until(()=>!!releaseSnapshot); + await open({root:roots[0]}); + const beforeRelease=messages.length;releaseSnapshot!();await switching;await tick(); + assert.ok(messages.slice(beforeRelease).every(message=>message.type!=="graph"||message.viewKey===viewKey)); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.viewKey,viewKey); + } finally { + releaseSnapshot?.(); + for(const subscription of context.subscriptions)subscription.dispose(); + await rm(directory,{recursive:true,force:true}); + } +}); diff --git a/test/projects/moduleGraph.test.ts b/test/projects/moduleGraph.test.ts index 79292bf..c689bae 100644 --- a/test/projects/moduleGraph.test.ts +++ b/test/projects/moduleGraph.test.ts @@ -49,3 +49,20 @@ test("malformed and upstream-truncated graphs cannot masquerade as complete", () const graph=buildModuleGraph(root,parseModuleGraph('{"modules":[],"imports":[],"truncated":true}'),new Set(),new Map()); assert.equal(graph.truncated,true); }); +test("test discovery honors custom globs, exclusions and empty discover without inventing a library", () => { + const candidates = ["checks/a.cpp", "checks/deep/b.cpp", "checks/skip.cpp", "tests/default.cpp"].map(file => path.join(root, file)); + const manifest = '[test]\ndiscover=["checks/**/*.cpp", "!checks/skip.cpp"]'; + const entries = moduleEntries(root, manifest, "app", candidates); + assert.match(entries.get(candidates[0])!, /test/); assert.match(entries.get(candidates[1])!, /test/); + assert.equal(entries.get(candidates[2]), undefined); assert.equal(entries.get(candidates[3]), undefined); + assert.ok(!moduleEntries(root, '[test]\ndiscover=[]', "app", candidates).has(candidates[0])); + const graph = buildModuleGraph(root, {modules:[], imports:[]}, new Set(candidates), entries); + assert.deepEqual(graph.groups?.map(group => group.label), ["Tests"]); assert.equal(graph.groups?.[0].nodes.length, 2); +}); + +test("target names do not determine library roles and shared main files preserve target names", () => { + const entries = moduleEntries(root, '[targets.library-tool]\nmain="src/start.cpp"\n[targets.second]\nmain="src/start.cpp"', "app"); + assert.match(entries.get(path.join(root,"src/start.cpp"))!, /library-tool.*second/); + const graph = buildModuleGraph(root, {modules:[],imports:[]}, new Set([path.join(root,"src/start.cpp")]), entries); + assert.deepEqual(graph.groups?.map(group => group.label), ["Main"]); +}); diff --git a/test/views/models.test.ts b/test/views/models.test.ts index 253f5e6..5dd2907 100644 --- a/test/views/models.test.ts +++ b/test/views/models.test.ts @@ -56,7 +56,7 @@ test("the project view is two labelled sections: commands open, basics folded", targets: [{ name: "greet", kind: "bin" }], sourceFiles: 87, }); - assert.deepEqual(ids(tree), ["project.section.basic", "project.section.commands"]); + assert.deepEqual(ids(tree), ["project.section.basic", "project.dependencies", "project.section.commands"]); assert.equal(find(tree, "project.section.basic")?.label.key, "Basics"); assert.equal(find(tree, "project.section.commands")?.label.key, "Common commands"); // The sidebar opens on what can be *done*; 「基本信息」 is one click away. @@ -90,10 +90,10 @@ test("a project without a toolchain says host default rather than inventing one" assert.equal(find(tree, "project.target"), undefined); }); -test("a project with no declared dependency has no dependency group", () => { - assert.equal(find(buildProjectTree({ root: "/w" }), "project.dependencies"), undefined); - assert.equal( - find(buildProjectTree({ root: "/w", dependencies: [] }), "project.dependencies"), - undefined, - ); +test("a project with no declared dependency keeps one non-collapsible status row", () => { + for (const dependencies of [undefined, []]) { + const row = find(buildProjectTree({ root: "/w", dependencies }), "project.dependencies"); + assert.equal(row?.label.key, "This project declares no dependencies"); + assert.equal(row?.children, undefined); + } }); diff --git a/tools/benchmark-dependency-graphs.mjs b/tools/benchmark-dependency-graphs.mjs index d38a72b..ae8c2f2 100644 --- a/tools/benchmark-dependency-graphs.mjs +++ b/tools/benchmark-dependency-graphs.mjs @@ -1,38 +1,48 @@ #!/usr/bin/env node -/** Optional browser benchmark: compile first; use the same external Playwright driver as the UI suite. */ +/** Compile first. Optional real fixture: MCPP_GRAPH_JSON and MCPP_GRAPH_ROOT. */ import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; +import {createRequire} from 'node:module'; import fs from 'node:fs/promises'; import path from 'node:path'; const require=createRequire(import.meta.url); -const {visibleGraph,mermaidGraph}=require('../dist/src/graphs/model.js'); -const {chromium}=require(process.env.MCPP_UI_PLAYWRIGHT||'playwright'); +const {visibleGraph,mermaidGraph,INITIAL_GRAPH_BUDGET}=require('../dist/src/graphs/model.js'); +const {buildModuleGraph,moduleEntries,moduleOwnership,parseModuleGraph,sourcePath}=require('../dist/src/projects/moduleGraph.js'); +const {readProjectSummary}=require('../dist/src/projects/summary.js'); +const {chromium}=require('playwright'); const node=id=>({id:String(id),label:'app.module'+id,lines:['export module','src/module'+id+'.cppm'],kind:'project'}); const shapes={ - chain:{nodes:Array.from({length:1000},(_,i)=>node(i)),edges:Array.from({length:999},(_,i)=>({from:String(i),to:String(i+1),label:'import'})),roots:['0']}, - wide:{nodes:Array.from({length:1000},(_,i)=>node(i)),edges:Array.from({length:999},(_,i)=>({from:'0',to:String(i+1),label:'import'})),roots:['0']}, - shared:{nodes:Array.from({length:45},(_,i)=>node(i)),edges:Array.from({length:15},(_,i)=>Array.from({length:30},(_,j)=>({from:String(i),to:String(j+15),label:'import'}))).flat(),roots:Array.from({length:15},(_,i)=>String(i))}, + chain:{nodes:Array.from({length:1000},(_,i)=>node(i)),edges:Array.from({length:999},(_,i)=>({from:String(i),to:String(i+1)})),roots:['0']}, + wide:{nodes:Array.from({length:1000},(_,i)=>node(i)),edges:Array.from({length:999},(_,i)=>({from:'0',to:String(i+1)})),roots:['0']}, + shared:{nodes:Array.from({length:45},(_,i)=>node(i)),edges:Array.from({length:15},(_,i)=>Array.from({length:30},(_,j)=>({from:String(i),to:String(j+15)}))).flat(),roots:Array.from({length:15},(_,i)=>String(i))}, + tests:{nodes:Array.from({length:152},(_,i)=>node(i)),edges:Array.from({length:151},(_,i)=>({from:String(i),to:'151'})),roots:Array.from({length:151},(_,i)=>String(i))}, }; +let realFocus; +if(process.env.MCPP_GRAPH_JSON&&process.env.MCPP_GRAPH_ROOT){ + const root=path.resolve(process.env.MCPP_GRAPH_ROOT),raw=parseModuleGraph(await fs.readFile(process.env.MCPP_GRAPH_JSON,'utf8')); + const manifest=await fs.readFile(path.join(root,'mcpp.toml'),'utf8'); + const files=new Set(raw.modules.flatMap(node=>node.files.map(file=>sourcePath(root,file)))),names=new Set(raw.modules.map(node=>node.name)); + const exists=file=>fs.access(file).then(()=>true,()=>false); + for(const edge of raw.imports)if(!names.has(edge.from)&&await exists(sourcePath(root,edge.from)))files.add(sourcePath(root,edge.from)); + const entries=moduleEntries(root,manifest,readProjectSummary(root,manifest.split(/\r?\n/)).name||path.basename(root),[...files]); + for(const file of entries.keys())if(await exists(file))files.add(file); + shapes.real=buildModuleGraph(root,raw,await moduleOwnership(root,[...files],exists),entries); + realFocus=shapes.real.groups?.[0]?.nodes[0]; +} const browser=await chromium.launch({headless:true,...(process.env.MCPP_UI_CHROMIUM?{executablePath:process.env.MCPP_UI_CHROMIUM}:{}),args:['--no-sandbox']}); const results=[]; -try { - const page=await browser.newPage({viewport:{width:1400,height:900}}); - await page.setContent('
'); - await page.addScriptTag({path:path.resolve('dist/vendor/mermaid.js')}); - for(const [shape,full] of Object.entries(shapes))for(const depth of [3,Infinity]) { - const start=performance.now(); - const projected=visibleGraph(full,{depth,expanded:new Set(),collapsed:new Set()}); - const projectionMs=performance.now()-start; - assert.ok(projected.nodes.length<=150&&projected.edges.length<=300); - const {source}=mermaidGraph(projected); - const renderMs=await page.evaluate(async source=>{ - mermaid.initialize({startOnLoad:false,htmlLabels:false,securityLevel:'strict',theme:'dark',flowchart:{htmlLabels:false},maxEdges:310}); - const start=performance.now();const result=await mermaid.render('benchmark',source);document.getElementById('graph').innerHTML=result.svg; - return performance.now()-start; - },source); - results.push({shape,depth:depth===Infinity?'all':depth,nodes:projected.nodes.length,edges:projected.edges.length,projectionMs:Math.round(projectionMs*100)/100,renderMs:Math.round(renderMs),limited:projected.limited}); - } -}finally{await browser.close();} -const output=path.resolve('.dev-profile/libraries-ui-artifacts/graph-performance.json'); -await fs.mkdir(path.dirname(output),{recursive:true});await fs.writeFile(output,JSON.stringify({testedAt:new Date().toISOString(),results},null,2)); -console.log(JSON.stringify(results,null,2)); +try{ + const page=await browser.newPage({viewport:{width:1400,height:900}});await page.setContent('
');await page.addScriptTag({path:path.resolve('dist/vendor/mermaid.js')}); + for(const [shape,full]of Object.entries(shapes)){ + const start=performance.now();const projected=visibleGraph(full,{depth:3,upstreamDepth:2,focus:shape==='real'?realFocus:undefined,expanded:new Set(),collapsed:new Set()},INITIAL_GRAPH_BUDGET);const projectionMs=performance.now()-start; + assert.ok(projected.nodes.length<=INITIAL_GRAPH_BUDGET.nodes&&projected.edges.length<=INITIAL_GRAPH_BUDGET.edges); + if(shape==='tests'||shape==='real')assert.ok(projected.edges.length>0,'entries retain connected dependencies'); + const {source}=mermaidGraph(projected),times=[]; + for(let sample=0;sample<20;sample++)times.push(await page.evaluate(async source=>{ + mermaid.initialize({startOnLoad:false,htmlLabels:false,securityLevel:'strict',theme:'dark',fontFamily:'system-ui',markdownAutoWrap:false,flowchart:{htmlLabels:false,useMaxWidth:true,wrappingWidth:1000,nodeSpacing:30,rankSpacing:90,padding:18},maxEdges:310}); + const start=performance.now();const result=await mermaid.render('benchmark',source);document.getElementById('graph').innerHTML=result.svg;return performance.now()-start; + },source)); + const sorted=[...times].sort((a,b)=>a-b);results.push({shape,nodes:projected.nodes.length,edges:projected.edges.length,projectionMs,medianMs:(sorted[9]+sorted[10])/2,p95Ms:sorted[18],samples:times,limited:projected.limited}); + } + const output=path.resolve('.dev-profile/graph-ui-artifacts/graph-performance.json');await fs.mkdir(path.dirname(output),{recursive:true});await fs.writeFile(output,JSON.stringify({browser:await browser.version(),results},null,2)); + console.log(JSON.stringify(results.map(({samples,...summary})=>summary),null,2)); +}finally{await browser.close()} diff --git a/tools/test-graphs-ui.mjs b/tools/test-graphs-ui.mjs new file mode 100644 index 0000000..1976c53 --- /dev/null +++ b/tools/test-graphs-ui.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** Real Chromium + shipped Mermaid and canvas. No VS Code installation is needed. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +const require = createRequire(import.meta.url); +const {chromium} = require('playwright'); +const {graphPanelHtml} = require('../dist/src/graphs/panelHtml.js'); +const {visibleGraph, mermaidGraph, groupedGraph, INITIAL_GRAPH_BUDGET} = require('../dist/src/graphs/model.js'); +const {decodeGraphExport} = require('../dist/src/graphs/export.js'); +const artifacts = path.resolve('.dev-profile/graph-ui-artifacts'); +await fs.mkdir(artifacts, {recursive:true}); +const browser = await chromium.launch({headless:true, ...(process.env.MCPP_UI_CHROMIUM ? {executablePath:process.env.MCPP_UI_CHROMIUM} : {}), args:['--no-sandbox']}); +const checks=[]; +try { + const page = await browser.newPage({viewport:{width:1440,height:1000}}); + const errors=[]; page.on('pageerror', error=>errors.push(error.message)); + let html = graphPanelHtml({nonce:'graph-test',cspSource:'',styleUri:''},'', ''); + html = html.replace(/]*>/,'').replace(/]*>/g,'').replace(/ diff --git a/src/projects/dependencyGraphPanel.ts b/src/projects/dependencyGraphPanel.ts index 98c790d..346eed4 100644 --- a/src/projects/dependencyGraphPanel.ts +++ b/src/projects/dependencyGraphPanel.ts @@ -32,7 +32,7 @@ interface ViewState { const SOURCE_SCHEME = "mcpp-graph-source"; /** Two providers, one persistent graph document. Expansion never re-runs the CLI. */ -export function registerDependencyGraph(context: vscode.ExtensionContext, project: () => ProjectSummary | undefined): void { +export function registerDependencyGraph(context: vscode.ExtensionContext, project: () => ProjectSummary | undefined): (uri?: vscode.Uri) => void { let panel: vscode.WebviewPanel | undefined, current: ViewState | undefined, source = "", generation = 0, publication = 0, ready = false, loading = false; const views = new Map(); const document = new WebviewDocument("dependencyGraph.css"), media = vscode.Uri.joinPath(context.extensionUri, "media"), vendor = vscode.Uri.joinPath(context.extensionUri, "dist", "vendor"); @@ -280,10 +280,12 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec } } }; + context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(document => { if (/(?:mcpp\.(?:toml|lock)|\.(?:cpp|cc|cxx|cppm|ixx|mpp|ccm))$/.test(document.uri.fsPath)) invalidate(document.uri); })); const watcher = vscode.workspace.createFileSystemWatcher("**/{mcpp.toml,mcpp.lock,*.cpp,*.cc,*.cxx,*.cppm,*.ixx,*.mpp,*.ccm}"); context.subscriptions.push(watcher, watcher.onDidChange(uri => invalidate(uri)), watcher.onDidCreate(uri => invalidate(uri)), watcher.onDidDelete(uri => invalidate(uri)), vscode.tasks.onDidEndTaskProcess(event => { const scope = event.execution.task.scope; if (scope && typeof scope === "object") { invalidate(scope.uri); } }), { dispose: () => { for (const view of views.values()) { view.coordinator?.dispose(); if (view.retry) { clearTimeout(view.retry); } } } }); context.subscriptions.push(vscode.commands.registerCommand(TOOL_COMMANDS.dependencyGraph, argument => open("packages", argument)), vscode.commands.registerCommand(TOOL_COMMANDS.moduleDependencyGraph, argument => open("modules", argument)), { dispose: () => panel?.dispose() }); + return invalidate; } diff --git a/src/projects/metadataWatch.ts b/src/projects/metadataWatch.ts new file mode 100644 index 0000000..4c0c32e --- /dev/null +++ b/src/projects/metadataWatch.ts @@ -0,0 +1,24 @@ +import { statSync } from "node:fs"; +import path from "node:path"; + +/** Two cheap file stamps provide a fallback when the editor's watcher drops events. */ +export class ProjectMetadataWatch { + private versions = new Map(); + public constructor(private readonly version: (file: string) => string = file => { + try { const stat = statSync(file, {bigint:true}); return `${stat.ino}:${stat.size}:${stat.mtimeNs}`; } + catch { return "unavailable"; } + }) {} + public capture(root?: string): void { + this.versions.clear(); + if (root) { for (const name of ["mcpp.toml", "mcpp.lock"]) { const file = path.join(root,name); this.versions.set(file,this.version(file)); } } + } + public changes(visible = true): string[] { + if (!visible) { return []; } + const changed: string[] = []; + for (const [file,previous] of this.versions) { + const next=this.version(file); + if(next!==previous){this.versions.set(file,next);changed.push(file);} + } + return changed; + } +} diff --git a/src/views/projectView.ts b/src/views/projectView.ts index c6d499c..9b15d32 100644 --- a/src/views/projectView.ts +++ b/src/views/projectView.ts @@ -13,6 +13,7 @@ import path from "node:path"; import * as vscode from "vscode"; +import { ProjectMetadataWatch } from "../projects/metadataWatch"; import { RefreshCoordinator } from "../graphs/refresh"; import { assertCompleteManifest, retainProjectDependencies } from "../projects/dependencyState"; import { readProjectSummary } from "../projects/summary"; @@ -105,7 +106,8 @@ export function registerProjectView(context: vscode.ExtensionContext, deps: Proj }); let current = snapshot(deps.currentProject(), true); - registerDependencyGraph(context, () => current.summary); + const invalidateGraph = registerDependencyGraph(context, () => current.summary); + const metadata = new ProjectMetadataWatch(); const view = registerTreeView(PROJECT_VIEW_ID, () => buildProjectTree(current.summary, { @@ -128,6 +130,7 @@ export function registerProjectView(context: vscode.ExtensionContext, deps: Proj project = { root: current.summary.root, manifestPath: path.join(current.summary.root, "mcpp.toml") }; } } + metadata.capture(project?.root); const next = snapshot(project, freshSources); next.summary = retainProjectDependencies(next.summary, current.summary); const changed = JSON.stringify(next) !== JSON.stringify(current); @@ -139,6 +142,18 @@ export function registerProjectView(context: vscode.ExtensionContext, deps: Proj const refresh = new RefreshCoordinator(async () => { const fresh = freshSourcesRequested; freshSourcesRequested = false; reload(fresh); }); const schedule = (fresh = false): void => { freshSourcesRequested ||= fresh; void refresh.request(); }; context.subscriptions.push(refresh); + let metadataTimer: ReturnType | undefined; + const checkMetadata = (): void => { + const changes = metadata.changes(view.view.visible); + for (const file of changes) { invalidateGraph(vscode.Uri.file(file)); } + if (changes.length) { schedule(changes.some(file => path.basename(file) === "mcpp.toml")); } + }; + const metadataVisibility = (): void => { + if (metadataTimer) { clearInterval(metadataTimer); metadataTimer = undefined; } + if (view.view.visible) { checkMetadata(); metadataTimer = setInterval(checkMetadata, 2000); } + }; + context.subscriptions.push(view.view.onDidChangeVisibility(metadataVisibility), {dispose:()=>{if(metadataTimer)clearInterval(metadataTimer);}}, + vscode.workspace.onDidSaveTextDocument(document => { if (["mcpp.toml","mcpp.lock"].includes(path.basename(document.uri.fsPath))) schedule(document.uri.fsPath.endsWith("mcpp.toml")); })); const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(LOCKFILE_GLOB); context.subscriptions.push( @@ -178,4 +193,5 @@ export function registerProjectView(context: vscode.ExtensionContext, deps: Proj ); reload(true); + metadataVisibility(); } diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 13fd3fb..4e636c0 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -45,6 +45,7 @@ const PURE_MODULES = [ "src/graphs/refresh.ts", "src/graphs/export.ts", "src/projects/dependencyState.ts", + "src/projects/metadataWatch.ts", "src/graphs/panelHtml.ts", "src/cli/moduleGraph.ts", "src/webview/readOnlyDocuments.ts", diff --git a/test/cli/process.test.ts b/test/cli/process.test.ts index e7caa81..5db51b0 100644 --- a/test/cli/process.test.ts +++ b/test/cli/process.test.ts @@ -47,3 +47,25 @@ test("shell-path arguments are quoted so spaces and cmd metacharacters survive", assert.equal(quoteWindowsArgument("C:\\dir\\"), '"C:\\dir\\\\"'); assert.equal(quoteWindowsArgument(""), '""'); }); + +test("aborting a provider read terminates its real child process", async () => { + const fs = await import("node:fs/promises"), os = await import("node:os"), path = await import("node:path"); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "graph-cancel-")), pidFile = path.join(directory,"pid"); + const controller = new AbortController(); + const running = runProcess(process.execPath, ["-e", 'require("node:fs").writeFileSync(process.argv[1],String(process.pid));setInterval(()=>{},1000)', pidFile], undefined, {signal:controller.signal,timeoutMs:10000}); + try { + let pid: number | undefined; + for(let attempt=0;attempt<250;attempt++) { + try { pid=Number(await fs.readFile(pidFile,"utf8")); break; } catch { await new Promise(resolve=>setTimeout(resolve,20)); } + } + assert.ok(pid,"the child started before cancellation"); + controller.abort(); const result=await running; + assert.notEqual(result.exitCode,0); + let exited=false; + for(let attempt=0;attempt<150;attempt++) { + try { process.kill(pid!,0); } catch { exited=true; break; } + await new Promise(resolve=>setTimeout(resolve,20)); + } + assert.equal(exited,true,"the cancelled child must no longer exist"); + } finally { controller.abort(); await running; await fs.rm(directory,{recursive:true,force:true}); } +}); diff --git a/test/projects/graphPanel.test.ts b/test/projects/graphPanel.test.ts index cfaeb49..e323fff 100644 --- a/test/projects/graphPanel.test.ts +++ b/test/projects/graphPanel.test.ts @@ -40,7 +40,7 @@ test("real graph host coalesces file events, ignores lock-only events, defers hi Uri:{file:uri,parse:uri,joinPath:(base: any,...parts: string[])=>uri(path.join(base.fsPath,...parts))}, EventEmitter:class{event=()=>disposable;fire(){}dispose(){}}, RelativePattern:class{constructor(public root: string,public pattern: string){}},ViewColumn:{Active:1,Beside:2}, - workspace:{isTrusted:true,findFiles:async(pattern: {root: string})=>Array.from({length:25},(_,i)=>uri(path.join(pattern.root,`tests/test${i}.cpp`))),registerTextDocumentContentProvider:()=>disposable, + workspace:{isTrusted:true,onDidSaveTextDocument:()=>disposable,findFiles:async(pattern: {root: string})=>Array.from({length:25},(_,i)=>uri(path.join(pattern.root,`tests/test${i}.cpp`))),registerTextDocumentContentProvider:()=>disposable, createFileSystemWatcher:()=>({dispose(){},onDidChange(listener: (uri: any)=>void){fileEvents.push(listener);return disposable;},onDidCreate:()=>disposable,onDidDelete:()=>disposable})}, window:{createWebviewPanel:()=>panel,showInformationMessage(){},showErrorMessage(error: string){throw new Error(error);}}, extensions:{getExtension:()=>undefined},tasks:{onDidEndTaskProcess:()=>disposable}, diff --git a/test/projects/metadataWatch.test.ts b/test/projects/metadataWatch.test.ts new file mode 100644 index 0000000..3f820f5 --- /dev/null +++ b/test/projects/metadataWatch.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { ProjectMetadataWatch } from "../../src/projects/metadataWatch"; +test("metadata fallback reads only two stamps while visible and emits each change once",()=>{ + const values=new Map();let reads=0; + const watcher=new ProjectMetadataWatch(file=>{reads++;return values.get(file)||"missing";}); + watcher.capture("project");assert.equal(reads,2); + assert.deepEqual(watcher.changes(false),[]);assert.equal(reads,2); + assert.deepEqual(watcher.changes(),[]);assert.equal(reads,4); + const manifest=path.join("project","mcpp.toml");values.set(manifest,"new"); + assert.deepEqual(watcher.changes(),[manifest]);assert.deepEqual(watcher.changes(),[]); + values.set(manifest,"newer");watcher.capture("project");assert.deepEqual(watcher.changes(),[],"a normal refresh captures the version before fallback runs"); + watcher.capture();const before=reads;assert.deepEqual(watcher.changes(),[]);assert.equal(reads,before); +}); diff --git a/tools/test-graphs-ui.mjs b/tools/test-graphs-ui.mjs index 0ca0026..6b1469c 100644 --- a/tools/test-graphs-ui.mjs +++ b/tools/test-graphs-ui.mjs @@ -50,6 +50,11 @@ try { await page.locator('[data-action="copy"]').click(); assert.equal(await page.locator('[data-export="svg"]').isVisible(),false); await page.locator('#graph-view-menu summary').click(); + const depths = await page.locator('#graph-view-menu select').evaluateAll(items => items.map(item => { const r=item.getBoundingClientRect(); return {left:r.left,width:r.width,height:r.height}; })); + assert.deepEqual(depths[0],depths[1]); + const header = await page.locator('.graph-header [data-action=refresh],#graph-export>summary').evaluateAll(items=>items.map(item=>{const r=item.getBoundingClientRect();return {top:r.top,height:r.height,color:getComputedStyle(item).color};})); + assert.equal(header[0].top,header[1].top); assert.equal(header[0].height,header[1].height); assert.notEqual(header[0].color,header[1].color); + await page.screenshot({path:path.join(artifacts,'view-options.png')}); await page.locator('#graph-upstream').selectOption('3'); assert.equal(await page.evaluate(()=>messages.filter(m=>m.action==='upstreamDepth').at(-1).depth),'3'); assert.equal(await page.locator('#graph-depth').isVisible(),false); @@ -62,6 +67,18 @@ try { assert.equal(await page.evaluate(()=>messages.filter(m=>m.action==='focus').at(-1).id),'a'); await a.dblclick(); assert.equal(await page.evaluate(()=>messages.filter(m=>m.action==='open').length),1); checks.push('single click selects, double click opens once, Focus targets selection'); + await a.click({button:'right'}); + assert.equal(await page.locator('#graph-node-menu').count(),0); + await page.locator('[data-context-action="expand"]').click(); + assert.equal(await page.evaluate(()=>messages.filter(m=>m.action==='expand').at(-1).id),'a'); + await a.focus(); await page.keyboard.press('Shift+F10'); + assert.equal(await page.locator('#graph-node-context').isVisible(),true); + await page.keyboard.press('ArrowDown'); + assert.equal(await page.locator('[data-context-action="locate"]').evaluate(e=>document.activeElement===e),true); + await page.screenshot({path:path.join(artifacts,'node-context.png')}); + await page.keyboard.press('Escape'); + assert.equal(await a.evaluate(e=>document.activeElement===e),true); + checks.push('node context menu targets clicked node, supports keyboard navigation and restores focus; controls align and use theme colors'); const renders=await page.evaluate(()=>renders.length); await page.locator('[data-action="locate"]').click(); await page.locator('[data-zoom="+"]').click(); @@ -115,10 +132,10 @@ try { await emit({type:'graph',viewKey:'modules:groups',mode:'modules',name:'test entries',depth:'1',upstreamDepth:'2',group:'@group/tests',offset:20, ...mermaidGraph(grouped),nodes:grouped.nodes.map(node=>({id:node.id,label:node.label,virtual:!!node.virtual,hidden:grouped.hidden.get(node.id)||0})),selected:'@group/tests'}); await page.waitForSelector('.node[data-node-key="@group/tests"]'); - await page.locator('#graph-node-menu summary').click(); - assert.equal(await page.locator('[data-action="open"]').isDisabled(),true); - assert.equal(await page.locator('[data-action="expandParents"]').isDisabled(),true); - assert.equal(await page.locator('[data-action="expand"]').isDisabled(),false); + await page.locator('.node[data-node-key="@group/tests"]').click({button:'right'}); + assert.equal(await page.locator('[data-context-action="open"]').isDisabled(),true); + assert.equal(await page.locator('[data-context-action="expandParents"]').isDisabled(),true); + assert.equal(await page.locator('[data-context-action="expand"]').isDisabled(),false); await page.keyboard.press('Escape'); checks.push('virtual entry groups offer applicable node actions and correct paging'); diff --git a/tools/test-libraries-ui.mjs b/tools/test-libraries-ui.mjs index 9b8156f..afe477b 100644 --- a/tools/test-libraries-ui.mjs +++ b/tools/test-libraries-ui.mjs @@ -91,6 +91,7 @@ let browser; await eventually(async () => { browser = await chromium.connectOverCDP(endpoint); return true; }, Boolean, 'VS Code CDP startup'); const page = browser.contexts()[0].pages()[0]; await page.setViewportSize({ width: 1600, height: 1000 }); +await page.bringToFront(); page.on('pageerror', (error) => console.error('renderer:', error.message)); async function frameWith(selector) { return eventually(async () => { @@ -497,6 +498,9 @@ try { const original=await fs.readFile(path.join(scratch,'module-project/src/l0.cppm'),'utf8'); await graph.locator('.node[data-node-key="demo.l0"]').click(); assert.equal(await page.locator('.monaco-editor:visible').filter({hasText:'export module demo.l0;'}).count(),0,'single click only selects'); + await graph.locator('.node[data-node-key="demo.l0"]').click({button:'right'}); + await graph.locator('[data-context-action=locate]').click(); + assert.equal(await graph.locator('#graph-node-context').isVisible(),false,'node context menu closes after action'); await graph.locator('.node[data-node-key="demo.l0"]').dblclick(); const editor=page.locator('.monaco-editor:visible').filter({hasText:'export module demo.l0;'}); await editor.waitFor(); From 8627076e2752a1bcb8d9b57aa2f715f955217fb3 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 20 Oct 2026 02:51:25 +0900 Subject: [PATCH 5/7] test: distinguish large-project graph from retained fixture views --- .agents/docs/2026-10-20-module-graph-validation.md | 4 ++-- tools/test-libraries-ui.mjs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/docs/2026-10-20-module-graph-validation.md b/.agents/docs/2026-10-20-module-graph-validation.md index 676c892..197140e 100644 --- a/.agents/docs/2026-10-20-module-graph-validation.md +++ b/.agents/docs/2026-10-20-module-graph-validation.md @@ -1,6 +1,6 @@ # Modules graph 0.9.0 validation and review -Status: implementation and large-project verification complete locally; CI and released-artifact verification pending. +Status: implementation and local review complete. The repository suite passes 725 tests; Chromium passes 11 interaction/export groups. Exact-head CI and released-artifact verification are final release gates. ## Review perspectives @@ -9,7 +9,7 @@ Status: implementation and large-project verification complete locally; CI and r | Graph semantics | Test-root budget starvation; parent traversal bringing sibling dependencies; edge limits stranding nodes; library classification based on target spelling | Graph/model and module provider regressions; large-project projection | | Interaction | Click opening files; repeated expansion doing nothing; selected/focus/camera ambiguity; initial root centering overriding Fit | Chromium canvas tests; real VS Code fixture navigation | | Refresh and concurrency | Event bursts starting duplicate reads; lock changes querying Modules; hidden refreshes; old view publications arriving after a project switch | Refresh coordinator tests and the real host module under a controlled message transport | -| Project state | Empty declarations removing the row; incomplete manifests clearing usable dependencies; delayed project switching exposing old actions | Dependency state tests; immediate cross-project update; real VS Code inspection | +| Project state | Empty declarations removing the row; incomplete manifests clearing usable dependencies; delayed project switching exposing old actions | Dependency state tests; immediate cross-project update; real VS Code empty → ready → stale → ready checks with the installed VSIX | | Cache correctness | Identical SVGs in different projects retaining old node identities | Chromium test proves layout reuse with new event bindings | | Export | Theme variables missing offline; transformed/cropped drawing; excessive raster size | Light/dark standalone SVG and PNG, decoded dimensions, Unicode and marker inspection | | Compatibility | Shared Packages canvas, read-only source documents, old language service commands | Existing unit and five service-variant Extension Host suites; real source read-only checks | diff --git a/tools/test-libraries-ui.mjs b/tools/test-libraries-ui.mjs index afe477b..b63f589 100644 --- a/tools/test-libraries-ui.mjs +++ b/tools/test-libraries-ui.mjs @@ -93,9 +93,9 @@ const page = browser.contexts()[0].pages()[0]; await page.setViewportSize({ width: 1600, height: 1000 }); await page.bringToFront(); page.on('pageerror', (error) => console.error('renderer:', error.message)); -async function frameWith(selector) { +async function frameWith(selector, projectName) { return eventually(async () => { - for (const frame of page.frames()) { if (await frame.locator(selector).count()) { return frame; } } + for (const frame of page.frames()) { if (await frame.locator(selector).count() && (!projectName || await frame.locator('#project-name').textContent() === projectName)) { return frame; } } }, Boolean, `frame ${selector}`); } async function palette(command) { @@ -548,7 +548,7 @@ try { await page.locator('.quick-input-list .monaco-list-row').filter({visible:true}).first().click(); await eventually(()=>page.locator('.monaco-editor:visible').allTextContents().then(text=>text.join(' ')),text=>text.includes('[package]'),'real manifest'); await palette('mcpp: Show C++ Modules Dependency Graph'); - const graph=await frameWith('#graph'); + const graph=await frameWith('#graph','mcpp'); await eventually(()=>graph.locator('#graph').innerText(),text=>text.includes('src/main.cpp')&&text.includes('mcpp.'),'large project graph',45000); assert.ok(await graph.locator('.edgePath,.flowchart-link').count()>0,'default entry has dependency edges'); assert.ok(await graph.locator('.node').count()<=60,'visible node budget'); From 1446955a9349af223f8e07cf02b06c5f6fcd1629 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 20 Oct 2026 02:52:36 +0900 Subject: [PATCH 6/7] fix: leave test pagination when focusing an individual entry --- .agents/docs/2026-10-20-module-graph-validation.md | 2 +- src/projects/dependencyGraphPanel.ts | 2 +- test/projects/graphPanel.test.ts | 6 ++++++ tools/test-libraries-ui.mjs | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.agents/docs/2026-10-20-module-graph-validation.md b/.agents/docs/2026-10-20-module-graph-validation.md index 197140e..f1e3d3f 100644 --- a/.agents/docs/2026-10-20-module-graph-validation.md +++ b/.agents/docs/2026-10-20-module-graph-validation.md @@ -7,7 +7,7 @@ Status: implementation and local review complete. The repository suite passes 72 | Perspective | Findings addressed | Evidence | | --- | --- | --- | | Graph semantics | Test-root budget starvation; parent traversal bringing sibling dependencies; edge limits stranding nodes; library classification based on target spelling | Graph/model and module provider regressions; large-project projection | -| Interaction | Click opening files; repeated expansion doing nothing; selected/focus/camera ambiguity; initial root centering overriding Fit | Chromium canvas tests; real VS Code fixture navigation | +| Interaction | Click opening files; repeated expansion doing nothing; selected/focus/camera ambiguity; initial root centering overriding Fit; test paging lingering after entry focus | Chromium canvas tests; real VS Code fixture navigation; host regression for leaving the pager and restoring its page with Back | | Refresh and concurrency | Event bursts starting duplicate reads; lock changes querying Modules; hidden refreshes; old view publications arriving after a project switch | Refresh coordinator tests and the real host module under a controlled message transport | | Project state | Empty declarations removing the row; incomplete manifests clearing usable dependencies; delayed project switching exposing old actions | Dependency state tests; immediate cross-project update; real VS Code empty → ready → stale → ready checks with the installed VSIX | | Cache correctness | Identical SVGs in different projects retaining old node identities | Chromium test proves layout reuse with new event bindings | diff --git a/src/projects/dependencyGraphPanel.ts b/src/projects/dependencyGraphPanel.ts index 346eed4..ec5a26e 100644 --- a/src/projects/dependencyGraphPanel.ts +++ b/src/projects/dependencyGraphPanel.ts @@ -240,7 +240,7 @@ export function registerDependencyGraph(context: vscode.ExtensionContext, projec (upstream ? current.upstreamCollapsed : current.collapsed).delete(id); } else if (value.action === "collapse" || value.action === "collapseParents") { (value.action === "collapseParents" ? current.upstreamCollapsed : current.collapsed).add(id); } - else if (value.action === "focus" || value.action === "search") { remember(); current.focus = id; current.localDepth = new Map(); current.collapsed = new Set(); current.upstreamLocalDepth = new Map(); current.upstreamCollapsed = new Set(); } + else if (value.action === "focus" || value.action === "search") { remember(); current.focus = id; current.group = undefined; current.offset = 0; current.localDepth = new Map(); current.collapsed = new Set(); current.upstreamLocalDepth = new Map(); current.upstreamCollapsed = new Set(); } else { return; } await publish(value.action === "search", value.action === "focus"); }; diff --git a/test/projects/graphPanel.test.ts b/test/projects/graphPanel.test.ts index e323fff..1f27254 100644 --- a/test/projects/graphPanel.test.ts +++ b/test/projects/graphPanel.test.ts @@ -72,6 +72,12 @@ test("real graph host coalesces file events, ignores lock-only events, defers hi receive({type:"action",action:"select",viewKey,id:"tests/test0.cpp"}); receive({type:"action",action:"page",viewKey,next:true});await tick(); assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.selected,"@group/tests","paging clears a selection from the previous page"); + receive({type:"action",action:"focus",viewKey,id:"tests/test20.cpp"});await tick(); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.group,undefined,"focusing one entry leaves the group pager"); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.offset,0); + receive({type:"action",action:"back",viewKey});await tick(); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.group,"@group/tests"); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.offset,20,"Back restores the previous test page"); for(let i=0;i<100;i++)fileEvents[0](uri(path.join(roots[0],"src/lib.cppm"))); await until(()=>queries===2);await tick(300);assert.equal(queries,2); fileEvents[0](uri(path.join(roots[0],"mcpp.lock")));await tick(250);assert.equal(queries,2); diff --git a/tools/test-libraries-ui.mjs b/tools/test-libraries-ui.mjs index b63f589..a9e86a3 100644 --- a/tools/test-libraries-ui.mjs +++ b/tools/test-libraries-ui.mjs @@ -559,6 +559,7 @@ try { const item=graph.locator('.node[data-node-key^="tests/"]').first(); await item.click(); await graph.locator('[data-action=focus]').click(); await eventually(()=>graph.locator('#focus-node').innerText(),text=>text.includes('tests/'),'focus selected test'); + assert.equal(await graph.locator('#entry-next').isVisible(),false,'a focused entry leaves the group pager'); await graph.locator('[data-action=locate]').click(); await graph.locator('[data-zoom=fit]').click(); await graph.locator('body').screenshot({path:path.join(artifacts,'large-project-tests.png')}); From 5508954b0fa9895f20045ffca55f011db4480140 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 20 Oct 2026 02:55:08 +0900 Subject: [PATCH 7/7] test: use provider entry identity across platforms --- test/projects/graphPanel.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/projects/graphPanel.test.ts b/test/projects/graphPanel.test.ts index 1f27254..a00d5a8 100644 --- a/test/projects/graphPanel.test.ts +++ b/test/projects/graphPanel.test.ts @@ -72,10 +72,12 @@ test("real graph host coalesces file events, ignores lock-only events, defers hi receive({type:"action",action:"select",viewKey,id:"tests/test0.cpp"}); receive({type:"action",action:"page",viewKey,next:true});await tick(); assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.selected,"@group/tests","paging clears a selection from the previous page"); - receive({type:"action",action:"focus",viewKey,id:"tests/test20.cpp"});await tick(); + const focusedEntry = messages.filter(message=>message.type==="snapshot").at(-1)?.groups.find((group: {id:string})=>group.id==="@group/tests").nodes[20]; + assert.ok(focusedEntry); + receive({type:"action",action:"focus",viewKey,id:focusedEntry});await until(()=>messages.filter(message=>message.type==="graph").at(-1)?.focus===focusedEntry); assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.group,undefined,"focusing one entry leaves the group pager"); assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.offset,0); - receive({type:"action",action:"back",viewKey});await tick(); + receive({type:"action",action:"back",viewKey});await until(()=>messages.filter(message=>message.type==="graph").at(-1)?.group==="@group/tests"); assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.group,"@group/tests"); assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.offset,20,"Back restores the previous test page"); for(let i=0;i<100;i++)fileEvents[0](uri(path.join(roots[0],"src/lib.cppm")));