From 64ed93b417ec32ad761a3b4b26545c0b385185b0 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 18 Oct 2026 23:11:33 +0900 Subject: [PATCH 01/20] docs: checkpoint Libraries UX implementation plan --- ...26-10-18-libraries-ux-optimisation-plan.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 .agnets/docs/2026-10-18-libraries-ux-optimisation-plan.md diff --git a/.agnets/docs/2026-10-18-libraries-ux-optimisation-plan.md b/.agnets/docs/2026-10-18-libraries-ux-optimisation-plan.md new file mode 100644 index 0000000..014bbbe --- /dev/null +++ b/.agnets/docs/2026-10-18-libraries-ux-optimisation-plan.md @@ -0,0 +1,258 @@ +# Libraries 与包详情页优化方案 + +日期:2026-10-18 +状态:待 review,尚未实施 +范围:索引来源、Libraries 浏览与搜索、包详情布局、版本选择、添加依赖及状态同步。 + +本文按本次要求放在 `.agnets/docs`;仓库已有文档目录实际为 `.agents/docs`,本次不迁移旧文档。 + +## 1. 结论与设计方向 + +Libraries 应围绕一条完整路径设计:**找到 C++ 库 → 判断能否使用 → 查看引入方式 → 为明确的项目添加明确的版本 → 得到准确反馈**。 + +先修数据来源和状态一致性,再优化显示层。延续侧边栏选包、编辑器区看详情的结构;保留单个详情面板、离线优先、简洁搜索工具栏、主操作与仓库/索引站按钮同行的已有决策。 + +建议本轮不加入多行筛选 chips、复杂卡片网格、评分/下载量、自动安装、自动修改版本、任意目标平台选择或在线 README 抓取。 + +## 2. 分析依据与确认程度 + +依据当前仓库源码、历史评审记录以及本机只读 CLI/文件检查。布局判断来自 HTML/CSS 静态分析,尚未在真实 VS Code 窗口做视觉验收;下文尺寸与视觉目标需在实施时验证。 + +主要代码: + +| 文件 | 关注点 | +| --- | --- | +| `src/library/indexLocator.ts` | 索引发现、快照缓存、示例读取 | +| `src/library/libraryView.ts` | 列表生命周期、网络搜索、刷新和设置监听 | +| `src/library/libraryHtml.ts` | 列表行、搜索、前端状态 | +| `src/library/detailPanel.ts` | 详情加载、解析、项目上下文、异步操作 | +| `src/library/detailHtml.ts` | 版本矩阵、主按钮、复制、反馈 | +| `src/library/indexModel.ts` | 身份、依赖声明/锁定状态、示例和用法推导 | +| `src/library/addDependency.ts` | `mcpp add`、通知与输出 | +| `media/library.css` | 侧边栏与详情页共享样式 | +| `src/extension.ts` | 项目获取、索引更新及依赖变化回调 | + +已有交互约束参考 `.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md` 的 §19、§20、§22:曾明确移除多行筛选标签,并将添加、仓库、索引站操作合并到一行。本方案不直接恢复被移除的结构。 + +### 2.1 已确认的问题 + +| 优先级 | 现状与证据 | 用户影响 | +| --- | --- | --- | +| P0 | 本机 mcpp 2026.10.16.3 的官方索引位于 `~/.mcpp/indices/mcpplibs`,含 252 个 Lua 描述符;插件只默认扫描 `registry/data` | 官方库漏读 | +| P0 | `rootAt()` 仅检查 `pkgs/`;本机旧扫描目录下 `xim-pkgindex` 含 337 个描述符 | Libraries 显示成 xim 包列表 | +| P0 | 快照 key 含索引 revision 和项目路径,不含 manifest/lock 内容;保存和添加后虽调用 refresh,仍可能命中旧快照 | 已添加徽标不更新 | +| P0 | 添加结果经共享 session 发给当前面板,没有绑定发起操作的包、项目或页面代次 | A 包添加期间切到 B,A 的结果可能把 B 标成已添加 | +| P1 | 网络搜索结果只在列表 `extra` 中;详情 `buildModel()` 只查本地 snapshot | 可点击的搜索结果打开后显示“不在本地索引” | +| P1 | 搜索有 debounce,但没有请求代次校验;旧请求可晚于新请求返回,关闭开关后也可能回填 | 搜索结果与当前关键词/开关不一致 | +| P1 | 详情创建后等待完整 `buildModel()` 才 paint,解析允许等待 20 秒 | 首次可能空白,切包期间可能仍显示上一包内容 | +| P1 | 所有平台的版本均为可点击按钮,选中状态只比较版本字符串 | 可误选其它平台独有版本;相同版本跨行同时高亮 | +| P1 | `installed` 合并 manifest 声明与 lock 解析状态;前端只保留版本字符串;dev 初始值未从现有声明恢复 | 已声明、已解析、依赖分组混淆;同版本切换分组无法准确表达 | +| P1 | 详情的项目通过当前编辑器动态获取,没有页面内明确目标;详情切换到活动编辑器本身也可能改变项目推断条件 | 多项目场景下难以判断写入哪个项目 | +| P2 | 详情为最长 900px 的纵向文档,版本、依赖在示例前;全部版本铺开 | 宽窗口利用不足,阅读用法需要滚动 | +| P2 | 复制、链接和添加共用一条结果栏,添加还触发通知;添加前端没有明确 pending 锁定 | 反馈互相覆盖、重复点击缺少解释 | + +补充:当前只根据 extension host 的 `process.platform` 判定平台;这不能等同于工程交叉编译目标。界面应称“当前环境平台”,不得宣称完整验证了工具链、架构和 ABI 兼容性。 + +## 3. 索引来源与数据语义 + +### 3.1 默认源选择 + +1. 非空 `mcpp.library.indexPath` 优先。保留索引根目录、`pkgs/` 目录、索引集合目录三种输入;无效显式路径直接显示路径错误,不静默换源。 +2. 默认发现 `/indices/mcpplibs`,兼容 `/registry/data/mcpplibs` 旧位置;同一 home 新位置有效时不再叠加旧位置。 +3. home 选择遵循 CLI 的实际规则。建议实现时以本地 CLI 源码与 `self env --format json` 确认:显式环境 home 优先,默认 home 作为后备;需要 CLI 探测时每会话缓存,显式刷新/环境配置变更可重试。不把两个 home 中的同名官方源默认合并。 +4. 默认不遍历加载全部 registry;官方源缺失时显示“未找到 mcpp-index”,即使 xim 已存在,也不能用 xim 填满列表。 +5. 默认模式排除 xim;用户显式指定自定义源时允许浏览,但标题/来源须明确为自定义,不能标成官方。 + +“来源身份”与“官网链接”拆开建模:`official / custom / other-registry` 由发现路径和可核实元数据决定;有无 `site` 只决定是否展示官网按钮。集合配置中的同名包必须保留来源,选定优先级前不能静默覆盖。 + +底部简短显示 `mcpp-index · 252 个包 · 本地`,详细信息可展开查看实际路径、索引版本/更新时间(仅有真实元数据时显示)、最近更新结果。计数是本地快照数量,不保证等于远端当前总量。 + +### 3.2 缓存与刷新 + +- 索引内容缓存和项目依赖状态分离。刷新依赖状态只重读当前项目 manifest/lock,不重读数百个描述符。 +- 索引缓存 key 包含源的规范路径、版本标记;显式本地重读可强制失效。源码 checkout 无更新 marker 时,应能检测描述符修改,不能只依赖根目录 mtime 和第一层目录数。 +- 监听 manifest/lock 的保存及磁盘创建、变更、删除;去抖后同时更新列表和当前详情。项目切换、索引源/CLI 配置变化也应更新上下文。 +- 区分两个操作:**重新读取本地索引**与**更新 mcpp 索引**。前者只读磁盘,后者执行 `mcpp index update`,可能联网。 +- 更新失败保留上一次可用数据,标明未更新成功,并提供重试/查看输出;不把异常降级为空列表。 +- 配置的是自定义 checkout 时,本地重读读取该路径;全局 `mcpp index update` 不承诺更新这个 checkout,操作说明必须指出其作用范围。 + +## 4. Libraries 列表交互与布局 + +### 4.1 工具栏与行结构 + +```text +Libraries [更新索引] +[搜索包名、命名空间、描述 ×] +☐ 搜索其它 registry 12 / 252 +───────────────────────────────────────── +compat.argparse 3.2 +#include · 已声明 +Command line argument parser… + +openkal.types 0.x +import +Type definitions… +───────────────────────────────────────── +mcpp-index · 本地 [来源详情] +``` + +上图版本与描述仅为结构示意,产品显示以真实索引为准。 + +- 保留搜索框和一个可选跨 registry 搜索开关,不常驻命名空间/用法筛选标签。 +- 包 ID 为主文本;版本为次文本;用法与项目状态一行,描述最多两行。示例/生态等次要标签移到详情,避免每行出现多枚同等权重徽标。 +- 已声明状态用文字加主题色;当前选中包增加可见标记与 `aria-current`。不在整行按钮内部嵌套“添加”按钮。 +- 支持 Tab/Enter 的基础操作;可进一步加入上下键移动结果、Enter 打开、Esc 清空搜索。键盘行为须与搜索框编辑行为明确区分。 +- 查询仍在客户端即时过滤;保留当前多词匹配,并让精确 ID、名称前缀优先,其后为描述命中;无查询时稳定按 ID 排序。 +- 保存 query、scroll、selectedKey;数据刷新后保留焦点和光标位置。使用 `setState/getState` 恢复被销毁的 webview,不依赖 DOM 长期存活。 + +### 4.2 搜索状态与来源 + +- 本地搜索始终立即可用;其它 registry 默认关闭。 +- 启用后只对至少两个字符的查询发起搜索;清空/短查询及时清理旧附加结果。 +- 每次请求记录 query、请求代次、开关及项目/源上下文;响应只提交到仍匹配的视图。关闭开关立即废弃在途结果。 +- 显示“正在搜索其它 registry…”,不阻塞本地结果。其它来源单独分组,行内注明来源;状态分别显示本地命中数与额外结果数。 +- 首期为非本地结果提供**有限预览详情**:包 ID、搜索返回的版本/描述、来源、可复制的检索命令,以及“如何获取完整详情”的说明。缺失字段不补造;暂不提供一键添加,避免把工具索引结果直接当作 C++ 库添加。 +- 取消当前“能点击但详情必然在本地查不到”的流程;后续有稳定解析接口时再接入跨源完整详情,不以解析人类输出构造完整包模型。 + +### 4.3 空状态 + +| 状态 | 展示与恢复入口 | +| --- | --- | +| 初次读取 | “正在读取 mcpp-index…”;有旧快照时保留列表 | +| 官方索引缺失 | 说明缺失来源;提供更新索引、设置路径 | +| 显式路径无效 | 展示路径和原因;提供修改路径、重试 | +| 搜索无命中 | 展示当前查询;提供清空,以及可选搜索其它 registry | +| 读取/更新失败 | 简短原因、重试、查看输出;保留可用旧结果 | +| 无 mcpp 项目 | 可浏览;顶部提示尚未选择项目,详情主操作解释为何不可添加 | + +## 5. 详情页布局 + +### 5.1 推荐信息顺序 + +1. **身份与概览**:包 ID、描述、用法、标准与许可;来源为次级信息。 +2. **项目与添加控制**:目标项目、当前环境平台、版本选择、依赖类型,以及原有同行的添加/仓库/索引站操作。 +3. **如何使用**:引入代码、真实示例及来源;用户无需越过全部平台版本矩阵。 +4. **版本与平台**:当前平台完整列表,其他平台默认折叠查看。 +5. **依赖与构建信息**:依赖清单、项目锁定对照、targets/include dirs;空白构建信息不单独占一节。 +6. **来源与诊断**:本地路径、解析来源、索引版本、降级信息。 + +### 5.2 布局草图 + +```text +compat.argparse +Command line argument parser… +#include · C++23 · MIT + +目标项目 app-a 当前环境 linux +版本 [3.2 ▾] 依赖类型 [普通 ▾] +项目声明 3.1 · 锁定解析 3.1 +[切换到 3.2] [仓库] [索引站] +mcpp add compat.argparse@3.2 [复制] + +如何使用 包信息 / 平台版本 +引入代码 [复制] 当前平台 linux +真实示例 [复制] 其它平台(折叠) + 依赖(数量) + 构建信息(折叠) +``` + +- 详情容器建议最大宽度约 1120px;可用宽度 ≥900px 时下半部分为主内容约 2/3、补充信息约 1/3。较窄编辑器降为单栏,不依赖窗口总宽度。 +- 添加区保持首屏可见,不在页面底部重复按钮;宽窗口下主操作与外链仍同行,窄窗口自然换行。 +- 暂不引入整页 tabs:当前信息量适合一个文档配局部折叠,避免用法和状态被藏在不同页签。 +- 长包名允许换行,代码区单独横向滚动;布局不产生整页横向滚动。字体、颜色、焦点和分隔线沿用 VS Code theme tokens。 +- 初次打开立刻显示包身份和局部加载状态;先展示本地轻量信息,解析完成只更新对应区块。切包时立即换标题和加载占位,不能让旧包按钮继续可操作。 +- 保留单详情面板;重新点同一包保留版本、依赖类型、折叠项和滚动位置。切包可缓存少量会话状态,索引更新后校验所选版本是否仍存在。 + +## 6. 版本、项目与添加行为 + +### 6.1 版本选择 + +- 主操作区使用单一版本选择器,默认现有声明版本(仍在当前环境可用集合时),否则选择当前环境最新可用版本;显式展示存在更新,不自动升级。 +- “最新”限定为当前环境,不表示跨平台最新。来自文本回退的可用性标记为“待 CLI 确认”;解析失败不能渲染成“确定无版本”。 +- 主选择器只列当前环境可用版本。其他平台矩阵只读;查看其它平台不改变命令、不启用添加。 +- 没有当前环境版本时禁用添加,明确原因;未知平台、解析失败、明确不支持平台分别处理。 +- 当前环境平台依据现有 host 检测;工程目标识别属于后续 CLI 契约扩展,首期不以平台下拉框模拟切换编译目标。 + +### 6.2 项目依赖状态 + +统一列表和详情使用的项目状态模型,分别保留:`declaredVersion`、`resolvedVersion`、`dependencyKind`、`sourceType`。短名匹配必须在无歧义时采用;不同命名空间同名不能误判。 + +| 状态 | 主操作和显示 | +| --- | --- | +| 无项目 | 禁用添加;提供选择/打开项目入口 | +| 多项目 | 展示已选择项目;首次不能明确定位时要求选择一次 | +| 不受信任 | 可读本地内容;禁用运行 CLI 的操作并显示原因 | +| 未声明 | “添加到项目” | +| 同版本同分组已声明 | “已声明”,禁用重复操作 | +| 不同版本已声明 | “切换到 X”,同时展示现有声明和锁定值 | +| 同版本不同分组 | 不误判为已完成;仅在 CLI 确认支持安全迁移时提供“改为开发/普通依赖” | +| path/git 依赖 | 明确显示来源;首期不直接以 registry 版本覆盖,保留查看 manifest 入口 | +| 仅 lock 中存在 | 标记“锁定中”,不能当作项目直接声明或已安装 | + +详情页绑定明确项目上下文;切项目后重新生成项目状态。提交时冻结 `{packageKey, projectRoot, version, dependencyKind, requestId, pageGeneration}`,操作期间即使焦点改变也不能重新选择写入项目。 + +首期继续通过 `mcpp add` 修改依赖。实现前验证其分组迁移、依赖替换、锁文件更新语义;未支持的操作应说明限制,不由扩展自行编辑 TOML 来补齐。 + +### 6.3 操作反馈与并发 + +- 点击添加立即进入 pending,按钮显示“正在添加…”,锁定本页版本/分组选择;host 同样去重。 +- 包/项目/页面代次与响应匹配时才更新当前页面;A 的操作完成时,B 页面不接收 A 的已添加标记。A 的完成状态可写入对应项目状态并保存在操作记录中。 +- 成功后重读 manifest/lock,以真实文件更新列表、详情和按钮;不只把请求中的版本当作最终状态。 +- 复制使用按钮旁短暂“已复制”;外链失败在该操作附近提供复制链接/重试。它们不覆盖添加结果。 +- 添加成功默认使用页内反馈;切走详情后使用通知。失败页内显示短原因与“查看输出/重试”,避免整段 stderr 同时填满通知和页面。 +- 首期不新增伪取消按钮。若底层进程没有可靠取消接口,不能只隐藏 pending 却让命令继续运行。 + +## 7. 用法、示例和依赖信息的真实性 + +- 将真实示例、描述符明确声明的引入方式、推导的参考写法分别标注来源。 +- 没有可验证的模块名/头文件路径时显示说明,不能把 `#include ` 或按包名猜出的 import 当作可直接复制的真实用法。 +- 本机打包索引目前没有 `tests/examples`,因此“暂无本地示例”是正常状态,不暗示包不支持使用。可提供已有的索引站链接;不自动联网补齐。 +- 示例提供代码复制与来源文件信息;只在本地确有文件时提供打开文件操作。完整文件查看按需加载,不一次读完所有示例。 +- 依赖信息要区分包描述符声明与当前项目 lock 对照;未确认所属版本时标为描述符层级信息,不能随着版本选择器变化就声称已切换到该版本依赖。 +- 可解析为本地包的依赖支持打开详情;无法解析的显示文本和来源说明。首期不绘制完整依赖图。 + +## 8. 实施拆分 + +| 阶段 | 交付内容 | 主要文件 | +| --- | --- | --- | +| P0:数据与状态正确 | 新旧官方索引定位;默认排除 xim;源身份;依赖状态缓存拆分;操作绑定包与项目 | `indexLocator.ts`、`indexModel.ts`、`detailPanel.ts`、`addDependency.ts`、`extension.ts` | +| P1:完成核心路径 | 列表来源与空态;搜索请求代次;跨源有限预览;详情加载占位;当前环境版本选择;项目与 dev 状态恢复;添加反馈 | `libraryView.ts`、`libraryHtml.ts`、`detailPanel.ts`、`detailHtml.ts` | +| P2:阅读与可达性 | 响应式主辅栏;用法/示例前移;其他平台折叠;来源诊断收起;选择/滚动恢复;键盘与焦点 | `detailHtml.ts`、`libraryHtml.ts`、`media/library.css` | + +可按 P0、P1、P2 分三个可 review 变更,每阶段可独立验收。调整相关中英文文案、设置说明、架构契约和原有测试;旧测试中“所有版本均为按钮”等断言应随最终行为更新。 + +不要求新增公共配置来控制每个布局细节。优先让默认行为正确,减少新增持久化设置。 + +## 9. 验收清单 + +### 9.1 必须覆盖的行为验证 + +1. 新版官方路径 + xim 并存:默认只显示官方源;以测试夹具计数断言,真实机器 252/337 只作本次证据。 +2. 只有旧版官方路径、只有 xim、显式无效路径、显式 checkout、多 home/重复源分别有明确结果。 +3. manifest/lock 保存、CLI 磁盘写入和项目切换后,列表与详情状态一致;未改变索引不重读全量描述符。 +4. 查询 A 慢、查询 B 快以及关闭网络开关:A 结果均不能覆盖 B/重新出现。 +5. 跨源结果可打开有限详情,不出现伪装成索引删除的错误页。 +6. A 包添加期间切到 B/切换项目/关闭再打开页面:A 的结果不得修改 B 或写入新项目。 +7. 当前平台无版本、其它平台独有版本、同版本跨平台、已有 dev、仅 lock、path/git、同名不同命名空间均正确展示。 +8. 添加失败、解析超时、更新失败均可恢复,已有数据保留;取消/关闭加载不能留下旧操作控件。 + +对索引发现、缓存失效、操作关联和竞态补充行为测试;HTML 测试验证语义与可达性,不以大量脆弱的 DOM 字符串断言替代真实交互。 + +### 9.2 人工视觉与交互验收 + +- VS Code 真实窗口:侧边栏约 180/280/420px;详情约 480/800/1200px;浅色、深色、高对比主题。 +- 首屏可判断目标项目、当前环境、所选版本和主操作;查看用法不必先滚过其它平台全部版本。 +- 键盘可完成搜索 → 打开 → 选版本 → 添加;焦点可见,状态有文本,折叠和选择状态有 aria 表达。 +- 搜索不重建文档、不丢光标;更新/重新打开不丢查询和滚动;添加/复制不引发整页刷新。 +- 用网络延迟/解析超时模拟加载过程,确认不展示可操作的上一包内容。 + +性能目标为本地数百包查询输入流畅(单次过滤目标 <50ms),列表不为每个包启动 CLI;优先测量现状,只有实际规模要求时再引入虚拟列表。 + +## 10. 本次 review 建议确认的决策 + +1. **来源范围**:默认只浏览官方 mcpp-index;自定义路径明确标识,其它 registry 通过可选搜索进入。 +2. **侧边栏密度**:保留搜索 + 一个跨源开关;行内只保留关键用法与项目状态,不恢复多行 chips。 +3. **详情层级**:项目/版本/添加放首屏,用法和示例优先,平台矩阵与构建信息降为补充;宽屏双栏、窄屏单栏。 +4. **版本行为**:默认保留现有可用声明版本;其它平台只读,当前环境版本由一个选择器控制。 +5. **跨源边界**:首期做有限预览,未有完整数据的结果不提供一键添加。 +6. **实施顺序**:先 P0 数据与并发正确性,再 P1 核心交互,最后 P2 视觉与键盘细节。 From 5632e5dbe702422115e90702ac80da1c6e7b6268 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 18 Oct 2026 23:13:35 +0900 Subject: [PATCH 02/20] fix(libraries): discover official indexes and separate project state from catalog cache --- src/library/detailPanel.ts | 2 +- src/library/indexLocator.ts | 218 +++++++++--------------------- src/library/projectState.ts | 41 ++++++ test/library/indexLocator.test.ts | 57 ++++++++ 4 files changed, 161 insertions(+), 157 deletions(-) create mode 100644 src/library/projectState.ts create mode 100644 test/library/indexLocator.test.ts diff --git a/src/library/detailPanel.ts b/src/library/detailPanel.ts index b784b26..c467c29 100644 --- a/src/library/detailPanel.ts +++ b/src/library/detailPanel.ts @@ -288,7 +288,7 @@ async function buildModel(session: DetailSession, id: string): Promise/.mcpp/registry/data//pkgs//.lua`. Three - * registries are installed on the reference machine (`mcpplibs` with 239 - * descriptors, `xim-pkgindex`, `xim-pkgindex-local`), and `mcpp index status` - * prints a table of them — which is deliberately **not** parsed: `--format json` - * is an unknown option there, and a second human-output parser is exactly what - * this project forbids. Globbing the data directory under `` gives the - * same answer without parsing anything. - * - * `` is `$MCPP_HOME`, then `~/.mcpp` — every one of them that holds a data - * directory — and when none does, whatever `mcpp self env --format json` - * reports, which is a machine shape and therefore allowed to be read. - * - * Two budgets shape this file: - * - * - **No process per package.** The list is read from the descriptor text - * (`src/library/indexModel.ts`); `mcpp xpkg parse --json` runs on demand, from - * the detail panel, never here. - * - **One read per index revision.** A snapshot is cached and invalidated by a - * cheap revision key (the `pkgs` directory's mtime, the `.mcpp-index-updated` - * marker and the descriptor count), so re-rendering the view after a filter - * change costs a few stats, not 239 file reads. - * - * A missing index is **not** an error: `locateIndexRoots()` returns `[]` and the - * caller renders the message this module hands it. +/** Local package discovery. Official indexes are separate from the xlings tool registry. + * Descriptor contents are cached independently from the project's dependency state. */ - import { promises as fs } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -35,10 +8,11 @@ import * as vscode from "vscode"; import { runMcpp } from "../cli/process"; import { read } from "../config/access"; +import { projectDependencyState } from "./projectState"; import { descriptorEntry, descriptorId, - declaredDependencies, + declaredDependencyEntries, exampleCatalog, openkalFacetFor, parseDescriptorLua, @@ -68,6 +42,8 @@ export const INDEX_SITE = "https://mcpplibs.github.io/mcpp-index/packages"; export interface IndexRoot { /** The directory name, e.g. `mcpplibs`. */ registry: string; + kind: "official" | "custom"; + updatedAt?: number; /** The index root: it contains `pkgs`, and may contain `tests/` and `.xpkgindex/`. */ path: string; /** `/pkgs`. */ @@ -124,125 +100,67 @@ async function isFile(target: string): Promise { } /** One index root, or `undefined` when the directory is not one. */ -async function rootAt(directory: string): Promise { +async function rootAt(directory: string, official = false): Promise { const pkgs = path.join(directory, "pkgs"); if (!(await isDirectory(pkgs))) { return undefined; } + directory = await fs.realpath(directory); const registry = path.basename(directory); + const updated = await statSafe(path.join(directory, ".mcpp-index-updated")); const generator = await isFile(path.join(directory, ".xpkgindex", "plugins", "mcpp.py")); return { registry, + kind: official ? "official" : "custom", + ...(updated === undefined ? {} : { updatedAt: updated.mtimeMs }), path: directory, - pkgs, + pkgs: path.join(directory, "pkgs"), hasExamples: await isDirectory(path.join(directory, "tests", "examples")), hasOpenkal: await isFile(path.join(directory, ".xpkgindex", "openkal-compat.json")), - ...(registry === "mcpplibs" || generator ? { site: INDEX_SITE } : {}), + ...(official || generator ? { site: INDEX_SITE } : {}), }; } -/** - * The index roots to read. - * - * `mcpp.library.indexPath` wins when the user set it — it may name one index - * root (a directory with `pkgs/`), a `pkgs` directory itself, or a whole - * `/.mcpp/registry/data` directory of registries. With no setting, the - * default glob is the data directory under each known mcpp home. - * - * **Finding ``.** Two are tried, in order: `$MCPP_HOME` (the variable mcpp - * itself honours) and `~/.mcpp`. Both are listed when both hold something, and - * the view's footer names the registries it read, so a stale second home shows - * up as an extra source rather than as a wrong answer. When *neither* has a - * `registry/data` with anything in it, mcpp is asked directly — `mcpp self env - * --format json` reports `mcppHome`, which is the only authoritative answer for - * an install in a place nothing can guess. That probe costs one process **per - * session** and only runs when the cheap globs came up empty, so the ordinary - * machine never pays for it; it is also skipped in an untrusted workspace, - * because `mcpp.path` is a resource-scoped setting and a workspace must not - * choose a binary to run. - * - * `[]` means "no index found"; the caller turns that into the message rather - * than into an error, because a machine without the index is a normal machine. - * - * (`locateIndexRoots` is kept as an alias: the name reads better at the call - * site, the exported name is the one `extension.ts` uses.) - */ +/** Explicit paths never silently fall back. Only the official library source is auto-discovered. */ export async function readIndexRoots(): Promise { - const configured = read("mcpp.library.indexPath"); - const candidates: string[] = []; - if (typeof configured === "string" && configured.trim().length > 0) { - const configured_path = path.resolve(expandHome(configured.trim())); - // A `pkgs` directory was named directly. - if (path.basename(configured_path) === "pkgs" && (await isDirectory(configured_path))) { - candidates.push(path.dirname(configured_path)); - } else { - const own = await rootAt(configured_path); - if (own !== undefined) { - candidates.push(own.path); - } else { - // A data directory: every registry inside it. - for (const entry of await readdirSafe(configured_path)) { - candidates.push(path.join(configured_path, entry)); - } - } - } - } else { - for (const data of await defaultDataDirectories()) { - for (const entry of await readdirSafe(data)) { - candidates.push(path.join(data, entry)); - } - } - } + return discoverIndexRoots(read("mcpp.library.indexPath"), await activeHome()); +} - const roots: IndexRoot[] = []; - for (const candidate of candidates) { - const root = await rootAt(candidate); - if (root !== undefined && !roots.some((seen) => seen.path === root.path)) { - roots.push(root); +/** Filesystem seam used by the extension and fixture-based tests. */ +export async function discoverIndexRoots(configured: string, home: string): Promise { + if (configured.trim()) { + const target = path.resolve(expandHome(configured.trim())); + const own = await rootAt(path.basename(target) === "pkgs" ? path.dirname(target) : target); + const roots = own === undefined + ? (await Promise.all((await readdirSafe(target)).sort().map((name) => rootAt(path.join(target, name))))) + .filter((root): root is IndexRoot => root !== undefined) + : [own]; + if (roots.length === 0) { + throw new Error(`No package index (pkgs/) at ${target}`); } + return roots.filter((root, index) => roots.findIndex((other) => other.path === root.path) === index); + } + for (const directory of [path.join(home, "indices", "mcpplibs"), path.join(home, "registry", "data", "mcpplibs")]) { + const root = await rootAt(directory, true); + if (root !== undefined) { return [root]; } } - return roots.sort((a, b) => (a.registry < b.registry ? -1 : a.registry > b.registry ? 1 : 0)); + return []; } -/** Set once per session, so `mcpp self env` runs at most once. */ let probedHome: string | undefined; let probeAttempted = false; -/** The homes mcpp may have used, most specific first, without duplicates. */ -function candidateHomes(): string[] { - const homes: string[] = []; - const fromEnvironment = process.env.MCPP_HOME?.trim(); - if (fromEnvironment !== undefined && fromEnvironment.length > 0) { - homes.push(fromEnvironment); - } - homes.push(path.join(os.homedir(), ".mcpp")); - if (probedHome !== undefined) { - homes.push(probedHome); - } - return [...new Set(homes)]; +/** CLI also supports self-contained installs: ask it once rather than merge unrelated homes. */ +async function activeHome(): Promise { + const explicit = process.env.MCPP_HOME?.trim(); + if (explicit) { return path.resolve(expandHome(explicit)); } + return (await mcppHomeFromCli()) ?? path.join(os.homedir(), ".mcpp"); } -/** - * The `/registry/data` directories worth listing. - * - * The cheap answer first: a data directory that already holds something is the - * answer, and no process is started. Only when all of them are missing or empty - * is `mcpp self env` asked where its home is — and its answer is picked up by - * re-reading the candidate list, because the probe is what adds the home to it. - */ -async function defaultDataDirectories(): Promise { - const candidates = (): string[] => candidateHomes().map((home) => path.join(home, "registry", "data")); - const present: string[] = []; - for (const data of candidates()) { - if ((await readdirSafe(data)).length > 0) { - present.push(data); - } - } - if (present.length > 0) { - return present; - } - await mcppHomeFromCli(); - return [...new Set(candidates())]; +export function invalidateIndexCache(): void { + cache = undefined; + probeAttempted = false; + probedHome = undefined; } /** @@ -324,21 +242,13 @@ async function descriptorFiles(pkgs: string): Promise { return out.sort(); } -/** - * A cheap key that changes when the index does. - * - * The `pkgs` directory's own mtime catches an added or removed letter - * directory; `.mcpp-index-updated` is written whenever mcpp refreshes the index, - * which catches a changed descriptor body. The descriptor count is included so a - * tree that stops being updated still costs one full read when its size changes. - */ +/** Include source identity and every descriptor stat, including checkout edits without a marker. */ async function revisionOf(roots: readonly IndexRoot[]): Promise { const parts: string[] = []; for (const root of roots) { - const pkgs = await statSafe(root.pkgs); - const marker = await statSafe(path.join(root.path, ".mcpp-index-updated")); - const letters = await readdirSafe(root.pkgs); - parts.push(`${root.registry}:${pkgs?.mtimeMs ?? 0}:${marker?.mtimeMs ?? 0}:${letters.length}`); + const files = await descriptorFiles(root.pkgs); + const stats = await Promise.all(files.map(async (file) => `${file}:${JSON.stringify(await statSafe(file))}`)); + parts.push(`${root.path}:${root.kind}:${root.updatedAt ?? ""}:${stats.join(";")}`); } return parts.join("|"); } @@ -416,17 +326,14 @@ async function readOpenkal(roots: readonly IndexRoot[]): Promise> { - if (projectRoot === undefined) { - return new Set(); - } - try { - const text = await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8"); - return new Set(declaredDependencies(text)); - } catch { - return new Set(); - } +/** Project overlay is always fresh and never changes the descriptor cache. */ +async function withProject(snapshot: LibrarySnapshot, projectRoot?: string): Promise { + const text = projectRoot === undefined ? "" : await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8").catch(() => ""); + const declarations = declaredDependencyEntries(text); + const ids = snapshot.entries.map((entry) => entry.id); + return { ...snapshot, entries: snapshot.entries.map((entry) => ({ + ...entry, added: projectDependencyState(entry.id, declarations, [], ids).declared, + })) }; } interface Cache { @@ -447,9 +354,9 @@ let cache: Cache | undefined; */ export async function loadSnapshot(options: { projectRoot?: string; platform?: string } = {}): Promise { const roots = await readIndexRoots(); - const revision = `${await revisionOf(roots)}|${options.projectRoot ?? ""}`; + const revision = `${await revisionOf(roots)}|${options.platform ?? platformKey(process.platform)}`; if (cache !== undefined && cache.revision === revision) { - return cache.snapshot; + return withProject(cache.snapshot, options.projectRoot); } if (roots.length === 0) { const empty: LibrarySnapshot = { entries: [], roots, revision }; @@ -458,7 +365,6 @@ export async function loadSnapshot(options: { projectRoot?: string; platform?: s } const platform = options.platform ?? platformKey(process.platform); - const added = await readAdded(options.projectRoot); const entries: LibraryEntry[] = []; const texts = new Map(); /** Example refs, keyed by package id, from every root that has examples. */ @@ -493,19 +399,19 @@ export async function loadSnapshot(options: { projectRoot?: string; platform?: s file, text, platform, - added: added.has(id), + added: false, ...(example === undefined ? {} : { example }), ...(facet === undefined ? {} : { openkal: facet }), }); entries.push(entry); - texts.set(entry.id, text); + texts.set(entry.file, text); } } entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const snapshot: LibrarySnapshot = { entries, roots, revision }; cache = { revision, snapshot, texts }; - return snapshot; + return withProject(snapshot, options.projectRoot); } /** The descriptor text of one package, from the cache when possible. */ @@ -518,8 +424,8 @@ export async function readDescriptorText(file: string): Promise candidate.slice(candidate.lastIndexOf(".") + 1) === short)).size === 1; + const exact = declarations.filter((entry) => entry.id === id); + const matches = exact.length ? exact : unique ? declarations.filter((entry) => entry.id === short) : []; + const entry = matches[0]; + const resolved = locked.find((entry) => entry.id === id) ?? (unique ? locked.find((entry) => entry.id === short) : undefined); + return { + declared: entry !== undefined, + ...(entry?.version === undefined ? {} : { declaredVersion: entry.version }), + ...(resolved === undefined ? {} : { resolvedVersion: resolved.version }), + dev: entry?.dev ?? false, + sourceType: entry === undefined ? "none" : entry.version === undefined ? "path-or-git" : "registry", + ambiguous: matches.length > 1 || (!unique && exact.length === 0 && declarations.some((entry) => entry.id === short)), + }; +} + +/** Every asynchronous result belongs to both an operation and a rendered page. */ +export function operationMatches( + operation: { key: string; projectRoot?: string; generation: number }, + page: { key: string; projectRoot?: string; generation: number }, +): boolean { + return operation.key === page.key && operation.projectRoot === page.projectRoot && operation.generation === page.generation; +} diff --git a/test/library/indexLocator.test.ts b/test/library/indexLocator.test.ts new file mode 100644 index 0000000..923fe92 --- /dev/null +++ b/test/library/indexLocator.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import Module, { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { projectDependencyState, operationMatches } from "../../src/library/projectState"; + +const loader = Module as unknown as { _load(request: string, parent: unknown, main: boolean): unknown }; +const original = loader._load; +loader._load = function (request, parent, main) { + return request === "vscode" ? {} : original.call(loader, request, parent, main); +}; +const locator: typeof import("../../src/library/indexLocator") = createRequire(__filename)("../../src/library/indexLocator"); +loader._load = original; + +test("official discovery prefers new layout, never substitutes xim, and honors explicit paths", async () => { + const home = await fs.mkdtemp(path.join(tmpdir(), "mcpp-index-test-")); + const make = async (relative: string) => fs.mkdir(path.join(home, relative, "pkgs"), { recursive: true }); + try { + await make("registry/data/xim-pkgindex"); + assert.deepEqual(await locator.discoverIndexRoots("", home), []); + await make("registry/data/mcpplibs"); + assert.equal((await locator.discoverIndexRoots("", home))[0].path, path.join(home, "registry/data/mcpplibs")); + await make("indices/mcpplibs"); + const roots = await locator.discoverIndexRoots("", home); + assert.equal(roots.length, 1); + assert.equal(roots[0].path, path.join(home, "indices/mcpplibs")); + assert.equal(roots[0].kind, "official"); + const custom = await locator.discoverIndexRoots(path.join(home, "registry/data/xim-pkgindex/pkgs"), home); + assert.equal(custom[0].kind, "custom"); + assert.equal(custom[0].site, undefined); + assert.equal((await locator.discoverIndexRoots(path.join(home, "registry/data"), home)).length, 2); + await assert.rejects(locator.discoverIndexRoots(path.join(home, "missing"), home), /No package index/); + } finally { await fs.rm(home, { recursive: true, force: true }); } +}); + +test("project state distinguishes declarations, lock-only edges, source dependencies and ambiguous short names", () => { + const catalog = ["compat.zlib", "other.zlib", "compat.argparse"]; + assert.equal(projectDependencyState("compat.zlib", [{ id: "zlib", version: "1", dev: false }], [], catalog).ambiguous, true); + const state = projectDependencyState("compat.argparse", [{ id: "argparse", version: "3.2", dev: true }], [{ id: "compat.argparse", version: "3.1" }], catalog); + assert.equal(state.declaredVersion, "3.2"); + assert.equal(state.resolvedVersion, "3.1"); + assert.equal(state.dev, true); + const lockOnly = projectDependencyState("compat.zlib", [], [{ id: "compat.zlib", version: "1" }], catalog); + assert.equal(lockOnly.declared, false); + assert.equal(lockOnly.resolvedVersion, "1"); + assert.equal(projectDependencyState("compat.argparse", [{ id: "argparse", dev: false }], [], catalog).sourceType, "path-or-git"); +}); + +test("async results cannot cross package, project or page lifetime", () => { + const operation = { key: "a", projectRoot: "/one", generation: 1 }; + assert.equal(operationMatches(operation, { ...operation }), true); + for (const page of [{ ...operation, key: "b" }, { ...operation, projectRoot: "/two" }, { ...operation, generation: 2 }]) { + assert.equal(operationMatches(operation, page), false); + } +}); From 6ed10920e865bad44d9275e998e1ae20459f4fae Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 18 Oct 2026 23:24:51 +0900 Subject: [PATCH 03/20] feat(libraries): checkpoint project-aware browsing and responsive package details --- .vscodeignore | 1 + data/config-registry.json | 4 +- data/i18n/zh-cn.json | 59 +++- docs/settings.md | 8 +- l10n/bundle.l10n.zh-cn.json | 59 +++- media/library.css | 54 ++++ package.json | 3 +- package.nls.json | 4 +- package.nls.zh-cn.json | 4 +- src/extension.ts | 58 +++- src/library/addDependency.ts | 13 +- src/library/detailHtml.ts | 480 +++++++++++++----------------- src/library/detailPanel.ts | 366 +++++++++++++---------- src/library/indexLocator.ts | 8 +- src/library/libraryHtml.ts | 196 +++++++----- src/library/libraryView.ts | 129 +++++--- src/library/projectState.ts | 39 ++- test/artifacts.test.ts | 10 +- test/library/detailHtml.test.ts | 138 +++------ test/library/indexLocator.test.ts | 62 +++- test/library/libraryHtml.test.ts | 8 +- 21 files changed, 1008 insertions(+), 695 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index fbbedb1..483bb46 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -2,6 +2,7 @@ .github/** .gitignore .agents/** +.agnets/** # 本地验收 profile(tools/dev-profile.mjs 生成)与 e2e 的 VS Code 缓存都不属于产物 .dev-profile/** .vscode-test/** diff --git a/data/config-registry.json b/data/config-registry.json index 8c2e425..2f08d91 100644 --- a/data/config-registry.json +++ b/data/config-registry.json @@ -783,7 +783,7 @@ "applies": "immediate", "since": "0.6.0", "title": "Path to a mcpp package index checkout", - "description": "Leave it empty to use the indexes under the mcpp home. The home is `$MCPP_HOME`, then `~/.mcpp`; when neither holds an index, the extension asks `mcpp self env --format json` where it is.", + "description": "Leave empty to browse the official mcpp-index at /indices/mcpplibs (legacy registry/data/mcpplibs is supported). MCPP_HOME takes precedence; otherwise the extension asks mcpp self env. Set a directory containing pkgs/ or a collection of indexes to browse a custom source.", "order": 8 }, { @@ -796,7 +796,7 @@ "applies": "immediate", "since": "0.6.0", "title": "Search every configured registry over the network", - "description": "The local index already lists every published package, so this is off by default. Turning it on also searches the other registries with `mcpp search`, which may use the network and can only be parsed on a best-effort basis.", + "description": "Off by default. Also searches other registries with mcpp search; remote results have limited previews and cannot be added directly.", "order": 9 }, { diff --git a/data/i18n/zh-cn.json b/data/i18n/zh-cn.json index a326dd4..ffe7ed3 100644 --- a/data/i18n/zh-cn.json +++ b/data/i18n/zh-cn.json @@ -471,5 +471,62 @@ "Only C++ Modules is serving C++": "仅 C++ Modules 在提供 C++ 语言服务", "No other C++ language service (C/C++, clangd, ccls) is active, so no result is shown twice.": "没有其他 C++ 语言服务(C/C++、clangd、ccls)在工作,结果不会重复出现。", "Other C++ language services": "其他 C++ 语言服务", - "They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere.": "它们也在处理同一批 C++ 文件,结果会出现两次。点击即可关闭它们的 C++ 语言功能;C++ Modules 会询问只对本工作区还是对所有地方生效。" + "They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere.": "它们也在处理同一批 C++ 文件,结果会出现两次。点击即可关闭它们的 C++ 语言功能;C++ Modules 会询问只对本工作区还是对所有地方生效。", + "Libraries": "库", + "Source details": "来源详情", + "Other registries": "其它 registry", + "Set index path": "设置索引路径", + "Update mcpp index": "更新 mcpp 索引", + "Show output": "查看输出", + "Clear search": "清空搜索", + "Search other registries": "搜索其它 registry", + "Declared": "已声明", + "Reread locally": "重新读取本地索引", + "Could not read the index: {0}": "无法读取索引:{0}", + "Searching other registries…": "正在搜索其它 registry…", + "Other registry": "其它 registry", + "Official index": "官方索引", + "Custom index": "自定义索引", + "Browse libraries; select an mcpp project in the detail page to add dependencies.": "可以浏览库;请在详情页选择 mcpp 项目后添加依赖。", + "No local mcpp-index found": "未找到本地 mcpp-index", + "{0} · {1} packages · local": "{0} · {1} 个包 · 本地", + "Target project": "目标项目", + "Choose project": "选择项目", + "Environment platform": "当前环境平台", + "Version": "版本", + "Other platforms (read only)": "其它平台(只读)", + "Resolved in lockfile": "锁定解析", + "Source dependency": "源码依赖", + "No verified import or header path is available locally. See the repository or index site for usage.": "本地没有可验证的模块名或头文件路径,请查看仓库或索引站的用法说明。", + "From the index's example source": "来自索引示例源码", + "Source and diagnostics": "来源与诊断", + "Reading package details…": "正在读取包详情…", + "Adding dependency…": "正在添加依赖…", + "Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration.": "此处暂不支持迁移依赖分组,请编辑 mcpp.toml 移动声明。", + "Retry reading details": "重新读取详情", + "Open mcpp.toml": "打开 mcpp.toml", + "Open source file": "打开源文件", + "Search preview: only the fields returned by mcpp search are available.": "搜索预览:仅展示 mcpp search 返回的字段。", + "Copied": "已复制", + "Copy failed": "复制失败", + "Opened in your browser.": "已在浏览器中打开。", + "Could not open the browser. Link copied to clipboard.": "无法打开浏览器,链接已复制到剪贴板。", + "Could not open the browser or copy the link: {0}": "无法打开浏览器或复制链接:{0}", + "Could not add the dependency. See output for details.": "无法添加依赖,请查看输出了解详情。", + "Could not read package details. See output for details.": "无法读取包详情,请查看输出了解详情。", + "This is a search preview. Set an index path containing this package to read its complete details.": "这是搜索预览。将索引路径设置为包含此包的目录后,可读取完整详情。", + "Search previews cannot be added. Open a complete local descriptor first.": "搜索预览无法直接添加,请先打开完整的本地包描述。", + "The package descriptor is missing. Reread the local index.": "包描述不存在,请重新读取本地索引。", + "Trust this workspace to run mcpp and add dependencies.": "信任此工作区后,才能运行 mcpp 和添加依赖。", + "Select an mcpp project before adding this library.": "请先选择 mcpp 项目,再添加此库。", + "This dependency name is ambiguous. Use a qualified package name in mcpp.toml.": "此依赖名称有歧义,请在 mcpp.toml 中使用完整包名。", + "This project uses a source dependency. Edit its manifest to change the source.": "项目已声明源码依赖,请编辑清单修改来源。", + "Version availability could not be confirmed. Retry reading this descriptor.": "暂时无法确认版本可用性,请重新读取包描述。", + "No version is published for the current environment platform.": "当前环境平台没有可用的发布版本。", + "Already declared": "已声明", + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.": "这里展示包描述中的依赖,所选版本的依赖可能不同;解析版本来自当前项目的锁文件。", + "mcpp add failed (exit {0}). See output for details.": "mcpp add 失败(退出码 {0}),请查看输出了解详情。", + "Choose the target mcpp project": "选择目标 mcpp 项目", + "Dependencies will be added to this project's mcpp.toml": "依赖将添加到此项目的 mcpp.toml", + "Updating the global mcpp index; a configured custom checkout is only reread.": "正在更新全局 mcpp 索引;已配置的自定义检出目录仅会被重新读取。" } diff --git a/docs/settings.md b/docs/settings.md index 2443de8..a343afe 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -738,9 +738,9 @@ Age buckets used to group cache entries in the cache view, written as "1d", "7d" ### `mcpp.library.indexPath` — advanced -Leave it empty to use the indexes under the mcpp home. The home is `$MCPP_HOME`, then `~/.mcpp`; when neither holds an index, the extension asks `mcpp self env --format json` where it is. +Leave empty to browse the official mcpp-index at /indices/mcpplibs (legacy registry/data/mcpplibs is supported). MCPP_HOME takes precedence; otherwise the extension asks mcpp self env. Set a directory containing pkgs/ or a collection of indexes to browse a custom source. -留空则使用 mcpp 主目录下的索引。主目录依次取 `$MCPP_HOME`、`~/.mcpp`;两者都没有索引时,扩展会用 `mcpp self env --format json` 询问 mcpp 主目录在哪。 +留空则读取 mcpp 主目录下 indices/mcpplibs 中的官方 mcpp-index,兼容旧位置 registry/data/mcpplibs。优先使用 MCPP_HOME,否则通过 mcpp self env 获取主目录。可指定带 pkgs/ 的目录或索引集合目录来浏览自定义来源。 | | | |---|---| @@ -752,9 +752,9 @@ Leave it empty to use the indexes under the mcpp home. The home is `$MCPP_HOME`, ### `mcpp.library.networkSearch` -The local index already lists every published package, so this is off by default. Turning it on also searches the other registries with `mcpp search`, which may use the network and can only be parsed on a best-effort basis. +Off by default. Also searches other registries with mcpp search; remote results have limited previews and cannot be added directly. -本地索引已经列出所有已发布的包,所以默认关闭。打开后会额外用 `mcpp search` 搜索其它 registry,可能联网,且只能尽力解析。 +默认关闭。开启后额外用 mcpp search 搜索其它 registry;搜索结果仅提供有限预览,不能直接添加。 | | | |---|---| diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index a326dd4..ffe7ed3 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -471,5 +471,62 @@ "Only C++ Modules is serving C++": "仅 C++ Modules 在提供 C++ 语言服务", "No other C++ language service (C/C++, clangd, ccls) is active, so no result is shown twice.": "没有其他 C++ 语言服务(C/C++、clangd、ccls)在工作,结果不会重复出现。", "Other C++ language services": "其他 C++ 语言服务", - "They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere.": "它们也在处理同一批 C++ 文件,结果会出现两次。点击即可关闭它们的 C++ 语言功能;C++ Modules 会询问只对本工作区还是对所有地方生效。" + "They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere.": "它们也在处理同一批 C++ 文件,结果会出现两次。点击即可关闭它们的 C++ 语言功能;C++ Modules 会询问只对本工作区还是对所有地方生效。", + "Libraries": "库", + "Source details": "来源详情", + "Other registries": "其它 registry", + "Set index path": "设置索引路径", + "Update mcpp index": "更新 mcpp 索引", + "Show output": "查看输出", + "Clear search": "清空搜索", + "Search other registries": "搜索其它 registry", + "Declared": "已声明", + "Reread locally": "重新读取本地索引", + "Could not read the index: {0}": "无法读取索引:{0}", + "Searching other registries…": "正在搜索其它 registry…", + "Other registry": "其它 registry", + "Official index": "官方索引", + "Custom index": "自定义索引", + "Browse libraries; select an mcpp project in the detail page to add dependencies.": "可以浏览库;请在详情页选择 mcpp 项目后添加依赖。", + "No local mcpp-index found": "未找到本地 mcpp-index", + "{0} · {1} packages · local": "{0} · {1} 个包 · 本地", + "Target project": "目标项目", + "Choose project": "选择项目", + "Environment platform": "当前环境平台", + "Version": "版本", + "Other platforms (read only)": "其它平台(只读)", + "Resolved in lockfile": "锁定解析", + "Source dependency": "源码依赖", + "No verified import or header path is available locally. See the repository or index site for usage.": "本地没有可验证的模块名或头文件路径,请查看仓库或索引站的用法说明。", + "From the index's example source": "来自索引示例源码", + "Source and diagnostics": "来源与诊断", + "Reading package details…": "正在读取包详情…", + "Adding dependency…": "正在添加依赖…", + "Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration.": "此处暂不支持迁移依赖分组,请编辑 mcpp.toml 移动声明。", + "Retry reading details": "重新读取详情", + "Open mcpp.toml": "打开 mcpp.toml", + "Open source file": "打开源文件", + "Search preview: only the fields returned by mcpp search are available.": "搜索预览:仅展示 mcpp search 返回的字段。", + "Copied": "已复制", + "Copy failed": "复制失败", + "Opened in your browser.": "已在浏览器中打开。", + "Could not open the browser. Link copied to clipboard.": "无法打开浏览器,链接已复制到剪贴板。", + "Could not open the browser or copy the link: {0}": "无法打开浏览器或复制链接:{0}", + "Could not add the dependency. See output for details.": "无法添加依赖,请查看输出了解详情。", + "Could not read package details. See output for details.": "无法读取包详情,请查看输出了解详情。", + "This is a search preview. Set an index path containing this package to read its complete details.": "这是搜索预览。将索引路径设置为包含此包的目录后,可读取完整详情。", + "Search previews cannot be added. Open a complete local descriptor first.": "搜索预览无法直接添加,请先打开完整的本地包描述。", + "The package descriptor is missing. Reread the local index.": "包描述不存在,请重新读取本地索引。", + "Trust this workspace to run mcpp and add dependencies.": "信任此工作区后,才能运行 mcpp 和添加依赖。", + "Select an mcpp project before adding this library.": "请先选择 mcpp 项目,再添加此库。", + "This dependency name is ambiguous. Use a qualified package name in mcpp.toml.": "此依赖名称有歧义,请在 mcpp.toml 中使用完整包名。", + "This project uses a source dependency. Edit its manifest to change the source.": "项目已声明源码依赖,请编辑清单修改来源。", + "Version availability could not be confirmed. Retry reading this descriptor.": "暂时无法确认版本可用性,请重新读取包描述。", + "No version is published for the current environment platform.": "当前环境平台没有可用的发布版本。", + "Already declared": "已声明", + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.": "这里展示包描述中的依赖,所选版本的依赖可能不同;解析版本来自当前项目的锁文件。", + "mcpp add failed (exit {0}). See output for details.": "mcpp add 失败(退出码 {0}),请查看输出了解详情。", + "Choose the target mcpp project": "选择目标 mcpp 项目", + "Dependencies will be added to this project's mcpp.toml": "依赖将添加到此项目的 mcpp.toml", + "Updating the global mcpp index; a configured custom checkout is only reread.": "正在更新全局 mcpp 索引;已配置的自定义检出目录仅会被重新读取。" } diff --git a/media/library.css b/media/library.css index 7d59070..42bb40e 100644 --- a/media/library.css +++ b/media/library.css @@ -630,3 +630,57 @@ pre.detail-code code { font-size: 10px; border-top: 1px solid var(--vscode-panel-border, transparent); } + +/* Libraries: keep the search control stable while replacing results in place. */ +.toolbar { position: sticky; } +.search { padding-right: 24px; } +.clear-search { position: absolute; right: 18px; top: 10px; font-size: 16px; } +.row[aria-current="true"] { background: var(--vscode-list-inactiveSelectionBackground); box-shadow: inset 2px 0 var(--vscode-focusBorder); } +.group-title { margin: 12px 10px 4px; font-size: 11px; color: var(--vscode-descriptionForeground); } +.search-status { margin: 8px 10px; color: var(--vscode-descriptionForeground); } +.footer details { margin-bottom: 6px; } +.footer summary, .detail-footer summary, .detail-aside summary { cursor: pointer; } +.footer-actions { display: flex; flex-wrap: wrap; gap: 6px 12px; } +.source { white-space: pre-wrap; } +.project-notice { margin-top: 2px; } + +body.detail { max-width: 1120px; margin: 0 auto; padding: 0 24px 40px; } +.detail-title { overflow-wrap: anywhere; } +.detail-primary { padding: 12px 0 4px; } +.project-context, .detail-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 8px 0; } +.project-context { color: var(--vscode-descriptionForeground); } +.project-context strong { font-weight: 500; overflow-wrap: anywhere; } +.detail-controls label { display: flex; align-items: center; gap: 6px; } +.detail-controls select { padding: 3px 8px; font: inherit; color: var(--vscode-dropdown-foreground); background: var(--vscode-dropdown-background); border: 1px solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent)); } +.detail-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 28px; } +.detail-layout > *, .detail-command-row > *, .detail-usage-line > * { min-width: 0; } +.detail-aside { min-width: 0; } +.detail-aside .detail-version-group { display: block; } +.detail-aside .detail-platform { display: block; margin-bottom: 4px; } +.detail-version-list { display: flex; flex-wrap: wrap; gap: 4px; } +span.detail-version { cursor: default; color: var(--vscode-descriptionForeground); background: var(--vscode-textCodeBlock-background); border-color: transparent; } +span.detail-version[data-installed] { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); } +.detail-usage > h2:not(:first-child) { margin-top: 20px; } +.detail-command { white-space: pre; } +.detail-command-row { min-width: 0; } +.detail-command-row .detail-command { flex: 1; } +.detail-command-row .detail-copy { flex-shrink: 0; } +.snippet-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 10px; } +.result-actions { display: flex; flex-wrap: wrap; gap: 12px; } +button:disabled, select:disabled { cursor: default; opacity: 0.65; } +button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } +.detail-footer { overflow-wrap: anywhere; } +@media (max-width: 899px) { + .detail-layout { display: block; } + body.detail { padding-left: 16px; padding-right: 16px; } +} +@media (max-width: 520px) { + .detail-actions { gap: 8px; } + .detail-actions button { padding: 5px 8px; } + .detail-command-row { flex-wrap: wrap; } + .detail-command-row .detail-command { flex-basis: 100%; } +} + +.toolbar .count { margin: 0 0 0 auto; align-self: center; } +.detail-actions .link-button { color: var(--vscode-textLink-foreground); background: transparent; border-color: transparent; padding: 4px 0; } +.detail-actions .link-button:hover { background: transparent; text-decoration: underline; } diff --git a/package.json b/package.json index 1e279de..622c194 100644 --- a/package.json +++ b/package.json @@ -315,7 +315,8 @@ { "command": "mcpp.library.updateIndex", "title": "%command.mcpp.library.updateIndex.title%", - "category": "%category%" + "category": "%category%", + "icon": "$(cloud-download)" } ], "configuration": { diff --git a/package.nls.json b/package.nls.json index eef1192..28edf8a 100644 --- a/package.nls.json +++ b/package.nls.json @@ -101,9 +101,9 @@ "mcpp.languageService.refreshAfterBuild.title": "Refresh language service after build", "mcpp.languageService.stateRefreshSeconds.description": "Re-read the language service state every this many seconds. 0 disables the timer.", "mcpp.languageService.stateRefreshSeconds.title": "Set the state refresh interval", - "mcpp.library.indexPath.description": "Leave it empty to use the indexes under the mcpp home. The home is `$MCPP_HOME`, then `~/.mcpp`; when neither holds an index, the extension asks `mcpp self env --format json` where it is.", + "mcpp.library.indexPath.description": "Leave empty to browse the official mcpp-index at /indices/mcpplibs (legacy registry/data/mcpplibs is supported). MCPP_HOME takes precedence; otherwise the extension asks mcpp self env. Set a directory containing pkgs/ or a collection of indexes to browse a custom source.", "mcpp.library.indexPath.title": "Path to a mcpp package index checkout", - "mcpp.library.networkSearch.description": "The local index already lists every published package, so this is off by default. Turning it on also searches the other registries with `mcpp search`, which may use the network and can only be parsed on a best-effort basis.", + "mcpp.library.networkSearch.description": "Off by default. Also searches other registries with mcpp search; remote results have limited previews and cannot be added directly.", "mcpp.library.networkSearch.title": "Search every configured registry over the network", "mcpp.log.level.description": "Amount of detail written to the mcpp output channel. Debug output is verbose.", "mcpp.log.level.title": "Set the log level", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 33cc3ad..a4d6a7d 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -101,9 +101,9 @@ "mcpp.languageService.refreshAfterBuild.title": "构建后刷新语言服务", "mcpp.languageService.stateRefreshSeconds.description": "每隔多少秒重新读取一次语言服务状态。0 表示关闭定时刷新。", "mcpp.languageService.stateRefreshSeconds.title": "设置状态刷新间隔", - "mcpp.library.indexPath.description": "留空则使用 mcpp 主目录下的索引。主目录依次取 `$MCPP_HOME`、`~/.mcpp`;两者都没有索引时,扩展会用 `mcpp self env --format json` 询问 mcpp 主目录在哪。", + "mcpp.library.indexPath.description": "留空则读取 mcpp 主目录下 indices/mcpplibs 中的官方 mcpp-index,兼容旧位置 registry/data/mcpplibs。优先使用 MCPP_HOME,否则通过 mcpp self env 获取主目录。可指定带 pkgs/ 的目录或索引集合目录来浏览自定义来源。", "mcpp.library.indexPath.title": "mcpp 包索引检出的路径", - "mcpp.library.networkSearch.description": "本地索引已经列出所有已发布的包,所以默认关闭。打开后会额外用 `mcpp search` 搜索其它 registry,可能联网,且只能尽力解析。", + "mcpp.library.networkSearch.description": "默认关闭。开启后额外用 mcpp search 搜索其它 registry;搜索结果仅提供有限预览,不能直接添加。", "mcpp.library.networkSearch.title": "在全部 registry 中联网搜索", "mcpp.log.level.description": "写入 mcpp 输出频道的详细程度。debug 输出较为冗长。", "mcpp.log.level.title": "设置日志级别", diff --git a/src/extension.ts b/src/extension.ts index f4ab0ec..59d03e7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -40,7 +40,7 @@ import { languagePreference, setLanguagePreference, t, type LanguagePreference } import { readCacheSnapshot, registerCacheView } from "./cache/cacheView"; import { registerLanguageServerCommands } from "./views/languageServerView"; import { createLibraryDetailOpener } from "./library/detailPanel"; -import { loadSnapshot } from "./library/indexLocator"; +import { loadSnapshot, invalidateIndexCache } from "./library/indexLocator"; import { registerLibraryView } from "./library/libraryView"; import { registerProjectView } from "./views/projectView"; @@ -322,22 +322,59 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi // The library ecosystem: a sidebar view plus an editor-area package page. The // detail page writes `mcpp.toml` through `mcpp add`, so it tells the view to // refresh — no save event fires for a file written outside the editor. + // Keep the selected library project when an editor-area webview takes focus. + let libraryProjectRoot: string | undefined = vscode.window.activeTextEditor?.document.uri.scheme === "file" + || vscode.workspace.workspaceFolders?.length === 1 ? findCurrentProject()?.root : undefined; + const libraryProject = (): string | undefined => libraryProjectRoot; + const chooseLibraryProject = async (): Promise => { + const manifests = await vscode.workspace.findFiles(MCPP_MANIFEST_GLOB, "**/{node_modules,target,.git}/**", 200); + const roots = [...new Set(manifests.map((uri) => uri.fsPath.slice(0, -"/mcpp.toml".length)))]; + const active = libraryProject(); + if (active && !roots.includes(active)) { roots.unshift(active); } + const selected = await vscode.window.showQuickPick(roots.map((root) => ({ label: vscode.workspace.asRelativePath(root), description: root, root })), { + title: t("Choose the target mcpp project"), placeHolder: t("Dependencies will be added to this project's mcpp.toml"), + }); + if (selected) { libraryProjectRoot = selected.root; } + return selected?.root; + }; const openLibraryDetail = createLibraryDetailOpener(extensionContext, { - mcppExecutable: () => cliController.mcppExecutable(findCurrentProject()), - projectRoot: () => findCurrentProject()?.root, + mcppExecutable: (root) => cliController.mcppExecutable(root ? { root, manifestPath: `${root}/mcpp.toml` } : undefined), + projectRoot: libraryProject, + chooseProject: chooseLibraryProject, output, isTrusted: () => vscode.workspace.isTrusted, - onDependenciesChanged: () => void library.refresh(), + onDependenciesChanged: () => { void library.refresh(); }, }); const library = registerLibraryView(extensionContext, { - projectRoot: () => findCurrentProject()?.root, - mcppExecutable: () => cliController.mcppExecutable(findCurrentProject()), + projectRoot: libraryProject, + mcppExecutable: () => cliController.mcppExecutable(libraryProjectRoot ? { root: libraryProjectRoot, manifestPath: `${libraryProjectRoot}/mcpp.toml` } : undefined), openDetail: openLibraryDetail, output, - // The cross-registry search runs `mcpp search`, and `mcpp.path` is - // resource-scoped: an untrusted workspace must not name the program. isTrusted: () => vscode.workspace.isTrusted, }); + const dependencyWatcher = vscode.workspace.createFileSystemWatcher("**/mcpp.{toml,lock}"); + let libraryTimer: NodeJS.Timeout | undefined; + const refreshLibraries = (): void => { + if (libraryTimer) { clearTimeout(libraryTimer); } + libraryTimer = setTimeout(() => { + libraryTimer = undefined; + void library.refresh(); + void openLibraryDetail.refresh(); + }, 120); + }; + extensionContext.subscriptions.push( + dependencyWatcher, + dependencyWatcher.onDidCreate(refreshLibraries), + dependencyWatcher.onDidChange(refreshLibraries), + dependencyWatcher.onDidDelete(refreshLibraries), + vscode.window.onDidChangeActiveTextEditor((editor) => { if (editor?.document.uri.scheme === "file") { const active = findCurrentProject()?.root; if (active) { libraryProjectRoot = active; } refreshLibraries(); } }), + vscode.workspace.onDidChangeWorkspaceFolders(() => { libraryProjectRoot = undefined; refreshLibraries(); }), + vscode.workspace.onDidGrantWorkspaceTrust(refreshLibraries), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.library") || event.affectsConfiguration("mcpp.path") || event.affectsConfiguration("mcpp.ui.language")) { refreshLibraries(); } + }), + { dispose: () => { if (libraryTimer) { clearTimeout(libraryTimer); } } }, + ); // `registerLibraryView` registers its own webview provider; these are only the // entry points a menu or the project view can name. extensionContext.subscriptions.push( @@ -357,6 +394,9 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return; } const project = findCurrentProject(); + if (read("mcpp.library.indexPath").trim()) { + output.appendLine(t("Updating the global mcpp index; a configured custom checkout is only reread.")); + } // A refresh that reaches the network is worth a progress notification, and // a refresh that succeeds has to *say so*: the command produced no visible // change at all before, which reads exactly like a broken button. @@ -367,7 +407,9 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi timeoutMs: 300_000, }), ); + invalidateIndexCache(); await library.refresh(); + await openLibraryDetail.refresh(); if (result === undefined || result.exitCode !== 0) { void vscode.window.showErrorMessage( result === undefined diff --git a/src/library/addDependency.ts b/src/library/addDependency.ts index d761851..48d7d2b 100644 --- a/src/library/addDependency.ts +++ b/src/library/addDependency.ts @@ -38,6 +38,7 @@ export interface AddDependencyDeps { output?: vscode.OutputChannel; /** `vscode.workspace.isTrusted`; an untrusted workspace may not be written to. */ isTrusted: () => boolean; + quiet?: boolean; } export interface AddDependencyRequest { @@ -82,18 +83,18 @@ export async function addDependency( const version = request.version?.trim() ?? ""; if (request.id.trim().length === 0 || version.length === 0) { const message = t("mcpp add needs an exact version: mcpp add {0}@.", request.id); - void vscode.window.showWarningMessage(message); + if (!deps.quiet) void vscode.window.showWarningMessage(message); return failure(message); } const root = deps.projectRoot(); if (root === undefined) { const message = t("This workspace has no mcpp.toml."); - void vscode.window.showWarningMessage(message); + if (!deps.quiet) void vscode.window.showWarningMessage(message); return failure(message); } if (!deps.isTrusted()) { const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); - void vscode.window.showWarningMessage(message); + if (!deps.quiet) void vscode.window.showWarningMessage(message); return failure(message); } @@ -128,7 +129,7 @@ export async function addDependency( if (result.exitCode === 0) { const message = t("Added {0} to mcpp.toml.", `${request.id}@${version}`); - void vscode.window.showInformationMessage(message); + if (!deps.quiet) void vscode.window.showInformationMessage(message); return { ok: true, argv, exitCode: 0, message }; } @@ -136,7 +137,7 @@ export async function addDependency( const message = detail.length === 0 ? t("mcpp add failed with exit code {0}.", result.exitCode) - : t("mcpp add failed with exit code {0}: {1}", result.exitCode, detail); - void vscode.window.showErrorMessage(message); + : t("mcpp add failed (exit {0}). See output for details.", result.exitCode); + if (!deps.quiet) void vscode.window.showErrorMessage(message); return { ok: false, argv, exitCode: result.exitCode, message }; } diff --git a/src/library/detailHtml.ts b/src/library/detailHtml.ts index 1c6d292..b49f622 100644 --- a/src/library/detailHtml.ts +++ b/src/library/detailHtml.ts @@ -17,6 +17,8 @@ * keyword list and punctuation. It is not a parser and is not meant to be. */ +import type { ProjectDependencyState } from "./projectState"; + import { BADGE_UI, surfaceLabel, @@ -39,6 +41,7 @@ export interface DetailDependency { version?: string; /** The version the project's `mcpp.lock` resolved, when it is known. */ resolved?: string; + key?: string; } /** One window of real example code. */ @@ -47,6 +50,7 @@ export interface DetailSnippet { startLine: number; lines: string[]; usageLine: number; + fileKey?: string; } /** The outcome of the last `mcpp add`, shown in place. */ @@ -66,6 +70,15 @@ export interface DetailInstalled { export interface DetailModel { /** Everything is already localized by the caller. */ ui: Record; + key?: string; + generation?: number; + projectRoot?: string; + platform?: string; + projectState?: ProjectDependencyState; + blockedReason?: string; + loading?: boolean; + preview?: boolean; + busy?: boolean; id: string; name: string; description?: string; @@ -123,6 +136,25 @@ export interface DetailAssets { export const DETAIL_UI = { htmlLang: "detail.htmlLang", title: "detail.title", + project: "detail.project", + chooseProject: "detail.chooseProject", + platform: "detail.platform", + version: "detail.version", + otherPlatforms: "detail.otherPlatforms", + declared: "detail.declared", + locked: "detail.locked", + sourceDependency: "detail.sourceDependency", + unknownUsage: "detail.unknownUsage", + usageSource: "detail.usageSource", + source: "detail.source", + loading: "detail.loading", + pending: "detail.pending", + groupChange: "detail.groupChange", + output: "detail.output", + retry: "detail.retry", + openManifest: "detail.openManifest", + openFile: "detail.openFile", + preview: "detail.preview", overview: "detail.overview", license: "detail.license", repo: "detail.repo", @@ -189,7 +221,9 @@ const BADGE_KEY_UI: Readonly> = { export type DetailMessage = | { type: "add"; version: string; dev: boolean } | { type: "openUrl"; url: string } - | { type: "copy"; text: string }; + | { type: "copy"; text: string; token?: string } + | { type: "selectProject" | "showOutput" | "retry" | "openManifest" | "mounted" } + | { type: "openDependency" | "openFile"; key: string }; export type UiLabel = (key: string) => string; @@ -247,6 +281,7 @@ function renderSnippets(model: DetailModel, label: UiLabel): string { .join(""); const caption = fill(label, DETAIL_UI.codeSource, [snippet.file, snippet.startLine]); return [ + `
${snippet.fileKey === undefined ? "" : ``}
`, `
${lines}
`, `

${escapeHtml(caption)}

`, ].join("\n"); @@ -264,42 +299,14 @@ function renderSnippets(model: DetailModel, label: UiLabel): string { * list is both. */ function renderVersions(model: DetailModel, label: UiLabel): string { - if (model.versions.length === 0) { - return `

${escapeHtml(label(DETAIL_UI.versionsNone))}

`; - } - const groups = model.versions - .map((group) => { - const list = - group.versions.length === 0 - ? `—` - : group.versions - .map((version) => { - const installed = model.installed !== undefined && model.installed.version === version; - return ( - `` + - // The word, not the colour: a reader who cannot see the hue still - // learns that this is the version in their manifest. - (installed ? `${escapeHtml(label(DETAIL_UI.installed))}` : "") - ); - }) - .join(""); - return ( - `
  • ` + - `${escapeHtml(group.platform)}` + - `${list}` + - (group.current ? `${escapeHtml(label(DETAIL_UI.versionsCurrent))}` : "") + - `
  • ` - ); - }) - .join("\n"); - return [ - `
      `, - groups, - `
    `, - `

    ${escapeHtml(label(DETAIL_UI.versionsPick))}

    `, - ].join("\n"); + if (!model.versions.length) { return `

    ${escapeHtml(label(DETAIL_UI.versionsNone))}

    `; } + const row = (group: DetailVersionGroup) => `
  • + ${escapeHtml(group.platform)} + ${group.versions.map((version) => `${escapeHtml(version)}`).join("") || "—"} + ${group.current ? `${escapeHtml(label(DETAIL_UI.versionsCurrent))}` : ""}
  • `; + return `
      ${model.versions.filter((group) => group.current).map(row).join("")}
    +
    ${escapeHtml(label(DETAIL_UI.otherPlatforms))} +
      ${model.versions.filter((group) => !group.current).map(row).join("")}
    `; } function renderDependencies(model: DetailModel, label: UiLabel): string { @@ -312,7 +319,7 @@ function renderDependencies(model: DetailModel, label: UiLabel): string { parts.push(fill(label, DETAIL_UI.resolved, [dependency.resolved])); } return ( - `
  • ${escapeHtml(dependency.id)}` + + `
  • ${dependency.key ? `` : `${escapeHtml(dependency.id)}`}` + (parts.length === 0 ? "" : ` — ${escapeHtml(parts.join(" · "))}`) + `
  • ` ); @@ -371,246 +378,155 @@ function renderExtras(model: DetailModel, label: UiLabel): string { * version is chosen in the list below (`renderVersions`), and this block shows * the command the choice produces. */ +function selectedVersion(model: DetailModel): string | undefined { + const declared = model.projectState?.declaredVersion ?? model.installed?.version; + return declared !== undefined && model.currentVersions.includes(declared) ? declared : model.latest; +} + function renderActions(model: DetailModel, label: UiLabel): string { - const disabled = model.latest === undefined; - const command = fill(label, DETAIL_UI.command, [model.id, model.latest ?? "?"]); - // The label the reader first sees is the one the *client* would compute for the - // same selection, so the button never changes meaning under the pointer. - const alreadyInstalled = model.installed !== undefined && model.installed.version === model.latest; - const addLabel = alreadyInstalled - ? label(DETAIL_UI.alreadyAdded) - : model.installed === undefined || model.latest === undefined - ? label(DETAIL_UI.add) - : fill(label, DETAIL_UI.switchTo, [model.latest]); - const link = (url: string, text: string): string => - ` `; - // The command and each usage line carry their own copy button: both are - // things a reader pastes somewhere else — a terminal, a source file. - const copyButton = (data: string, text: string): string => - ``; - const usage = - model.usage.length === 0 - ? [] - : [ - `

    ${escapeHtml(label(DETAIL_UI.usage))}

    `, - ...model.usage.map( - (line) => - `

    ${escapeHtml(line)}${copyButton(`data-copy="${escapeHtml(line)}"`, label(DETAIL_UI.copy))}

    `, - ), - ]; - return [ - `
    `, - `
    `, - ` `, - ` `, - ...(model.repo === undefined ? [] : [link(model.repo, label(DETAIL_UI.openRepo))]), - ...(model.indexUrl === undefined ? [] : [link(model.indexUrl, label(DETAIL_UI.indexLink))]), - `
    `, - // The copy button sits beside the command, not inside it: `command.textContent` - // is what a click copies, and a button inside the paragraph would be copied too. - `
    `, - `

    ${escapeHtml(command)}

    `, - ` `, - `
    `, - disabled - ? `

    ${escapeHtml(label(DETAIL_UI.addNoVersion))}

    ` - : `

    ${escapeHtml(label(DETAIL_UI.addLatest))}

    `, - ...usage, - `

    ${escapeHtml(model.result?.message ?? "")}

    `, - `
    `, - ] - .filter((line) => line.length > 0) - .join("\n"); + const version = selectedVersion(model); + const current = model.projectState; + const declared = current?.declaredVersion ?? model.installed?.version; + const dev = current?.dev ?? model.installed?.dev ?? false; + const already = declared !== undefined && declared === version; + const blocked = model.blockedReason; + const link = (url: string, text: string) => ``; + const command = (dev ? model.commandDevTemplate : model.commandTemplate).replace("{0}", model.id).replace("{1}", version ?? "?"); + return `
    +
    ${escapeHtml(label(DETAIL_UI.project))}: ${escapeHtml(model.projectRoot?.split(/[\\/]/).pop() ?? "—")} + + ${escapeHtml(label(DETAIL_UI.platform))}: ${escapeHtml(model.platform ?? "—")}
    + ${model.loading ? `

    ${escapeHtml(label(DETAIL_UI.loading))}

    ` : ""} + ${model.preview ? `

    ${escapeHtml(label(DETAIL_UI.preview))}

    ` : ""} + ${model.preview ? "" : `
    + + + ${declared === undefined ? "" : `${escapeHtml(label(DETAIL_UI.declared))}: ${escapeHtml(declared)}`} + ${declared && model.latest && model.latest !== declared ? `${escapeHtml(label(DETAIL_UI.version))}: ${escapeHtml(model.latest)} ↑` : ""} + ${current?.resolvedVersion === undefined ? "" : `${escapeHtml(label(DETAIL_UI.locked))}: ${escapeHtml(current.resolvedVersion)}`} + ${current?.sourceType === "path-or-git" ? `${escapeHtml(label(DETAIL_UI.sourceDependency))}` : ""} +
    `} +
    + ${model.preview ? "" : ``} + ${model.repo === undefined ? "" : link(model.repo, label(DETAIL_UI.openRepo))} + ${model.indexUrl === undefined ? "" : link(model.indexUrl, label(DETAIL_UI.indexLink))} + ${current?.declared ? `` : ""} +
    + ${blocked ? `

    ${escapeHtml(blocked)}

    ` : ""} +

    ${escapeHtml(model.preview ? `mcpp search ${model.id}` : command)}

    +

    +

    ${escapeHtml(model.result?.message ?? "")}

    +
    +
    `; +} + +export function detailClientState(model: DetailModel): Record { + return { + key: model.key ?? model.id, generation: model.generation ?? 0, projectRoot: model.projectRoot, + id: model.id, versions: model.currentVersions, version: selectedVersion(model), + declared: model.projectState?.declaredVersion ?? model.installed?.version, + dev: model.projectState?.dev ?? model.installed?.dev ?? false, + loading: !!model.loading, blocked: !!model.blockedReason, busy: !!model.busy, preview: !!model.preview, + command: model.commandTemplate, commandDev: model.commandDevTemplate, + labels: Object.fromEntries(Object.entries(DETAIL_UI).map(([name, key]) => [name, model.ui[key] ?? key])), + }; } -/** - * The client. It posts two things — `add` and `openUrl` — and applies - * the host's `{type:"result"}` in place, so running `mcpp add` never rebuilds the - * document and never resets the version the reader picked. - */ function clientScript(initialModel: string): string { return `(function () { "use strict"; var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; var state = ${initialModel}; - var devInput = document.getElementById("detail-dev"); - var command = document.getElementById("detail-command"); - var result = document.getElementById("detail-result"); - var addButton = document.getElementById("detail-add"); - var version = command ? (command.getAttribute("data-selected-version") || "") : ""; - var labels = (state && state.labels) || {}; - var installed = state && state.installed ? state.installed : ""; - - function post(message) { - if (api) { api.postMessage(message); } - } - - function fillTemplate(template, values) { - var out = template; - for (var index = 0; index < values.length; index += 1) { - out = out.split("{" + index + "}").join(String(values[index])); - } - return out; - } - - function updateCommand() { - if (!command) { return; } - var dev = devInput ? devInput.checked === true : false; - var template = dev - ? (command.getAttribute("data-template-dev") || "") - : (command.getAttribute("data-template") || ""); - command.setAttribute("data-selected-version", version); - command.textContent = fillTemplate(template, [state.id, version || "?"]); - } - - /** - * The button's label and enabled state follow the selection: adding a new - * package, switching the version of one that is already there, or nothing to do - * because this is the version the manifest already asks for. The inert state - * carries the data-installed attribute, which is what paints it the thinned - * green of a thing already done. - */ - function updateButton() { - if (!addButton) { return; } - if (!version) { - addButton.textContent = labels.add || ""; - addButton.disabled = true; - addButton.removeAttribute("data-installed"); - return; - } - if (installed && version === installed) { - addButton.textContent = labels.alreadyAdded || ""; - addButton.disabled = true; - addButton.setAttribute("data-installed", ""); - return; - } - addButton.textContent = installed - ? (labels.switchTo || "{0}").split("{0}").join(version) - : (labels.add || ""); - addButton.disabled = false; - addButton.removeAttribute("data-installed"); + var saved = api && api.getState ? api.getState() || {} : {}; + var pages = saved.pages || {}; + var busy = state.busy; + var version = state.version || ""; + var copyId = 0; + function pageKey() { return state.key + "|" + (state.projectRoot || ""); } + function post(message) { message.pageKey = state.key; message.generation = state.generation; if (api) { api.postMessage(message); } } + function persist() { + if (state.loading) { return; } + var dev = document.getElementById("detail-dev"); + var expanded = Array.from(document.querySelectorAll("details[data-persist]")).filter(function (node) { return node.open; }).map(function (node) { return node.getAttribute("data-persist"); }); + pages[pageKey()] = { version: version, dev: dev ? dev.checked : false, scroll: window.scrollY, expanded: expanded }; + var keys = Object.keys(pages); while (keys.length > 20) { delete pages[keys.shift()]; } + if (api && api.setState) { api.setState({ pages: pages }); } } - - /** One version button is the selection; the rest are alternatives. */ - function selectVersion(next) { - version = next; - var buttons = document.querySelectorAll("[data-version]"); - for (var index = 0; index < buttons.length; index += 1) { - var button = buttons[index]; - if (button.getAttribute("data-version") === next) { - button.setAttribute("data-selected", ""); - } else { - button.removeAttribute("data-selected"); - } + function update() { + var select = document.getElementById("detail-version"); + var dev = document.getElementById("detail-dev"); + var button = document.getElementById("detail-add"); + if (state.preview) { return; } + if (select) { select.value = version; select.disabled = busy || !state.versions.length; } + if (dev) { dev.disabled = busy; } + var isDev = dev ? dev.checked : false; + var groupChange = !!state.declared && isDev !== state.dev; + var same = !!state.declared && state.declared === version && !groupChange; + if (button) { + button.disabled = busy || state.blocked || !version || same || groupChange; + button.textContent = busy ? state.labels.pending : same ? state.labels.alreadyAdded : state.declared ? state.labels.switchTo.split("{0}").join(version) : state.labels.add; + if (same) { button.setAttribute("data-installed", ""); } else { button.removeAttribute("data-installed"); } } - updateCommand(); - updateButton(); + var note = document.getElementById("detail-selection-note"); if (note) { note.textContent = groupChange ? state.labels.groupChange : ""; } + var command = document.getElementById("detail-command"); + if (command) { command.textContent = (isDev ? state.commandDev : state.command).split("{0}").join(state.id).split("{1}").join(version || "?"); } } - - function showResult(payload) { - if (!result) { return; } - result.hidden = false; - result.setAttribute( - "data-state", - payload.state === "ok" ? "ok" : payload.state === "pending" ? "pending" : "error", - ); - result.textContent = payload.message || ""; + function restore() { + var page = pages[pageKey()] || {}; + if (state.versions.indexOf(page.version) >= 0) { version = page.version; } + var dev = document.getElementById("detail-dev"); if (dev && typeof page.dev === "boolean") { dev.checked = page.dev; } + document.querySelectorAll("details[data-persist]").forEach(function (node) { node.open = (page.expanded || []).indexOf(node.getAttribute("data-persist")) >= 0; }); + update(); window.scrollTo(0, page.scroll || 0); } - - if (devInput) { devInput.addEventListener("change", updateCommand); } - + document.addEventListener("change", function (event) { + if (event.target.id === "detail-version") { version = event.target.value; } + if (event.target.id === "detail-version" || event.target.id === "detail-dev") { update(); persist(); } + }); + document.addEventListener("toggle", persist, true); + window.addEventListener("scroll", persist); document.addEventListener("click", function (event) { - var target = event.target; - if (!target || typeof target.closest !== "function") { return; } - var link = target.closest("[data-open-url]"); - if (link) { - event.preventDefault(); - var url = link.getAttribute("data-open-url") || ""; - // Say something the instant the click lands: the host's answer replaces - // this line, and if it never arrives the reader can see that too. - showResult({ state: "pending", message: (labels.opening || "{0}").split("{0}").join(url) }); - post({ type: "openUrl", url: url }); - return; - } - var copy = target.closest("[data-copy-command], [data-copy]"); - if (copy) { - // The command's own text is what gets copied — the button beside it, not - // inside it, is why the command's textContent is exactly the command. - var text = copy.hasAttribute("data-copy-command") - ? (command ? String(command.textContent || "") : "") - : String(copy.getAttribute("data-copy") || ""); - if (!text) { return; } - showResult({ state: "pending", message: labels.copying || "" }); - post({ type: "copy", text: text }); - return; - } - var pick = target.closest("[data-version]"); - if (pick) { - selectVersion(pick.getAttribute("data-version") || ""); - return; + var target = event.target.closest && event.target.closest("button"); + if (!target || target.disabled) { return; } + var action = target.getAttribute("data-action"); + if (action) { post({ type: action }); return; } + if (target.hasAttribute("data-open-dependency")) { persist(); post({ type: "openDependency", key: target.getAttribute("data-open-dependency") }); return; } + if (target.hasAttribute("data-open-file")) { post({ type: "openFile", key: target.getAttribute("data-open-file") }); return; } + if (target.hasAttribute("data-open-url")) { post({ type: "openUrl", url: target.getAttribute("data-open-url") }); return; } + if (target.hasAttribute("data-copy") || target.hasAttribute("data-copy-command")) { + var token = "copy-" + (++copyId); target.setAttribute("data-copy-token", token); + post({ type: "copy", text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, token: token }); return; } - var add = target.closest("#detail-add"); - if (add && !add.disabled) { - if (!version) { return; } - post({ type: "add", version: version, dev: devInput ? devInput.checked === true : false }); + if (target.id === "detail-add" && !busy && version) { + busy = true; update(); + var result = document.getElementById("detail-result"); result.hidden = false; result.dataset.state = "pending"; result.textContent = state.labels.pending; + persist(); post({ type: "add", version: version, dev: document.getElementById("detail-dev").checked }); } }); - - /** Move the "added" marker onto the version the project now asks for. */ - function markInstalled(next) { - installed = next; - var marks = document.querySelectorAll(".detail-installed"); - for (var index = 0; index < marks.length; index += 1) { marks[index].parentNode.removeChild(marks[index]); } - var buttons = document.querySelectorAll("[data-version]"); - for (var index2 = 0; index2 < buttons.length; index2 += 1) { - var button = buttons[index2]; - if (button.getAttribute("data-version") !== next) { - button.removeAttribute("data-installed"); - continue; - } - button.setAttribute("data-installed", ""); - var mark = document.createElement("span"); - mark.className = "detail-installed"; - mark.textContent = labels.installed || ""; - button.parentNode.insertBefore(mark, button.nextSibling); - } - updateButton(); - } - window.addEventListener("message", function (event) { var data = event.data; - if (data && data.type === "result" && data.result) { showResult(data.result); } - if (data && data.added && data.added.version) { markInstalled(data.added.version); } + if (!data) { return; } + if (data.type === "detailUpdate") { + persist(); + var focused = document.activeElement && document.activeElement.id; + var doc = new DOMParser().parseFromString(data.html, "text/html"); + ["detail-header", "detail-main", "detail-footer"].forEach(function (id) { var old = document.getElementById(id); var next = doc.getElementById(id); if (old && next) { old.replaceWith(next); } }); + state = data.state; busy = state.busy; version = state.version || ""; + restore(); + var nextFocus = focused && document.getElementById(focused); if (nextFocus) { nextFocus.focus({ preventScroll: true }); } + return; + } + if (data.key !== state.key || data.generation !== state.generation) { return; } + if (data.type === "copyResult") { + var copy = document.querySelector('[data-copy-token="' + data.token + '"]'); + if (copy) { copy.textContent = data.message; setTimeout(function () { copy.textContent = state.labels.copy; }, 1800); } + } + if (data.type === "linkResult") { var status = document.getElementById("detail-link-result"); status.textContent = data.message; } }); - - updateCommand(); - updateButton(); + restore(); + post({ type: "mounted" }); })();`; } -/** The little state the client needs; the page is already rendered from the rest. */ -function clientState(model: DetailModel): Record { - return { - id: model.id, - ...(model.latest === undefined ? {} : { latest: model.latest }), - // The three labels the button can wear, and the version the project already - // has: the client re-decides the label every time the selection changes, so - // the host cannot be the only one that knows what the button means. The - // `installed` word belongs here too — `markInstalled()` writes it next to - // the version button after a successful add, and an empty string there is - // a marker nobody can read (§22). - labels: { - add: model.ui[DETAIL_UI.add] ?? "", - switchTo: model.ui[DETAIL_UI.switchTo] ?? "", - alreadyAdded: model.ui[DETAIL_UI.alreadyAdded] ?? "", - opening: model.ui[DETAIL_UI.opening] ?? "", - installed: model.ui[DETAIL_UI.installed] ?? "", - copying: model.ui[DETAIL_UI.copying] ?? "", - }, - ...(model.installed === undefined ? {} : { installed: model.installed.version }), - }; -} - /** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ function embedJson(value: unknown): string { return JSON.stringify(value) @@ -637,35 +553,32 @@ export function renderDetailHtml(model: DetailModel, assets: DetailAssets): stri ${escapeHtml(title)} -
    +

    ${escapeHtml(model.id)}

    ${model.description === undefined ? "" : `

    ${escapeHtml(model.description)}

    `} ${renderMeta(model, label)}
    -
    +
    ${renderActions(model, label)} -
    -

    ${escapeHtml(versionsHeading)}

    - ${renderVersions(model, label)} -
    -
    -

    ${escapeHtml(label(DETAIL_UI.dependencies))}

    - ${renderDependencies(model, label)} -
    -
    -

    ${escapeHtml(label(DETAIL_UI.code))}

    - ${renderSnippets(model, label)} - ${model.exampleProject === undefined ? "" : `

    ${escapeHtml(fill(label, DETAIL_UI.codeProject, [model.exampleProject]))}

    `} -
    -
    -

    ${escapeHtml(label(DETAIL_UI.overview))}

    - ${renderExtras(model, label)} - ${model.parseNotice === undefined ? "" : `

    ${escapeHtml(model.parseNotice)}

    `} -
    + +
    +
    +

    ${escapeHtml(label(DETAIL_UI.usage))}

    + ${model.usage.length ? `

    ${escapeHtml(label(DETAIL_UI.usageSource))}

    ` + model.usage.map((line) => `
    ${escapeHtml(line)}
    `).join("") : `

    ${escapeHtml(label(DETAIL_UI.unknownUsage))}

    `} +

    ${escapeHtml(label(DETAIL_UI.code))}

    + ${renderSnippets(model, label)} + ${model.exampleProject === undefined ? "" : `

    ${escapeHtml(fill(label, DETAIL_UI.codeProject, [model.exampleProject]))}

    `} +
    + +
    -
    ${escapeHtml(model.dataSource)}
    +
    ${escapeHtml(label(DETAIL_UI.source))}

    ${escapeHtml(model.dataSource)}

    ${model.parseNotice === undefined ? "" : `

    ${escapeHtml(model.parseNotice)}

    `}
    @@ -691,6 +604,15 @@ export function decodeDetailMessage(raw: unknown): DetailMessage | undefined { return undefined; } switch (raw.type) { + case "mounted": + case "selectProject": + case "showOutput": + case "retry": + case "openManifest": + return { type: raw.type }; + case "openDependency": + case "openFile": + return nonEmptyString(raw.key) ? { type: raw.type, key: raw.key } : undefined; case "add": return nonEmptyString(raw.version) && typeof raw.dev === "boolean" ? { type: "add", version: raw.version, dev: raw.dev } @@ -705,7 +627,7 @@ export function decodeDetailMessage(raw: unknown): DetailMessage | undefined { // hostile document cannot park a novel in the clipboard — and everything // else about the text is the user's own click. return nonEmptyString(raw.text) && raw.text.length <= 16_384 - ? { type: "copy", text: raw.text } + ? { type: "copy", text: raw.text, ...(typeof raw.token === "string" && /^copy-\d+$/.test(raw.token) ? { token: raw.token } : {}) } : undefined; default: return undefined; diff --git a/src/library/detailPanel.ts b/src/library/detailPanel.ts index c467c29..a23564e 100644 --- a/src/library/detailPanel.ts +++ b/src/library/detailPanel.ts @@ -27,18 +27,18 @@ import { read } from "../config/access"; import { languagePreference, t } from "../i18n/t"; import { localeFromEditorLanguage } from "../i18n/translate"; import { WebviewDocument } from "../webview/document"; +import { projectDependencyState, readProjectDeclarations, operationMatches } from "./projectState"; +import type { LibraryRow } from "./libraryHtml"; import { addDependency } from "./addDependency"; import { badgesOf, codeSnippets, descriptorDependencies, - installedFor, lockPackageVersions, mergeSurfaces, parseXpkgJson, platformKey, surfaceLabel, - syntheticUsageLines, usageLinesFor, type LibraryEntry, type Surface, @@ -48,7 +48,7 @@ import { DETAIL_UI, decodeDetailMessage, renderDetailHtml, - type DetailInstalled, + detailClientState, type DetailModel, type DetailVersionGroup, type DetailResult, @@ -63,7 +63,8 @@ const STYLESHEET = "library.css"; const PARSE_TIMEOUT_MS = 20_000; export interface DetailPanelDeps { - mcppExecutable: () => string; + mcppExecutable: (projectRoot?: string) => string; + chooseProject?: () => Promise; projectRoot: () => string | undefined; output: vscode.OutputChannel; isTrusted: () => boolean; @@ -80,13 +81,16 @@ export interface DetailPanelDeps { export function createLibraryDetailOpener( context: vscode.ExtensionContext, deps: DetailPanelDeps, -): (id: string) => Promise { +): LibraryDetailOpener { const session: DetailSession = { context, deps, panel: undefined, id: undefined, busy: false, + generation: 0, + projectRoot: deps.projectRoot(), + painted: false, webviewDocument: new WebviewDocument(STYLESHEET), }; context.subscriptions.push({ @@ -95,7 +99,19 @@ export function createLibraryDetailOpener( session.panel = undefined; }, }); - return (id: string) => open(session, id); + const opener = (id: string, preview?: LibraryRow) => open(session, id, preview); + opener.refresh = async () => { + if (session.id === undefined || session.panel === undefined) { return; } + const root = deps.projectRoot(); + if (root !== session.projectRoot) { session.projectRoot = root; session.generation++; } + await render(session, session.id); + }; + return opener; +} + +export interface LibraryDetailOpener { + (id: string, preview?: LibraryRow): Promise; + refresh(): Promise; } /** One panel per window, remembered so a second `open` reveals rather than stacks. */ @@ -106,6 +122,12 @@ export interface DetailSession { id: string | undefined; /** `mcpp add` in flight: a second click must not start a second command. */ busy: boolean; + generation: number; + projectRoot?: string; + preview?: LibraryRow; + model?: DetailModel; + painted: boolean; + result?: DetailResult; /** * The document and its nonce, kept per session so re-opening the *same* * package renders byte for byte the same document and does not reload the @@ -117,7 +139,7 @@ export interface DetailSession { /** The parse result per descriptor, so re-opening a package costs no process. */ const parsed = new Map(); -export async function open(session: DetailSession, id: string): Promise { +export async function open(session: DetailSession, id: string, preview?: LibraryRow): Promise { if (session.panel === undefined) { const panel = vscode.window.createWebviewPanel( PANEL_VIEW_TYPE, @@ -133,6 +155,8 @@ export async function open(session: DetailSession, id: string): Promise { panel.onDidDispose(() => { session.panel = undefined; session.id = undefined; + session.generation++; + session.painted = false; // The next open creates a brand-new webview: what was on screen says // nothing about it, so the document comparison starts from nothing. session.webviewDocument.invalidate(); @@ -142,161 +166,181 @@ export async function open(session: DetailSession, id: string): Promise { }); } const panel = session.panel; + const changed = session.id !== id; session.id = id; - panel.title = id; + session.preview = preview; + if (changed) { session.generation++; session.result = undefined; } + const nextProject = session.deps.projectRoot(); + if (nextProject !== session.projectRoot) { session.generation++; session.result = undefined; } + session.projectRoot = nextProject; + panel.title = preview?.id ?? path.basename(id, ".lua"); + if (changed || !session.painted) { + const loading = emptyModel(preview?.id ?? path.basename(id, ".lua"), labels(), t("Reading package details…")); + loading.key = id; loading.generation = session.generation; loading.projectRoot = session.projectRoot; + loading.loading = true; loading.blockedReason = t("Reading package details…"); + paintModel(session, loading); + } panel.reveal(vscode.ViewColumn.Active, false); await render(session, id); } async function handle(session: DetailSession, raw: unknown): Promise { const message = decodeDetailMessage(raw); - if (message === undefined || session.panel === undefined) { + if (message === undefined || session.panel === undefined || session.id === undefined) { return; } + const envelope = raw as { pageKey?: unknown; generation?: unknown }; + if (message.type === "mounted") { + if (session.model) { paintModel(session, session.model, true); } return; } + if (envelope.pageKey !== session.id || envelope.generation !== session.generation) { return; } + const operation = { key: session.id, projectRoot: session.projectRoot, generation: session.generation }; + const current = () => operationMatches(operation, { key: session.id ?? "", projectRoot: session.projectRoot, generation: session.generation }); switch (message.type) { - case "openUrl": { - // `decodeDetailMessage` already restricted this to https. - await openExternal(session, message.url); + case "selectProject": { + const root = await session.deps.chooseProject?.(); + if (root !== undefined && current()) { + session.projectRoot = root; session.generation++; session.result = undefined; + await render(session, session.id); + } return; } + case "showOutput": session.deps.output.show(true); return; + case "openManifest": + if (session.projectRoot) { await vscode.window.showTextDocument(vscode.Uri.file(path.join(session.projectRoot, "mcpp.toml"))); } + return; + case "retry": + if (session.result?.state === "error") { session.result = undefined; } + await render(session, session.id); return; + case "openDependency": + if (session.model?.dependencies.some((dependency) => dependency.key === message.key)) { await open(session, message.key); } + return; + case "openFile": { + if (!session.model?.snippets.some((snippet) => snippet.fileKey === message.key)) { return; } + await vscode.window.showTextDocument(vscode.Uri.file(message.key)); return; + } case "copy": { - // The command preview and the usage lines: both are text a reader pastes - // somewhere else, and a click that answers with nothing looks like a dead - // button (the lesson `openExternal` learned in §20.2). - try { - await vscode.env.clipboard.writeText(message.text); - postResult(session, { state: "ok", message: t("Copied to the clipboard.") }); - } catch (error) { - session.deps.output.appendLine( - `mcpp library: copying to the clipboard failed: ${error instanceof Error ? error.message : String(error)}`, - ); - postResult(session, { state: "error", message: t("Could not write the clipboard.") }); - } + let result: string; + try { await vscode.env.clipboard.writeText(message.text); result = t("Copied"); } + catch { result = t("Copy failed"); } + if (current()) { void session.panel?.webview.postMessage({ type: "copyResult", key: operation.key, generation: operation.generation, token: message.token, message: result }); } + return; + } + case "openUrl": { + // Only links in this package's model can ask the host to open a browser. + if (message.url !== session.model?.repo && message.url !== session.model?.indexUrl) { return; } + let opened = false; + try { opened = await vscode.env.openExternal(vscode.Uri.parse(message.url)); } catch { /* report below */ } + let copied = false; + if (!opened) { try { await vscode.env.clipboard.writeText(message.url); copied = true; } catch { /* accurate feedback below */ } } + if (current()) { void session.panel?.webview.postMessage({ type: "linkResult", key: operation.key, generation: operation.generation, + message: opened ? t("Opened in your browser.") : copied ? t("Could not open the browser. Link copied to clipboard.") : t("Could not open the browser or copy the link: {0}", message.url) }); } return; } case "add": { - if (session.busy || session.id === undefined) { - return; - } + const model = session.model; + if (session.busy || !model || model.blockedReason || model.preview || !model.currentVersions.includes(message.version)) { return; } + const state = model.projectState; + if (state?.declared && (state.dev !== message.dev || state.sourceType !== "registry" || state.ambiguous)) { return; } session.busy = true; - let result: DetailResult; + session.result = { state: "pending", message: t("Adding dependency…") }; + paintModel(session, { ...model, busy: true, result: session.result }); try { - const outcome = await addDependency( - { - mcppExecutable: session.deps.mcppExecutable, - projectRoot: session.deps.projectRoot, - output: session.deps.output, - isTrusted: session.deps.isTrusted, - }, - { id: session.id, version: message.version, dev: message.dev }, - ); - result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; - if (outcome.ok) { - session.deps.onDependenciesChanged?.(); + const executable = session.deps.mcppExecutable(operation.projectRoot); + const outcome = await addDependency({ + mcppExecutable: () => executable, + projectRoot: () => operation.projectRoot, + isTrusted: session.deps.isTrusted, + output: session.deps.output, + quiet: true, + }, { id: model.id, version: message.version, dev: message.dev }); + if (outcome.ok) { session.deps.onDependenciesChanged?.(); } + if (current() && session.panel) { + session.result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; + } else { + void (outcome.ok ? vscode.window.showInformationMessage(outcome.message) : vscode.window.showErrorMessage(outcome.message)); } + } catch (error) { + session.deps.output.appendLine(String(error)); + if (current()) { session.result = { state: "error", message: t("Could not add the dependency. See output for details.") }; } } finally { session.busy = false; + if (session.panel && session.id) { await render(session, session.id); } } - if (session.panel === undefined) { - return; - } - postResult(session, result, result.state === "ok" ? message.version : undefined); return; } - default: - return; } } -/** - * Open a link, and say what happened. - * - * This used to be `void vscode.env.openExternal(…)`: the boolean it resolves to - * was dropped, so a host with no browser (or no way to reach one) answered a - * click with nothing at all — indistinguishable from a dead button. Now the page - * is told either way, a failure is copied to the clipboard so the click is still - * worth something, and the output channel keeps a record. - */ -async function openExternal(session: DetailSession, url: string): Promise { - let opened = false; - try { - opened = await vscode.env.openExternal(vscode.Uri.parse(url)); - } catch (error) { - session.deps.output?.appendLine( - `mcpp library: opening ${url} failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - if (opened) { - postResult(session, { state: "ok", message: t("Opened {0} in your browser.", url) }); - return; - } - session.deps.output?.appendLine(`mcpp library: VS Code could not open ${url}; copying it instead.`); - try { - await vscode.env.clipboard.writeText(url); - } catch { - // A clipboard that refuses is not worth a second error. +function paintModel(session: DetailSession, model: DetailModel, replay = false): void { + const panel = session.panel; + if (!panel) { return; } + session.model = model; + const html = renderDetailHtml(model, session.webviewDocument.assets(vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), panel.webview)); + if (!session.painted && !replay) { + session.webviewDocument.paint(panel.webview, html); session.painted = true; + } else { + void panel.webview.postMessage({ type: "detailUpdate", html, state: detailClientState(model) }); } - postResult(session, { - state: "error", - message: t("VS Code could not open {0}. The link is on your clipboard.", url), - }); -} - -/** - * The page's status line, in the same shape the add flow uses. - * - * `added` tells the page that the project now depends on that version, so it can - * move its "added" marker and re-label the button without rebuilding the - * document — which would throw away the reader's scroll position and their - * version selection, the one thing running `mcpp add` must not do. - */ -function postResult(session: DetailSession, result: DetailResult, added?: string): void { - void session.panel?.webview.postMessage({ - type: "result", - result, - ...(added === undefined ? {} : { added: { version: added } }), - }); } async function render(session: DetailSession, id: string): Promise { const panel = session.panel; - if (panel === undefined) { - return; - } - const model = await buildModel(session, id); - if (session.panel !== panel || session.id !== id) { - return; + const generation = session.generation; + const root = session.projectRoot; + if (!panel) { return; } + let model: DetailModel; + try { model = await buildModel(session, id); } + catch (error) { + session.deps.output.appendLine(String(error)); + model = emptyModel(path.basename(id, ".lua"), labels(), t("Could not read package details. See output for details.")); + model.blockedReason = model.parseNotice; + model.result = { state: "error", message: model.parseNotice ?? "" }; } - const html = renderDetailHtml( - model, - session.webviewDocument.assets( - vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), - panel.webview, - ), - ); - session.webviewDocument.paint(panel.webview, html); + if (session.panel !== panel || session.id !== id || generation !== session.generation || root !== session.projectRoot) { return; } + paintModel(session, { ...model, key: id, generation, busy: session.busy, result: session.result ?? model.result }); } async function buildModel(session: DetailSession, id: string): Promise { - const projectRoot = session.deps.projectRoot(); + const projectRoot = session.projectRoot; + const generation = session.generation; const snapshot = await loadSnapshot(projectRoot === undefined ? {} : { projectRoot }); - const entry = snapshot.entries.find((candidate) => candidate.id === id); + const entry = snapshot.entries.find((candidate) => candidate.file === id || candidate.id === id); // The root the descriptor lives under, matched by path: two roots may share a // directory name, and the site link belongs to the one that holds the file. - const root = entry === undefined ? undefined : snapshot.roots.find((candidate) => entry.file.startsWith(candidate.path)); + const root = entry === undefined ? undefined : snapshot.roots.find((candidate) => path.relative(candidate.path, entry.file).split(path.sep)[0] !== ".."); const ui = labels(); if (entry === undefined) { - return emptyModel(id, ui, t("This package is not in the local index any more.")); + const preview = session.preview; + const model = emptyModel(preview?.id ?? path.basename(id, ".lua"), ui, preview ? t("This is a search preview. Set an index path containing this package to read its complete details.") : t("This package is not in the local index any more.")); + model.preview = preview !== undefined; + model.description = preview?.description; + model.registry = preview?.source ?? ""; + model.projectRoot = projectRoot; + model.blockedReason = preview ? t("Search previews cannot be added. Open a complete local descriptor first.") : t("The package descriptor is missing. Reread the local index."); + return model; } const text = cachedDescriptorText(entry.file) ?? (await readDescriptorText(entry.file)) ?? ""; const platform = platformKey(process.platform); // Keyed by the index revision as well as the path, so a refreshed index is not // answered from a parse of the previous one. - const cacheKey = `${snapshot.revision}\u0000${entry.file}`; + const cacheKey = `${snapshot.revision}\u0000${entry.file}\u0000${session.deps.isTrusted()}\u0000${session.deps.mcppExecutable(projectRoot)}\u0000${projectRoot ?? ""}`; let info = parsed.get(cacheKey); if (!parsed.has(cacheKey)) { - info = await readXpkg(session, entry); - parsed.set(cacheKey, info); + if (session.id === id && session.generation === generation && session.model?.loading) { + const lightweight: DetailModel = { + ...emptyModel(entry.id, ui, t("Reading package details…")), + key: id, generation, projectRoot, platform: platform ?? process.platform, + description: entry.description, registry: entry.registry, surface: entry.surface, surfaces: entry.surfaces, + licenses: entry.licenses, repo: entry.repo, badges: badgesOf(entry), + currentVersions: entry.versions[platform ?? ""] ?? [], latest: entry.version, + versions: versionGroups(entry.versions, platform).map((group) => ({ ...group, current: group.platform === platform })), + loading: true, blockedReason: t("Reading package details…"), dataSource: dataSource(entry, snapshot.roots.length), + }; + paintModel(session, lightweight); + } + info = await readXpkg(session, entry, projectRoot); + if (info !== undefined) { parsed.set(cacheKey, info); } + while (parsed.size > 100) { parsed.delete(parsed.keys().next().value!); } } const versions = info === undefined ? entry.versions : info.versions; @@ -308,38 +352,47 @@ async function buildModel(session: DetailSession, id: string): Promise ({ + ...snippet, ...(root === undefined ? {} : { fileKey: path.join(root.path, snippet.file) }), + })); + // Only verified lines from real example source are copyable. const realUsage = usageLinesFor(exampleFiles, entry.id); - const usage = realUsage.length > 0 ? realUsage : syntheticUsageLines(mergeSurfaces(info, text), entry.id); + const usage = realUsage; // The lock answers what a build actually resolved for each declared edge — // the same matching rule `installedFor` uses for this package itself. - const lock = lockPackageVersions(readInstalledLock(session)); + const lock = lockPackageVersions(projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.lock")) : ""); + const declarations = readProjectDeclarations(projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.toml")) : ""); + const state = projectDependencyState(entry.id, declarations, lock, snapshot.entries.map((entry) => entry.id)); const dependencies = descriptorDependencies(text).map((dependency) => { - const short = dependency.id.slice(dependency.id.lastIndexOf(".") + 1); - const resolved = lock.find((entry) => entry.id === dependency.id || entry.id === short)?.version; - return resolved === undefined ? { ...dependency } : { ...dependency, resolved }; + const resolved = lock.find((entry) => entry.id === dependency.id)?.version; + const local = snapshot.entries.find((entry) => entry.id === dependency.id); + return { ...dependency, ...(resolved ? { resolved } : {}), ...(local ? { key: local.file } : {}) }; }); const model: DetailModel = { ui, + projectRoot, + platform: platform ?? process.platform, + projectState: state, + blockedReason: !session.deps.isTrusted() ? t("Trust this workspace to run mcpp and add dependencies.") + : !projectRoot ? t("Select an mcpp project before adding this library.") + : state.ambiguous ? t("This dependency name is ambiguous. Use a qualified package name in mcpp.toml.") + : state.sourceType === "path-or-git" ? t("This project uses a source dependency. Edit its manifest to change the source.") + : latest === undefined ? info === undefined ? t("Version availability could not be confirmed. Retry reading this descriptor.") : t("No version is published for the current environment platform.") + : undefined, id: entry.id, name: entry.name, ...(entry.description === undefined ? {} : { description: entry.description }), licenses: entry.licenses, ...(entry.repo === undefined ? {} : { repo: entry.repo }), registry: entry.registry, - ...(entry.surface === undefined ? {} : { surface: entry.surface }), - surfaces: entry.surfaces, + ...(mergeSurfaces(info, text)[0] === undefined ? {} : { surface: mergeSurfaces(info, text)[0] }), + surfaces: mergeSurfaces(info, text), badges: badgesOf(entry), versions: groups, currentVersions: sortVersions(currentVersions), ...(latest === undefined ? {} : { latest }), - // §22: the page finally learns what the project already has. Without this - // the button said "Add" to a package mcpp.toml listed all along. - ...readInstalled(session, entry.id), ...(info?.standard === undefined ? {} : { standard: info.standard }), dependencies, includeDirs: info?.includeDirs ?? [], @@ -367,13 +420,13 @@ async function buildModel(session: DetailSession, id: string): Promise { +async function readXpkg(session: DetailSession, entry: LibraryEntry, projectRoot?: string): Promise { // `mcpp.path` is resource-scoped, so an untrusted workspace must not name // the program: the refusal degrades to the descriptor's own text. const result = await vscode.window.withProgress( { location: vscode.ProgressLocation.Window, title: t("Reading {0}…", entry.id) }, () => - runMcpp(session.deps.isTrusted(), session.deps.mcppExecutable(), ["xpkg", "parse", entry.file, "--json"], session.deps.projectRoot(), { + runMcpp(session.deps.isTrusted(), session.deps.mcppExecutable(projectRoot), ["xpkg", "parse", entry.file, "--json"], projectRoot, { timeoutMs: PARSE_TIMEOUT_MS, maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), }), @@ -424,42 +477,33 @@ function emptyModel(id: string, ui: Record, notice: string): Det }; } -/** - * The workspace's own answer about one package (§20.1, wired in §22): - * `mcpp.toml` first, `mcpp.lock` second, both read tolerantly — a missing or - * unreadable file is simply "not installed", never a broken page. The spread - * sets `installed` only when there is one. - */ -function readInstalled(session: DetailSession, id: string): { installed?: DetailInstalled } { - const root = session.deps.projectRoot(); - if (root === undefined) { - return {}; - } - const toml = readTextTolerantly(path.join(root, "mcpp.toml")); - const lock = readTextTolerantly(path.join(root, "mcpp.lock")); - const installed = installedFor(id, toml, lock); - return installed === undefined ? {} : { installed }; -} - -/** An unreadable or missing file reads as empty text; the view degrades, not breaks. */ function readTextTolerantly(file: string): string { - try { - return readFileSync(file, "utf8"); - } catch { - return ""; - } -} - -/** The workspace's `mcpp.lock` text, empty when there is none to read. */ -function readInstalledLock(session: DetailSession): string { - const root = session.deps.projectRoot(); - return root === undefined ? "" : readTextTolerantly(path.join(root, "mcpp.lock")); + try { return readFileSync(file, "utf8"); } catch { return ""; } } /** Every visible string, resolved once per model. */ function labels(): Record { return { [DETAIL_UI.htmlLang]: htmlLanguage(), + [DETAIL_UI.project]: t("Target project"), + [DETAIL_UI.chooseProject]: t("Choose project"), + [DETAIL_UI.platform]: t("Environment platform"), + [DETAIL_UI.version]: t("Version"), + [DETAIL_UI.otherPlatforms]: t("Other platforms (read only)"), + [DETAIL_UI.declared]: t("Declared"), + [DETAIL_UI.locked]: t("Resolved in lockfile"), + [DETAIL_UI.sourceDependency]: t("Source dependency"), + [DETAIL_UI.unknownUsage]: t("No verified import or header path is available locally. See the repository or index site for usage."), + [DETAIL_UI.usageSource]: t("From the index's example source"), + [DETAIL_UI.source]: t("Source and diagnostics"), + [DETAIL_UI.loading]: t("Reading package details…"), + [DETAIL_UI.pending]: t("Adding dependency…"), + [DETAIL_UI.groupChange]: t("Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration."), + [DETAIL_UI.output]: t("Show output"), + [DETAIL_UI.retry]: t("Retry reading details"), + [DETAIL_UI.openManifest]: t("Open mcpp.toml"), + [DETAIL_UI.openFile]: t("Open source file"), + [DETAIL_UI.preview]: t("Search preview: only the fields returned by mcpp search are available."), [DETAIL_UI.title]: t("Library package {0}"), [DETAIL_UI.overview]: t("Build shape"), [DETAIL_UI.license]: t("License"), @@ -477,7 +521,7 @@ function labels(): Record { [DETAIL_UI.dependencies]: t("Dependencies"), [DETAIL_UI.dependenciesNone]: t("This descriptor declares no dependencies."), [DETAIL_UI.dependenciesHint]: t( - "What the descriptor declares. The resolved version lives in a project's mcpp.lock, not here.", + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.", ), [DETAIL_UI.resolved]: t("resolved {0}"), [DETAIL_UI.code]: t("Example code"), @@ -488,7 +532,7 @@ function labels(): Record { [DETAIL_UI.addDev]: t("dev dependency"), [DETAIL_UI.addLatest]: t("The version is required: mcpp accepts an exact version only."), [DETAIL_UI.switchTo]: t("Switch to {0}"), - [DETAIL_UI.alreadyAdded]: t("Already added"), + [DETAIL_UI.alreadyAdded]: t("Already declared"), [DETAIL_UI.installed]: t("added"), [DETAIL_UI.opening]: t("Opening {0}…"), [DETAIL_UI.addNoVersion]: t("This index publishes no version for this platform, so there is nothing to add."), diff --git a/src/library/indexLocator.ts b/src/library/indexLocator.ts index 37c90d7..c269ecd 100644 --- a/src/library/indexLocator.ts +++ b/src/library/indexLocator.ts @@ -1,6 +1,7 @@ /** Local package discovery. Official indexes are separate from the xlings tool registry. * Descriptor contents are cached independently from the project's dependency state. */ +import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -8,11 +9,10 @@ import * as vscode from "vscode"; import { runMcpp } from "../cli/process"; import { read } from "../config/access"; -import { projectDependencyState } from "./projectState"; +import { projectDependencyState, readProjectDeclarations } from "./projectState"; import { descriptorEntry, descriptorId, - declaredDependencyEntries, exampleCatalog, openkalFacetFor, parseDescriptorLua, @@ -250,7 +250,7 @@ async function revisionOf(roots: readonly IndexRoot[]): Promise { const stats = await Promise.all(files.map(async (file) => `${file}:${JSON.stringify(await statSafe(file))}`)); parts.push(`${root.path}:${root.kind}:${root.updatedAt ?? ""}:${stats.join(";")}`); } - return parts.join("|"); + return createHash("sha256").update(parts.join("|")).digest("hex"); } async function statSafe(target: string): Promise<{ mtimeMs: number; size: number } | undefined> { @@ -329,7 +329,7 @@ async function readOpenkal(roots: readonly IndexRoot[]): Promise { const text = projectRoot === undefined ? "" : await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8").catch(() => ""); - const declarations = declaredDependencyEntries(text); + const declarations = readProjectDeclarations(text); const ids = snapshot.entries.map((entry) => entry.id); return { ...snapshot, entries: snapshot.entries.map((entry) => ({ ...entry, added: projectDependencyState(entry.id, declarations, [], ids).declared, diff --git a/src/library/libraryHtml.ts b/src/library/libraryHtml.ts index 99feba8..e2d7c67 100644 --- a/src/library/libraryHtml.ts +++ b/src/library/libraryHtml.ts @@ -35,6 +35,8 @@ import { BADGE_UI, SURFACE_TEXT, surfaceLabel, type BadgeKey, type Surface } fro /** One result row, already resolved for display. */ export interface LibraryRow { id: string; + key?: string; + source?: string; namespace?: string; name: string; /** The greatest version this platform has, or `undefined`. */ @@ -71,6 +73,9 @@ export interface LibraryModel { networkSearchSetting: string; /** The data source, in plain words, for the footer. */ dataSource: string; + sourceDetails?: string; + status?: string; + projectNotice?: string; /** `{0}` visible of `{1}` known, already localized; the header count uses it. */ countTemplate: string; /** Rows before filtering; the count line shows it against what is visible. */ @@ -112,6 +117,12 @@ export const LIBRARY_UI = { count: "library.count", open: "library.open", refresh: "library.refresh", + sources: "library.sources", + other: "library.other", + settings: "library.settings", + update: "library.update", + output: "library.output", + clear: "library.clear", } as const; /** One badge key's `ui` key, so the renderer has a single lookup table. */ @@ -138,7 +149,7 @@ const BADGE_KEY_UI: Readonly> = { * (`documentNeedsRender`) — so this cannot come back by accident. */ export type LibraryMessage = - | { type: "refresh" } + | { type: "refresh" | "settings" | "update" | "output" | "mounted" } | { type: "search"; query: string } | { type: "networkSearch"; enabled: boolean } | { type: "open"; id: string }; @@ -187,7 +198,7 @@ export function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string } function renderRow(row: LibraryRow, label: UiLabel): string { - const badges = badgeLabels(row.badges, label); + const badges: string[] = []; const openTitle = fill(label, LIBRARY_UI.open, [row.id]); const surface = row.surface === undefined ? undefined : surfaceLabel(row.surface, label); const version = @@ -197,14 +208,14 @@ function renderRow(row: LibraryRow, label: UiLabel): string { ...badges.map((badge) => `${escapeHtml(badge)}`), row.added ? `${escapeHtml(label(LIBRARY_UI.added))}` : "", row.unreadable ? `${escapeHtml(label(LIBRARY_UI.unreadable))}` : "", - row.crossRegistry === true ? `${escapeHtml(label(LIBRARY_UI.crossRegistry))}` : "", + row.crossRegistry === true ? `${escapeHtml(row.source ?? label(LIBRARY_UI.crossRegistry))}` : "", ] .filter((part) => part.length > 0) .join(""); const description = row.description === undefined ? "" : row.description; return [ `
  • `, - ` `, ` `, + ` ${escapeHtml(fill(label, LIBRARY_UI.count, [model.rows.filter((row) => !row.crossRegistry && rowMatchesClient(model, row)).length, model.rows.filter((row) => !row.crossRegistry).length]))}`, ``, ].join("\n"); } function renderList(model: LibraryModel, label: UiLabel): string { - if (model.rows.length === 0) { - return `

    ${escapeHtml(model.notice ?? label(LIBRARY_UI.noResults))}

    `; - } - return [ - `
      `, - model.rows.map((row) => renderRow(row, label)).join("\n"), - `
    `, - ``, - ].join("\n"); + const local = model.rows.filter((row) => !row.crossRegistry); + const extra = model.rows.filter((row) => row.crossRegistry); + return `
    + ${model.projectNotice ? `

    ${escapeHtml(model.projectNotice)}

    ` : ""} +
      ${local.map((row) => renderRow(row, label)).join("\n")}
    +
    +

    ${escapeHtml(label(LIBRARY_UI.other))} (${extra.length})

    +
      ${extra.map((row) => renderRow(row, label)).join("\n")}
    +
    +

    ${escapeHtml(model.notice ?? label(LIBRARY_UI.noResults))}

    +

    ${escapeHtml(model.status ?? "")}

    +
    `; } /** @@ -272,72 +288,93 @@ function clientScript(initialModel: string): string { "use strict"; var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; var state = ${initialModel}; + var saved = api && api.getState ? api.getState() || {} : {}; var searchInput = document.getElementById("library-search"); var networkInput = document.getElementById("library-network"); - var list = document.getElementById("library-list"); - var empty = document.getElementById("library-empty"); - var countElement = document.getElementById("library-count"); - - function post(message) { - if (api) { api.postMessage(message); } + var selected = saved.selected || ""; + if (searchInput && typeof saved.query === "string") { searchInput.value = saved.query; } + function post(message) { if (api) { api.postMessage(message); } } + function persist() { + if (api && api.setState) { api.setState({ query: searchInput.value, selected: selected, scroll: window.scrollY }); } } - - // Every whitespace-separated word has to appear in the row's haystack, which - // the host builds from the id, the name, the description and the usage labels. - function rowMatches(row, query) { - if (query.length === 0) { return true; } - var haystack = row.getAttribute("data-haystack") || ""; - var parts = query.split(/\\s+/); - for (var index = 0; index < parts.length; index += 1) { - if (parts[index].length > 0 && haystack.indexOf(parts[index]) < 0) { return false; } - } - return true; - } - function apply() { - var query = searchInput ? searchInput.value.trim().toLowerCase() : ""; - var any = false; - var visible = 0; + var query = searchInput.value.trim().toLowerCase(); + var parts = query.split(/\\s+/); + var rows = Array.from(document.querySelectorAll("[data-row]")); + var local = 0; + var extra = 0; + rows.forEach(function (row) { + var haystack = row.getAttribute("data-haystack") || ""; + var shown = parts.every(function (part) { return !part || haystack.indexOf(part) >= 0; }); + row.parentNode.hidden = !shown; + if (shown) { if (row.getAttribute("data-cross") === "true") { extra++; } else { local++; } } + if (row.getAttribute("data-row") === selected) { row.setAttribute("aria-current", "true"); } + else { row.removeAttribute("aria-current"); } + }); + var list = document.getElementById("library-list"); if (list) { - var rows = list.querySelectorAll("[data-row]"); - for (var index = 0; index < rows.length; index += 1) { - var shown = rowMatches(rows[index], query); - rows[index].parentNode.hidden = !shown; - if (shown) { any = true; visible += 1; } - } - list.hidden = !any; - } - if (empty) { empty.hidden = any || !list; } - if (countElement) { - var template = state && state.countTemplate ? state.countTemplate : ""; - var total = state && state.total ? state.total : 0; - countElement.textContent = template.split("{0}").join(String(visible)).split("{1}").join(String(total)); + Array.from(list.children).sort(function (a, b) { + function rank(item) { + var id = item.firstElementChild.getAttribute("data-id").toLowerCase(); + var name = id.slice(id.lastIndexOf(".") + 1); + return !query ? 2 : id === query || name === query ? 0 : id.indexOf(query) === 0 || name.indexOf(query) === 0 ? 1 : 2; + } + return rank(a) - rank(b) || a.firstElementChild.getAttribute("data-id").localeCompare(b.firstElementChild.getAttribute("data-id")); + }).forEach(function (item) { list.appendChild(item); }); } + var empty = document.getElementById("library-empty"); + if (empty) { empty.hidden = local + extra > 0; } + var group = document.getElementById("library-extra"); + if (group) { group.hidden = extra === 0; } + document.getElementById("library-count").textContent = state.countTemplate.split("{0}").join(String(local)).split("{1}").join(String(state.localTotal)); + persist(); } - - if (searchInput) { - searchInput.addEventListener("input", function () { - apply(); - post({ type: "search", query: searchInput.value }); - }); - } - if (networkInput) { - networkInput.addEventListener("change", function () { - post({ type: "networkSearch", enabled: networkInput.checked === true }); - }); - } + searchInput.addEventListener("input", function () { apply(); post({ type: "search", query: searchInput.value }); }); + networkInput.addEventListener("change", function () { post({ type: "networkSearch", enabled: networkInput.checked }); }); document.addEventListener("click", function (event) { - var target = event.target; - if (!target || typeof target.closest !== "function") { return; } - var refresh = target.closest("[data-action]"); - if (refresh && refresh.getAttribute("data-action") === "refresh") { - post({ type: "refresh" }); - return; + var target = event.target.closest && event.target.closest("[data-action], [data-row]"); + if (!target) { return; } + var action = target.getAttribute("data-action"); + if (action === "clear") { searchInput.value = ""; searchInput.focus(); apply(); post({ type: "search", query: "" }); return; } + if (action) { post({ type: action }); return; } + selected = target.getAttribute("data-row"); apply(); post({ type: "open", id: selected }); + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && event.target === searchInput) { + searchInput.value = ""; apply(); post({ type: "search", query: "" }); } - var row = target.closest("[data-row]"); - if (row) { post({ type: "open", id: row.getAttribute("data-row") }); } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } + var rows = Array.from(document.querySelectorAll("[data-row]")).filter(function (row) { return !row.parentNode.hidden; }); + if (!rows.length || (event.target !== searchInput && !event.target.hasAttribute("data-row"))) { return; } + event.preventDefault(); + var index = rows.indexOf(document.activeElement); + var next = event.key === "ArrowDown" ? Math.min(rows.length - 1, index + 1) : Math.max(0, index - 1); + rows[next].focus(); + }); + window.addEventListener("scroll", persist); + window.addEventListener("message", function (event) { + var payload = event.data; + if (!payload || payload.type !== "libraryUpdate") { return; } + var scroll = window.scrollY; + var focused = document.activeElement && document.activeElement.getAttribute("data-row"); + var details = document.querySelector("footer details"); + var expanded = details && details.open; + var doc = new DOMParser().parseFromString(payload.html, "text/html"); + ["library-results", "library-footer"].forEach(function (id) { + var old = document.getElementById(id); var next = doc.getElementById(id); + if (old && next) { old.replaceWith(next); } + }); + state = payload.state; + networkInput.checked = payload.networkSearch; + apply(); + var nextDetails = document.querySelector("footer details"); if (nextDetails) { nextDetails.open = expanded; } + if (focused) { Array.from(document.querySelectorAll("[data-row]")).some(function (row) { if (row.getAttribute("data-row") === focused) { row.focus(); return true; } return false; }); } + window.scrollTo(0, scroll); }); apply(); + window.scrollTo(0, saved.scroll || 0); + post({ type: "mounted" }); + post({ type: "search", query: searchInput.value }); })();`; } @@ -348,10 +385,11 @@ function clientScript(initialModel: string): string { * embedding the model again would double the page for nothing. `<` is escaped in * `embedJson`, so even this cannot close the script element. */ -function clientState(model: LibraryModel): Record { +export function libraryClientState(model: LibraryModel): Record { return { countTemplate: model.countTemplate, total: model.total, + localTotal: model.rows.filter((row) => !row.crossRegistry).length, query: model.query, }; } @@ -380,20 +418,24 @@ export function renderLibraryHtml(model: LibraryModel, assets: LibraryAssets): s ${escapeHtml(label(LIBRARY_UI.title))} -
    +

    ${escapeHtml(label(LIBRARY_UI.title))}

    -

    ${escapeHtml(count)}

    ${renderToolbar(model, label)}
    ${renderList(model, label)}
    -