diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b125a4..5a42e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,21 @@ ## [Unreleased] +### Added + +- **Branches 视图分支右键新增 Push(推送指定分支)**:右键任一本地分支 → `hyperGit.pushBranch`——已配置上游时按显式 refspec 推送到追踪分支(本地名/上游名不一致亦正确),无上游时选定 remote 并以 `-u` 建立追踪(语义同全局 Push 的双路径),推送后刷新视图并提示。 +- **Commit 视图标题栏新增 Discard Changes(最左 `$(discard)` 图标,批量撤销勾选文件)**:以文件列表勾选集为范围,单次 modal 确认(列示目标文件,超 10 个截断)后批量执行——未跟踪文件 `clean` 删除、已跟踪改动 `restore` 还原,语义与单文件右键 Discard Changes 一致;勾选集自 webview 单向同步 host 镜像(webview 仍为事实源),无勾选时给出提示,视图刷新经 `onDidChange → refreshAll` 驱动。同时 Set Active Changelist… 与 New Changelist… 两入口由标题栏图标位收入「…」菜单(`0_changelist` 组,居同步组之前)。 + +### Fixed + +- **两处图标名无效导致标题栏/菜单渲染空白方块**:`$(sync-ignore)` → `$(sync-ignored)`(Prune Deleted Remote Branches)、`$(compare)` → `$(git-compare)`(Compare with Current Branch)。无效 codicon id 在 VS Code 中静默渲染为空白、无任何告警,现以 [`tests/unit/codicon-validity.test.ts`](./tests/unit/codicon-validity.test.ts) 白名单护栏全量校验命令与 submenu 图标,杜绝同类漏出。 + ### Changed +- **Branches 视图标题栏重排**:① 原 Push 图标下放至分支右键菜单(见上 Added;全局 Push 仍可用 Commit 视图标题栏与命令面板)。② 「Prune Deleted Remote Branches」由「…」菜单上移至标题栏 `$(sync-ignored)` 图标(`navigation@4`,即原 Push 位;该图标全扩展唯一,既区别于同栏 `$(refresh)`/`$(sync)` 同步类,也不与右键删除类 `$(trash)` 混淆)。③ 「Merge…」由标题栏图标收入「…」菜单(同步组内,Update Project 与 New Tag… 之间),不再占用图标位。 +- **视图标题栏图标常驻引导**:VS Code 默认仅在 hover/聚焦视图时显示标题栏动作(平台行为,`ViewPane` 依 `workbench.view.alwaysShowHeaderActions` 切换 CSS 类 `actions-always-visible`,扩展贡献点无法覆盖)。现提供双入口:命令面板 **Hyper Git: Always Show View Toolbar Icons** 随时开启,以及首次激活的一次性提示(Always Show 写入全局设置并即时生效、Don't Show Again 不再打扰;设置已为 `true` 时不提示)。 - **CI 发布渠道重启双市场**:`publish` job 增回 Open VSX 发布步骤(`pnpm exec ovsx publish --packagePath ./*.vsix`,由仓库变量 `ENABLE_OVSX_PUBLISH` 门控 + `OVSX_PAT` 凭证,复用 `package` job 的同一枚 VSIX、共享 production 审批门),覆盖 Cursor / Windsurf / VSCodium 等 Open VSX 系编辑器;README 安装渠道同步加回 Open VSX。发布决策沿革见 [发布策略调研](./docs/research/04-publishing-cicd.md)。 -- **Commit 视图头部两行 UI 上移 VS Code 标题栏(省两行竖直空间)**:① 「Active Changelist」下拉与 `⋯` 管理菜单合并为标题栏 Refresh 图标左侧的 `$(checklist)` 图标按钮——点击弹出 QuickPick:各列表带文件计数、当前活动项预选,分隔线后并入 New / Rename / Delete Changelist… 操作(默认列表不可改名/删除);活动 changelist 名常驻视图副标题(标题「Commit」同行右侧)。② 文件列表 List ⇄ Tree 段控改为标题栏 `$(list-tree)`/`$(list-flat)` 互斥图标(交互同 Graph/Branches 切换范式),偏好移交 host `workspaceState` 按仓库持久化(`hyperGit.commit.dmode:`)——**原 webview state 中的旧 List/Tree 偏好一次性重置为默认 flat**(勾选集、目录折叠与提交草稿不受影响)。③ Select All 复选框吸顶于文件列表容器内首行(列表为空时随空态隐藏),原 `.cl-bar` 与 files-header 两行整体移除。 +- **Commit 视图头部两行 UI 上移 VS Code 标题栏(省两行竖直空间)**:① 「Active Changelist」下拉与 `⋯` 管理菜单合并为标题栏「…」菜单的 Set Active Changelist… 入口(New Changelist… 亦收入「…」)——点击弹出 QuickPick:各列表带文件计数、当前活动项预选,分隔线后并入 New / Rename / Delete Changelist… 操作(默认列表不可改名/删除);活动 changelist 名常驻视图副标题(标题「Commit」同行右侧,Default 活动列表时省略——无信息量不常驻)。② 文件列表 List ⇄ Tree 段控改为标题栏 `$(list-tree)`/`$(list-flat)` 互斥图标(交互同 Graph/Branches 切换范式),偏好移交 host `workspaceState` 按仓库持久化(`hyperGit.commit.dmode:`)——**原 webview state 中的旧 List/Tree 偏好一次性重置为默认 flat**(勾选集、目录折叠与提交草稿不受影响)。③ Select All 复选框吸顶于文件列表容器内首行(列表为空时随空态隐藏),原 `.cl-bar` 与 files-header 两行整体移除。 ## [0.0.17] - 2026-09-09 — Log 提交详情常驻面板 · VS Code 1.136 最佳实践对齐 · 稳定性与体验修复 diff --git a/docs/.agents/browser-validation.md b/docs/.agents/browser-validation.md deleted file mode 100644 index dd0f4f3..0000000 --- a/docs/.agents/browser-validation.md +++ /dev/null @@ -1,458 +0,0 @@ -# 浏览器验证协议 (Browser Validation Protocol) - -> **文档定位**:本文是 AI Agent 浏览器验证策略的**唯一详尽来源 (Single Source of Truth)**。[AGENTS.md › Browser Validation Protocol](../../AGENTS.md) 中仅保留摘要级约束,协议实体以本文为准。 - ---- - -## 1. 概述 - -### 1.1 适用范围 - -本协议规范 AI Agent(Claude Code、Codex 等)在以下浏览器操作场景中的行为准则: - -- **即时验证**:Agent 在开发过程中对 UI 功能、页面渲染、网络请求的实时校验; -- **E2E 回归测试**:通过 Playwright 等框架执行端到端自动化测试; -- **OAuth / SSO 链路验证**:涉及第三方 OAuth(如 Google OAuth)或内部 SSO 的认证流程检测。 - -### 1.2 核心不变量 (Invariant) - -> **唯一信任源:真实用户登录态。** -> -> Agent **不得**自行完成、绕过或模拟任何 OAuth / SSO 认证流程。所有登录态均来源于用户已认证的浏览器,登录动作由用户在自有浏览器内手动完成。 - -### 1.3 术语约定 - -| 术语 | 定义 | -| ------------------ | ------------------------------------------------------------------------------------------------------- | -| **用户浏览器** | 用户日常使用的 Chrome,包含已登录的 IdP 账号、扩展、书签等完整用户数据 | -| **Sandbox 浏览器** | 无用户数据的隔离浏览器实例(如 Playwright 默认 `chromium.launch()` 或新建空白 Chrome profile) | -| **A 类场景** | 依赖真实用户登录态的验证场景(OAuth / SSO / IdP 服务交互),**必须**通过 `claude-in-chrome` 执行 | -| **B 类场景** | 不依赖第三方 OAuth 的本地隔离场景(dev-cookie 注入、`storageState` 复用),可通过 Playwright 等框架执行 | -| **IdP** | Identity Provider,身份提供商(如 Google、GitHub、Okta 等),负责签发认证凭证 | -| **Tab Group** | `claude-in-chrome` 扩展为每个 Agent 会话创建的隔离标签组,用于管理浏览器标签页 | - ---- - -## 2. 问题域 - -当项目采用第三方 OAuth / SSO 认证流时,Agent 在 Sandbox 浏览器中访问受保护页面会被 IdP 重定向至其登录端点。由于 Sandbox 浏览器不持有任何有效会话,认证流程会被同意屏、风控策略或多因素验证拦截[[3]](#ref3),导致验证链路中断。 - -以下绕过手段均**不可行**且**被明确禁止**: - -| 手段 | 失败原因 | -| --------------------------- | ---------------------------------------------------------------------------------- | -| 自动填充密码 / 验证码 | IdP 风控基于设备指纹、IP、User-Agent 等多维信号检测异常会话[[2]](#ref2) | -| 跨浏览器复制 `storageState` | Cookie 与会话令牌绑定浏览器上下文,跨实例注入触发会话无效化 | -| Agent 代理完成 OAuth 同意屏 | 违反 AI Agent 安全准则中敏感凭证不入 chat 的硬性要求 | - ---- - -## 3. 设计约束 - -### 3.1 目标 - -所有依赖登录态的浏览器验证统一通过 `mcp__claude-in-chrome__*` 接入用户**常用 Chrome**,复用**真实用户**的已认证会话态。用户仅需在自有浏览器中手动完成一次登录。 - -### 3.2 非目标 - -- 不实现密码自动填充 / 验证码自动接收 / CAPTCHA 自动求解; -- 不在 Sandbox / 空白 profile / 模拟身份下处理 OAuth / SSO 登录跳转; -- 不在 CI / 共享环境中长期托管真实账号 `storageState`; -- 不为多账号热切换提前抽象 profile manager(YAGNI)。 - ---- - -## 4. 架构决策 - -### 4.1 驱动选型 - -**唯一驱动:`mcp__claude-in-chrome__*`(Claude in Chrome 扩展)。** - -选型依据: - -1. 通过 Chrome 扩展直接接入用户日常使用的 Chrome,共享完整设备指纹与登录历史,规避 IdP 风控拦截; -2. 无需用户手动添加 `--remote-debugging-port` 启动参数,扩展安装后自动建立连接,零额外配置; -3. 提供 Tab Group 隔离机制,每个 Agent 会话拥有独立标签组,互不干扰; -4. 同时支持视觉交互(`computer` 截图 + 鼠标键盘)与无障碍树(`read_page`)双通道,适应不同验证需求; -5. 内置批量操作(`browser_batch`)与 GIF 录制(`gif_creator`),提升验证效率与文档化能力。 - -> **A 类(交互)vs B 类(自治)的驱动分工**:上述"唯一驱动"针对 **A 类交互场景**——由开发者或 Agent 在**真人监督**的会话中验证 OAuth/SSO/认证态 UI,复用用户真实 Chrome 登录态。而 **B 类自治场景**([Routine](../concepts/039-the-routine-system.md) 后台 Claude Code 子进程:无真人、无桌面、headless)无法使用 claude-in-chrome(其需可见桌面浏览器 + 人工完成登录/CAPTCHA,且非 command/url 形态、无法注入 mcp_config)。为此,系统将 **Playwright MCP(`@playwright/mcp`,headless · isolated)内置为全系统默认浏览器操作 MCP**,经单一注入点 `builtin_tools(claude_code).config.mcp_config` provision 至所有 Routine 运行时,用于浏览器实机回归验证;鉴权回归复用本仓 dev-cookie storageState 旁路(§9,**非** IdP storageState 复制)。选型论证见 [浏览器操作 MCP 调研](../research/120-browser-automation-mcp.md),集成与使用见 [浏览器操作 MCP 集成方案](../concepts/design/browser-automation-mcp-integration.md)。 - -### 4.2 工具职责矩阵 - -| 维度 | `mcp__claude-in-chrome__*`(**唯一驱动**) | `mcp__playwright__*`(**仅限 B 类场景**) | -| ------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| 浏览器实例 | 用户常用 Chrome(扩展接入,共享真实 profile) | Playwright 自启动 Chromium(独立 Sandbox) | -| 登录态来源 | 用户原生已登录 Chrome;扩展自动共享会话 | 默认空 profile,需 `storageState` / `userDataDir` 注入 | -| OAuth 兼容性 | ✅ 高(同设备指纹 / 同登录历史) | ❌ 禁止(触发 IdP 风控拦截) | -| 适用场景 | A 类:Agent 即时验证、OAuth/SSO 链路、IdP 服务交互、UI 回归 | B 类:① E2E setup project 人工登录后 `storageState` 复用;② 本地 dev-cookie 注入 | -| 凭证安全语义 | 用户全程在自有浏览器内完成敏感操作,不持久化任何凭证副本 | `storageState` / `userDataDir` 仅落本地,须 `.gitignore` 保护 | -| 额外能力 | `gif_creator` 录制验证过程;`browser_batch` 批量操作;`find` 自然语言元素定位 | 完整 Playwright API(测试框架级控制) | - -### 4.3 核心工具速查 - -| 功能 | 工具 | 说明 | -| ---------- | ---------------------------------- | --------------------------------------------------------- | -| 连通性检查 | `tabs_context_mcp` | 获取当前 Tab Group 信息,`createIfEmpty: true` 可自动创建 | -| 新建标签页 | `tabs_create_mcp` | 在当前 Tab Group 中创建新标签 | -| 导航 | `navigate` | 跳转至指定 URL | -| 页面读取 | `read_page` | 获取无障碍树(结构化,适合交互操作) | -| 视觉截图 | `computer` (screenshot) | 截取当前页面或指定区域截图(适合视觉验证) | -| 元素查找 | `find` | 以自然语言描述查找页面元素 | -| 表单填写 | `form_input` / `browser_fill_form` | 设置表单元素值 | -| JS 执行 | `javascript_tool` | 在页面上下文执行 JavaScript | -| 点击交互 | `computer` (left_click) | 通过坐标点击页面元素 | -| 键盘输入 | `computer` (type) / `type_text` | 键入文本 | -| 控制台日志 | `read_console_messages` | 读取浏览器控制台输出 | -| 网络请求 | `read_network_requests` | 读取页面网络请求 | -| 批量操作 | `browser_batch` | 单次调用顺序执行多个操作 | -| GIF 录制 | `gif_creator` | 录制验证过程并导出 | -| 关闭标签 | `tabs_close_mcp` | 关闭指定标签 | - -### 4.4 场景路由决策 - -```mermaid -flowchart TD - Start["需要浏览器验证"] --> NeedAuth{"涉及登录态?
(OAuth / SSO / 真实用户)"} - - NeedAuth -->|"A 类"| CiC["mcp__claude-in-chrome__*
接入用户 Chrome"] - CiC --> Reachable{"tabs_context_mcp
可达?"} - Reachable -->|是| TabCreate["tabs_create_mcp
创建验证标签"] - TabCreate --> OK["✅ 直接使用
(已含真实登录态)"] - Reachable -->|否| Recover["引导用户:
1. 确认 Chrome 已启动
2. 确认 claude-in-chrome 扩展已安装启用
3. 点击扩展图标完成连接"] - Recover --> OK - - NeedAuth -->|"B 类"| BRoute{"隔离场景分流"} - BRoute --> E2E["E2E setup project
真人一次性登录 →
storageState 复用"] - BRoute --> DevCK["本地 dev-cookie 注入
(不接触 OAuth)"] - - NeedAuth -.->|"严禁"| Forbid["❌ Sandbox 浏览器
跳转 IdP 同意屏"] - - style Start fill:#0f172a,stroke:#60a5fa,color:#e2e8f0 - style CiC fill:#0b1f2a,stroke:#34d399,color:#ecfdf5 - style OK fill:#052e16,stroke:#22c55e,color:#dcfce7 - style Recover fill:#1f1d0a,stroke:#f59e0b,color:#fef3c7 - style E2E fill:#0b1f2a,stroke:#3b82f6,color:#e6edf3 - style DevCK fill:#0b1f2a,stroke:#3b82f6,color:#e6edf3 - style NeedAuth fill:#1f2937,stroke:#a78bfa,color:#ede9fe - style Reachable fill:#1f2937,stroke:#a78bfa,color:#ede9fe - style BRoute fill:#1f2937,stroke:#a78bfa,color:#ede9fe - style Forbid fill:#3b0a0a,stroke:#f87171,color:#fee2e2 -``` - ---- - -## 5. 安全模型 - -### 5.1 禁止行为 (Forbidden Actions) - -| 编号 | 禁止行为 | 违反原则 | -| ---- | ------------------------------------------------------------ | ---------------- | -| F-1 | 在 Sandbox 浏览器中跳转 IdP 同意屏 | 核心不变量 §1.2 | -| F-2 | 以模拟用户或第三方账号替代真实用户完成登录态验证 | 核心不变量 §1.2 | -| F-3 | 要求用户在 chat 中粘贴密码、Cookie 或一次性验证码 | 敏感信息保护准则 | -| F-4 | 读取、复制或传输用户密码 / 验证码 / Refresh Token | 敏感信息保护准则 | -| F-5 | 将 `storageState` / `cookies` / `userDataDir` 提交至版本控制 | 凭证泄漏防护 | - -### 5.2 凭证生命周期管理 - -```mermaid -flowchart LR - subgraph 生成["凭证生成"] - direction TB - A1["用户在自有 Chrome
手动完成 OAuth 登录"] - A2["E2E setup project
导出 storageState"] - A3["项目签名脚本
生成 dev-cookie"] - end - - subgraph 存储["本地存储"] - direction TB - B1["storageState JSON"] - B2["dev-cookie JSON"] - B3["Chrome 用户 profile
(claude-in-chrome 共享)"] - end - - subgraph 防护["泄漏防护"] - direction TB - C1[".gitignore 屏蔽"] - C2[".dockerignore 屏蔽"] - C3["禁止 CI 挂载
真实账号 storageState"] - end - - A1 --> B3 - A2 --> B1 - A3 --> B2 - B1 & B2 --> C1 & C2 - B3 --> C3 - - style 生成 fill:#0f172a,stroke:#60a5fa,color:#e2e8f0 - style 存储 fill:#1f2937,stroke:#a78bfa,color:#ede9fe - style 防护 fill:#052e16,stroke:#22c55e,color:#dcfce7 -``` - -### 5.3 事件响应 - -若怀疑会话凭证泄漏: - -1. 立即通过 IdP 的设备管理页面撤销相关会话(如 [Google 设备管理](https://myaccount.google.com/device-activity)); -2. 删除本地所有 `storageState` 与 `userDataDir` 产物; -3. 重新执行 §6 连通性自检流程。 - ---- - -## 6. 运维规程:连通性自检 - -每次会话**首次**需要登录态浏览前,Agent **必须**按以下顺序执行自检。任一步骤失败应**立即中止**并向用户报告现象,**禁止**以"换 Sandbox 重试"等方式暗箱降级。 - -### Step 1 — 扩展接入与 Tab Group 初始化 - -```ts -// 1) 获取 Tab Group 上下文(自动创建若不存在) -mcp__claude-in-chrome__tabs_context_mcp({ createIfEmpty: true }); - -// 2) 创建验证标签 -mcp__claude-in-chrome__tabs_create_mcp(); - -// 3) 导航至 IdP 账号页验证登录态(以 Google 为例) -mcp__claude-in-chrome__navigate({ url: "https://myaccount.google.com", tabId: }); -``` - -**验收条件**: - -- `tabs_context_mcp` 返回有效 Tab Group 信息(包含至少一个 tab ID); -- 导航至 IdP 账号页后,页面内容包含用户账号标识(如邮箱地址),可通过 `get_page_text` 或 `read_page` 确认。 - -**异常处理**: - -| 现象 | 处置 | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `tabs_context_mcp` 返回空或报错 | 引导用户确认:① Chrome 已启动并处于前台;② claude-in-chrome 扩展已安装且已启用;③ 点击扩展图标完成连接,**禁止**改用 Sandbox | -| 接入成功但 IdP 账号页未识别用户 | 引导用户在该 Chrome 中手动登录目标账号(Agent 不接触密码 / 验证码) | - -> [!NOTE] -> 若用户有多个 Chrome profile,需确保 claude-in-chrome 扩展安装在包含目标 IdP 账号的 profile 中。 - -### Step 2 — 应用 OAuth 链路打通 - -```ts -// 替换为项目实际的 OAuth 登录入口 -mcp__claude-in-chrome__navigate({ - url: "http://localhost:/", - tabId: , -}); -``` - -**前置条件**:本地 dev server(前端 + 后端)已启动。 - -**验收条件**:无需重新输入密码,自动回跳应用首页并完成会话写入。 - -**异常处理**:检查项目配置中 OAuth callback URL 是否与当前运行端口一致。 - -> [!IMPORTANT] -> Step 1 与 Step 2 之间应保持 **≥ 3 秒**间隔,避免短时高频跳转触发 IdP CAPTCHA 验证。 - ---- - -## 7. 常用操作模式 - -### 7.1 页面验证 - -```ts -// 方式 A:无障碍树读取(推荐用于交互操作) -mcp__claude-in-chrome__read_page({ tabId: }); - -// 方式 B:视觉截图(推荐用于 UI 验证) -mcp__claude-in-chrome__computer({ action: "screenshot", tabId: }); -``` - -### 7.2 元素交互 - -```ts -// 自然语言查找元素 -mcp__claude-in-chrome__find({ query: "登录按钮", tabId: }); - -// 通过坐标点击 -mcp__claude-in-chrome__computer({ action: "left_click", coordinate: [x, y], tabId: }); - -// 表单填写 -mcp__claude-in-chrome__form_input({ ref: "", value: "", tabId: }); -``` - -### 7.3 调试辅助 - -```ts -// 读取控制台日志 -mcp__claude-in-chrome__read_console_messages({ tabId: , pattern: "error|warning|" }); - -// 读取网络请求 -mcp__claude-in-chrome__read_network_requests({ tabId: , urlPattern: "/api/" }); - -// 执行 JavaScript -mcp__claude-in-chrome__javascript_tool({ action: "javascript_exec", text: "document.title", tabId: }); -``` - -### 7.4 批量操作 - -单次调用顺序执行多个操作,减少回合开销: - -```ts -mcp__claude-in-chrome__browser_batch({ - actions: [ - { name: "navigate", input: { url: "http://localhost:/login", tabId: } }, - { name: "computer", input: { action: "screenshot", tabId: } }, - { name: "find", input: { query: "登录按钮", tabId: } }, - { name: "computer", input: { action: "left_click", coordinate: [x, y], tabId: } }, - ], -}); -``` - -### 7.5 验证过程录制 - -对于需要留档或分享的验证流程,可使用 GIF 录制: - -```ts -// 开始录制 -mcp__claude-in-chrome__gif_creator({ action: "start_recording", tabId: }); - -// ... 执行验证操作 ... - -// 停止录制并导出 -mcp__claude-in-chrome__gif_creator({ - action: "export", - tabId: , - download: true, - filename: "validation-.gif", -}); -``` - ---- - -## 8. E2E 测试集成 - -### 8.1 会话复用工作流 - -Playwright E2E 测试通过 `setup` project 实现一次性人工登录后的会话复用[[1]](#ref1): - -```mermaid -sequenceDiagram - participant Dev as 开发者 - participant PW as Playwright Runner - participant Browser as Headed Chromium - participant FS as 本地文件系统 - - rect rgb(15, 23, 42) - Note over Dev,FS: Phase 1 — 一次性登录(有头模式) - Dev->>PW: 执行 setup project - PW->>Browser: 启动有头浏览器 - Browser-->>Dev: 弹出 IdP 登录窗口 - Dev->>Browser: 手动完成 OAuth 登录 - Browser-->>PW: page.context().storageState() - PW->>FS: 写入 .auth/user.json - end - - rect rgb(5, 46, 22) - Note over Dev,FS: Phase 2 — 测试执行(无头模式) - Dev->>PW: 执行测试 - PW->>Browser: 启动新 context(headless) - PW->>Browser: 注入 storageState - Browser->>Browser: 以已登录态访问应用 - end -``` - -### 8.2 会话失效与刷新 - -| 触发条件 | 应对措施 | -| ------------------------------- | -------------------------------------------------------------------- | -| IdP 会话过期 | 删除 `storageState` 文件后重跑 `setup` project(`--headed` 模式) | -| 后端会话存储重建 | 同上 | -| Cookie 域 / `SameSite` 属性变更 | 同上;建议 setup project 末尾增加 URL 断言确认登录成功(见 §10 R-2) | - -### 8.3 CI 环境注意事项 - -依赖真实登录态的集成测试需外部后端服务与有效认证密钥。CI 环境中建议: - -- 通过环境变量开关控制 authed project 的注册,缺失时自动跳过; -- 认证辅助模块不内联 secret(防止入库),env 缺失时 fail-fast 并指向本文档; -- 不依赖后端的 mocked spec 始终全跑,确保 CI 覆盖率。 - ---- - -## 9. Dev-Cookie 旁路方案 - -适用于**不经第三方 OAuth** 的本地 Agent 开发场景,通过项目自签 cookie 直接获取业务页面访问权限。 - -### 9.1 工作原理 - -项目提供签名脚本,基于共享密钥(如 `AUTH_TOKEN_SECRET`)生成合法的会话 cookie。该 cookie 与 OAuth 颁发的 cookie 格式相同,后端 token 解码逻辑不区分来源,因此可在本地开发中绕过 OAuth 流程。 - -### 9.2 注入方式 - -**方式 A — 通过 `claude-in-chrome` 运行时注入**(适用于 Agent 即时验证): - -```ts -// 1. 导航至应用同源页面(建立 cookie 作用域) -mcp__claude-in-chrome__navigate({ url: "http://localhost:/", tabId: }); - -// 2. 注入 dev-cookie -mcp__claude-in-chrome__javascript_tool({ - action: "javascript_exec", - text: `document.cookie = "=; path=/; SameSite=Lax"; document.cookie.includes("")`, - tabId: , -}); - -// 3. 导航至目标页面 -mcp__claude-in-chrome__navigate({ url: "http://localhost:/", tabId: }); -``` - -**方式 B — `storageState` 持久化**(推荐用于 spec 复用): - -```bash -# 1. 生成 storageState 文件 -node --storage-state - -# 2. 以 storageState 运行测试 -PLAYWRIGHT_STORAGE_STATE= \ -npx playwright test -``` - -### 9.3 注意事项 - -| 项目 | 说明 | -| -------------------------- | --------------------------------------------------------------------------------------------- | -| `localhost` vs `127.0.0.1` | 若 dev server 仅监听 `localhost`(IPv6),浏览器导航**必须**使用 `localhost` 而非 `127.0.0.1` | -| `httpOnly` 属性 | 通过 `document.cookie` 注入的 cookie 为非 httpOnly,但浏览器仍随后续同源请求发送 | -| Cookie 持久性 | 浏览器 context 在导航间保持 cookie,但 context 关闭后丢失 | - ---- - -## 10. 风险矩阵 - -| 编号 | 风险 | 影响 | 概率 | 对策 | -| ---- | ------------------- | ---------------------------------------------- | ---- | --------------------------------------------------------------------------- | -| R-1 | IdP 风控误报 | 短时高频跳转触发 CAPTCHA / 邮箱验证 | 中 | 自检各步骤间保持 ≥ 3s 间隔;避免自动化循环中重复触发 OAuth | -| R-2 | `storageState` 漂移 | Cookie 域 / `SameSite` 变更后旧 state 静默失效 | 低 | setup project 末尾增加 URL 断言确认登录成功(如排除 `/auth/login` 路径) | -| R-3 | 多账号干扰 | 浏览器同时登录多个 IdP 账号致 OAuth 选择器弹出 | 低 | 自检失败时将账号选择器纳入指引,由用户在浏览器内显式选择目标账号 | -| R-4 | 凭证泄漏 | `storageState` 文件意外提交至版本控制 | 低 | `.gitignore` + `.dockerignore` 双重屏蔽;CI 禁止挂载真实账号 `storageState` | -| R-5 | 扩展连接中断 | claude-in-chrome 扩展与 Agent 会话断连 | 低 | 引导用户点击扩展图标重新建立连接;必要时重启 Chrome | - ---- - -## 附录. 协议演进记录 - -| 日期 | 变更 | -| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-05-06 | 废弃浏览器扩展首选方案,统一收敛至 `mcp__chrome_devtools__*` 唯一驱动 | -| 2026-05-15 | 文档结构重组:引入术语约定、安全模型、风险矩阵;将项目特化案例移至附录 | -| 2026-05-15 | 协议泛化:移除项目特化描述,使协议可通用于所有需要浏览器验证的 Agent 行为规范 | -| 2026-05-16 | 驱动迁移:从 `mcp__chrome_devtools__*` 迁移至 `mcp__claude-in-chrome__*`,利用 Chrome 扩展实现零配置接入;新增常用操作模式章节;泛化所有项目特化引用 | -| 2026-06-06 | 厘清 A 类(claude-in-chrome 交互)/ B 类(自治)分工;将 **Playwright MCP(`@playwright/mcp`)内置为全系统默认浏览器操作 MCP**,经 `builtin_tools(claude_code).config.mcp_config` 单一注入点 provision 至所有 Routine 运行时,用于浏览器实机回归验证(见 [集成方案](../concepts/design/browser-automation-mcp-integration.md)) | - ---- - -## References (IEEE) - -[1] Microsoft, "Authentication," _Playwright Documentation_, 2025. [Online]. Available: https://playwright.dev/docs/auth. - -[2] OWASP Foundation, "Session Management Cheat Sheet," _OWASP Cheat Sheet Series_, 2024. [Online]. Available: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html. - -[3] D. Hardt, "The OAuth 2.0 Authorization Framework," _IETF RFC 6749_, Oct. 2012, doi: 10.17487/RFC6749. diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index ff492da..24c39cc 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -11,8 +11,7 @@ ## Agents 知识库(本目录) - [Issue 记录](./issue.md) — 跨上下文问题处理经验(表因 / 根因 / 处理 / 防范)。 -- [引用规范 IEEE](./reference-specifications.md) — 文献引用格式与上标锚定。 -- [浏览器验证协议](./browser-validation.md) — OAuth/SSO 红线与 E2E 验证协议。 +- 引用规范(IEEE)与浏览器验证协议:以全局 `~/.agents/docs/` 为单一事实源(`reference-specifications.md` / `browser-validation.md`),仓库内不再留副本。 ## 项目文档(docs/) - [文档中心](../docs/README.md) — 文档与调研资产总索引。 diff --git a/docs/.agents/reference-specifications.md b/docs/.agents/reference-specifications.md deleted file mode 100644 index 896b866..0000000 --- a/docs/.agents/reference-specifications.md +++ /dev/null @@ -1,16 +0,0 @@ -# Reference Specifications (IEEE) - -> **模版准则**:[编号] 作者缩写. 姓, "文章标题," _刊名/会议名缩写 (斜体)_, 卷号, 期数, 页码, 年份. - -```latex -[1] A. Author, B. Author, and C. Author, "Title of paper," *Abbrev. Title of Journal*, vol. X, no. Y, pp. XX–XX, Year. -``` - -**引用实践** - -- **文内锚定**:采用标准上标链接形式:`描述内容[[1]](#ref1)`。 -- **文献索引**:底层采用 HTML 锚点 `id` 实现跳转稳定性。 - -```latex -[1] A. Vaswani et al., "Attention is all you need," Adv. Neural Inf. Process. Syst., vol. 30, pp. 5998–6008, 2017. -``` diff --git a/docs/README.md b/docs/README.md index c00c0d7..c2c9526 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,4 +24,4 @@ ## 协作与规范 - [AGENTS.md](../AGENTS.md) — 协作协议与工程行为准则。 -- [知识索引](./.agents/knowledge-map.md) · [Issue 记录](./.agents/issue.md) · [引用规范 IEEE](./.agents/reference-specifications.md)。 +- [知识索引](./.agents/knowledge-map.md) · [Issue 记录](./.agents/issue.md)。引用规范(IEEE)与浏览器验证协议以全局 `~/.agents/docs/` 为准(见 AGENTS.md)。 diff --git a/docs/features/commit-view-consolidation.md b/docs/features/commit-view-consolidation.md index fab7002..fa0680c 100644 --- a/docs/features/commit-view-consolidation.md +++ b/docs/features/commit-view-consolidation.md @@ -8,8 +8,8 @@ |---|---| | 文件单击看 Diff | 文件行单击(勾选框除外)→ `commit/openFile` → 复用 `hyperGit.openDiff` | | 单文件右键菜单 | 文件行右键 → `commit/fileMenu` → 原生 `showQuickPick`:Open Diff / Move to Changelist… / Show History / Stage·Unstage Hunks… / Add to .gitignore / Discard Changes(复用既有命令,含 discard 确认框) | -| Changelist 管理 | 标题栏 `$(checklist)` 图标(Refresh 左侧)→ `hyperGit.setActiveChangelist` QuickPick:各列表带文件计数、当前活动项 `picked` 预选,分隔线后并入 New / Rename / Delete…(默认列表不可改名删除);活动列表名常驻 `view.description` 副标题(原头部 `` + `⋯` 菜单行已上移,省一行竖直空间) | +| Git 操作工具栏 | 标题栏(`view/title`)承接 refresh / push / pull / fetch / pushDialog / updateProject / createPatch / applyPatch;最左 `$(discard)` 图标 Discard Changes 批量撤销勾选文件(`discardChanges` 单/多统一路径);New Changelist… 收入「…」菜单;去掉与 webview 内 Commit·Commit&Push 按钮重复的两项 | | 活动栏未提交数角标 | 迁至 Commit `WebviewView.badge`(容器图标角标 = 各视图 badge 之和,总数不变) | ## 关键设计(复用与最小回归) @@ -22,16 +22,16 @@ ## 交互范围取舍 -Commit 视图文件区以**活动 Changelist** 为提交目标(勾选集即提交范围);非活动 Changelist 通过标题栏 `$(checklist)` 图标弹 QuickPick 切换,活动列表名常驻 `view.description` 副标题。相较旧树「同屏并列多 changelist」少了一处低频便利,换取窄侧栏下更聚焦的提交工作流。 +Commit 视图文件区以**活动 Changelist** 为提交目标(勾选集即提交范围);非活动 Changelist 通过标题栏「…」菜单 Set Active Changelist… 弹 QuickPick 切换,活动列表名常驻 `view.description` 副标题(Default 省略)。相较旧树「同屏并列多 changelist」少了一处低频便利,换取窄侧栏下更聚焦的提交工作流。 ## 实现 - 视图与菜单:[`package.json`](../../package.json)(删 `hyperGit.changes` 视图 / viewsWelcome / 全部 `view == hyperGit.changes` 菜单;`view/title` 迁至 `view == hyperGit.commit`)。 - 视图主体:[`adapter/webview/commit-webview.ts`](../../src/adapter/webview/commit-webview.ts)(groups/文件交互/菜单/角标/目录树;changelist 切换与管理、List/Tree 切换已上移标题栏,mode 以 host 为事实源按仓库持久化)。 -- 命令:[`adapter/commands.ts`](../../src/adapter/commands.ts)(`resolveChange` + 签名重构;`setActiveChangelist` QuickPick 承载切换与管理)。 +- 命令:[`adapter/commands.ts`](../../src/adapter/commands.ts)(`resolveChange` + 签名重构;`setActiveChangelist` QuickPick 承载切换与管理;`discardChanges` 单/多统一路径:归一数组 + modal 批量确认 + clean/restore 各一次调用)。 - 装配:[`extension.ts`](../../src/extension.ts)(移除 tree/changesView,角标改由 `commitView.updateBadge`)。 -- 协议:[`shared/protocol.ts`](../../src/shared/protocol.ts)(`commit/openFile|fileMenu`;changelist 操作消息已随标题栏上移移除)。 +- 协议:[`shared/protocol.ts`](../../src/shared/protocol.ts)(`commit/openFile|fileMenu|checkedChanged`;changelist 操作消息已随标题栏上移移除,`checkedChanged` 为勾选集 webview → host 单向镜像,供标题栏批量 Discard)。 ## 验证 -`pnpm run check-types && pnpm run lint && pnpm run test:unit` 全绿;Extension Development Host(F5):无 CHANGES 视图;标题栏 `$(checklist)` 图标可切换/管理 changelist、副标题显示活动列表名;文件单击开 Diff、右键出菜单(含 discard 确认);标题栏 Git 动作可用;活动栏角标随未提交数增减;无 Git 仓库时降级不崩溃。 +`pnpm run check-types && pnpm run lint && pnpm run test:unit` 全绿;Extension Development Host(F5):无 CHANGES 视图;标题栏最左 `$(discard)` 图标按 modal 确认批量撤销勾选文件(未跟踪删除、已跟踪还原,无勾选时仅提示);「…」菜单可切换/管理 changelist(Set Active Changelist… QuickPick)与新建列表、副标题显示活动列表名(Default 时无副标题);文件单击开 Diff、右键出菜单(含 discard 确认);标题栏 Git 动作可用;活动栏角标随未提交数增减;无 Git 仓库时降级不崩溃。 diff --git a/package.json b/package.json index 5cbcf06..b46f94e 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,11 @@ "title": "Show Version", "category": "Hyper Git" }, + { + "command": "hyperGit.alwaysShowHeaderActions", + "title": "Always Show View Toolbar Icons", + "category": "Hyper Git" + }, { "command": "hyperGit.refresh", "title": "Refresh", @@ -285,6 +290,12 @@ "category": "Hyper Git", "icon": "$(list-flat)" }, + { + "command": "hyperGit.commit.discardSelected", + "title": "Discard Changes", + "category": "Hyper Git", + "icon": "$(discard)" + }, { "command": "hyperGit.selectRepository", "title": "Select Repository…", @@ -445,7 +456,7 @@ "command": "hyperGit.pruneRemotes", "title": "Prune Deleted Remote Branches", "category": "Hyper Git", - "icon": "$(clear-all)" + "icon": "$(sync-ignored)" }, { "command": "hyperGit.cherryPick", @@ -497,7 +508,7 @@ "command": "hyperGit.compareWithCurrent", "title": "Compare with Current Branch", "category": "Hyper Git", - "icon": "$(compare)" + "icon": "$(git-compare)" }, { "command": "hyperGit.tagCreate", @@ -644,6 +655,12 @@ "category": "Hyper Git", "icon": "$(git-merge)" }, + { + "command": "hyperGit.pushBranch", + "title": "Push", + "category": "Hyper Git", + "icon": "$(arrow-up)" + }, { "command": "hyperGit.resolveConflicts", "title": "Resolve Conflicts (3-way Merge Editor)…", @@ -830,7 +847,7 @@ ], "view/title": [ { - "command": "hyperGit.setActiveChangelist", + "command": "hyperGit.commit.discardSelected", "when": "view == hyperGit.commit", "group": "navigation@0.5" }, @@ -845,14 +862,19 @@ "group": "navigation@0.75" }, { - "command": "hyperGit.refresh", + "command": "hyperGit.setActiveChangelist", "when": "view == hyperGit.commit", - "group": "navigation@1" + "group": "0_changelist@0" }, { "command": "hyperGit.newChangelist", "when": "view == hyperGit.commit", - "group": "navigation@2" + "group": "0_changelist@1" + }, + { + "command": "hyperGit.refresh", + "when": "view == hyperGit.commit", + "group": "navigation@1" }, { "command": "hyperGit.push", @@ -955,15 +977,10 @@ "group": "navigation@3" }, { - "command": "hyperGit.push", + "command": "hyperGit.pruneRemotes", "when": "view == hyperGit.branches", "group": "navigation@4" }, - { - "command": "hyperGit.mergeDialog", - "when": "view == hyperGit.branches", - "group": "navigation@5" - }, { "command": "hyperGit.branchesGroupByPrefix", "when": "view == hyperGit.branches && !hyperGit.branchesGrouping", @@ -979,11 +996,6 @@ "when": "view == hyperGit.branches", "group": "1_sync@1" }, - { - "command": "hyperGit.pruneRemotes", - "when": "view == hyperGit.branches", - "group": "1_sync@1.5" - }, { "command": "hyperGit.updateProject", "when": "view == hyperGit.branches", @@ -1028,6 +1040,11 @@ "command": "hyperGit.worktreePrune", "when": "view == hyperGit.worktrees", "group": "navigation@3" + }, + { + "command": "hyperGit.mergeDialog", + "when": "view == hyperGit.branches", + "group": "1_sync@2.5" } ], "view/item/context": [ @@ -1046,6 +1063,11 @@ "when": "view == hyperGit.branches && viewItem == hyperGit.remoteBranch", "group": "9_dangerous@2" }, + { + "command": "hyperGit.pushBranch", + "when": "view == hyperGit.branches && viewItem == hyperGit.branch && !listMultiSelection", + "group": "1_branch@2" + }, { "command": "hyperGit.mergeBranch", "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", @@ -1411,6 +1433,10 @@ "command": "hyperGit.commit.detailFlat", "when": "view == hyperGit.commit" }, + { + "command": "hyperGit.commit.discardSelected", + "when": "view == hyperGit.commit" + }, { "command": "hyperGit.acceptOurs", "when": "hyperGit.hasConflicts" @@ -1418,6 +1444,10 @@ { "command": "hyperGit.acceptTheirs", "when": "hyperGit.hasConflicts" + }, + { + "command": "hyperGit.pushBranch", + "when": "false" } ] }, @@ -1540,7 +1570,7 @@ "esbuild": "^0.28.1", "eslint": "^10.9.1", "mocha": "^11.0.1", - "ovsx": "^1.1.1", + "ovsx": "^1.1.1", "prettier": "^3.9.6", "rimraf": "^6.0.1", "typescript": "^6.0.3", diff --git a/src/adapter/commands.ts b/src/adapter/commands.ts index b18307a..f6f9bf5 100644 --- a/src/adapter/commands.ts +++ b/src/adapter/commands.ts @@ -184,32 +184,51 @@ export function registerChangesCommands( ); subs.push( - vscode.commands.registerCommand('hyperGit.discardChanges', async (arg: ChangeItem | string) => { - const repo = service.repo; - const change = resolveChange(arg); - if (!repo || !change) { - return; - } - const choice = await vscode.window.showWarningMessage( - `Discard changes to "${change.relativePath}"? This action cannot be undone.`, - { modal: true }, - 'Discard', - ); - if (choice !== 'Discard') { - return; - } - try { - // 未跟踪文件用 clean(删除);已跟踪的改动用 restore(丢弃工作区改动)。 - // 视图刷新由 service.onDidChange → refreshAll 驱动。 - if (change.status === FileStatus.Untracked) { - await repo.clean([change.uri.fsPath]); - } else { - await repo.restore([change.uri.fsPath]); + vscode.commands.registerCommand( + 'hyperGit.discardChanges', + // 单文件(文件右键菜单)与批量(Commit 标题栏勾选集)统一路径:归一为数组后逐项解析过滤。 + async (arg: ChangeItem | string | readonly (ChangeItem | string)[]) => { + const repo = service.repo; + const changes = (Array.isArray(arg) ? arg : [arg]) + .map(resolveChange) + .filter((c): c is ChangeItem => Boolean(c)); + if (!repo || changes.length === 0) { + return; } - } catch (e) { - void vscode.window.showErrorMessage(`Failed to discard: ${e instanceof Error ? e.message : String(e)}`); - } - }), + const paths = changes.map((c) => c.relativePath); + const choice = await vscode.window.showWarningMessage( + paths.length === 1 + ? `Discard changes to "${paths[0]}"? This action cannot be undone.` + : `Discard changes to ${paths.length} selected files? This action cannot be undone.`, + // 批量时列示目标文件(超出 10 个截断),破坏性操作保持可见范围。 + { + modal: true, + detail: + paths.length > 1 + ? paths.slice(0, 10).join('\n') + (paths.length > 10 ? `\n… and ${paths.length - 10} more` : '') + : undefined, + }, + 'Discard', + ); + if (choice !== 'Discard') { + return; + } + try { + // 未跟踪文件用 clean(删除);已跟踪的改动用 restore(丢弃工作区改动),两类各一次调用。 + // 视图刷新由 service.onDidChange → refreshAll 驱动。 + const untracked = changes.filter((c) => c.status === FileStatus.Untracked).map((c) => c.uri.fsPath); + const tracked = changes.filter((c) => c.status !== FileStatus.Untracked).map((c) => c.uri.fsPath); + if (untracked.length > 0) { + await repo.clean(untracked); + } + if (tracked.length > 0) { + await repo.restore(tracked); + } + } catch (e) { + void vscode.window.showErrorMessage(`Failed to discard: ${e instanceof Error ? e.message : String(e)}`); + } + }, + ), ); return subs; diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index ef41e00..0a9b9af 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -314,6 +314,49 @@ export function registerHistoryCommands( }), ); + subs.push( + vscode.commands.registerCommand('hyperGit.pushBranch', async (node: BranchNode) => { + // 分支右键推送(原 Branches 标题栏 Push 下放):仅本地分支;上游取自 RawRef.upstream + // (%(upstream:short),非当前分支无 HEAD.upstream 可用),无上游路径与 hyperGit.push 一致。 + const repo = service.repo; + if (!repo || node?.kind !== 'branch' || node.remote) { + return; + } + const name = node.ref.shortName; + try { + const up = node.ref.upstream; + if (up) { + // 已配置上游:显式 refspec 推送(本地名/上游名不一致亦正确;remote 名约定不含 /)。 + const slash = up.indexOf('/'); + const remote = slash >= 0 ? up.slice(0, slash) : up; + const upstreamName = slash >= 0 ? up.slice(slash + 1) : name; + await runWithProgress('Pushing…', () => repo.push(remote, `${name}:${upstreamName}`)); + } else { + // 无上游:选定 remote 并以 -u 建立追踪(修复「Failed to execute git」根因)。 + const remotes = repo.state.remotes.map((r) => r.name); + if (remotes.length === 0) { + void vscode.window.showWarningMessage('No remote configured; cannot push'); + return; + } + const remote = + remotes.length === 1 + ? remotes[0] + : await vscode.window.showQuickPick(remotes, { + placeHolder: `Select a remote to push "${name}" to (will set upstream tracking -u)`, + }); + if (!remote) { + return; + } + await runWithProgress('Pushing…', () => repo.push(remote, name, true)); + } + branchesTree.refresh(); + void vscode.window.showInformationMessage(`Pushed ${name}`); + } catch (e) { + void showGitError(`Failed to Push: ${errMsg(e)}`); + } + }), + ); + subs.push( vscode.commands.registerCommand('hyperGit.fetch', async () => { const repo = service.repo; diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index 57db6d1..8f6b980 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -22,7 +22,9 @@ import { getNonce } from './nonce'; * 单文件右键操作 + 多行 Commit Message 编辑器 + Amend/sign-off/skip-hooks 选项 + * Commit/Commit and Push 按钮 + Conventional Commits 实时校验 + 最近消息复用。 * changelist 切换与管理(New/Rename/Delete)由标题栏 $(checklist) 图标 → setActiveChangelist - * QuickPick 承载,活动列表名常驻 view.description 副标题;Select All 吸顶于文件列表容器内首行。 + * QuickPick 承载,活动列表名常驻 view.description 副标题(Default 省略——无信息量不常驻); + * 标题栏「…」菜单 Discard Changes 批量撤销勾选文件(勾选集经 checkedChanged 单向镜像至 host)。 + * Select All 吸顶于文件列表容器内首行。 * 选中态由 webview 端管理(host 不回写,避免覆盖用户操作)。 * 注:活动栏未提交数角标已迁至隐藏的 hyperGit.changesBadge TreeView 承载(见 extension.ts)。 */ @@ -32,6 +34,8 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider, vscode private currentMessage = ''; /** 文件列表展示模式(List/Tree,标题栏图标切换):host 为事实源,随 state 整态下发。 */ private detailMode: 'flat' | 'tree' = 'flat'; + /** 勾选集镜像(webview 事实源单向同步):标题栏「…」批量 Discard 数据源,切仓库时置空待重推。 */ + private checkedPaths: readonly string[] = []; private readonly disposables: vscode.Disposable[] = []; /** List/Tree 偏好按仓库持久化 key(issue #107 同 log.dmode 范式;第三视图复用时提炼共享 helper)。 */ @@ -67,11 +71,12 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider, vscode this.disposables.length = 0; } - /** 装载当前仓库的 List/Tree 偏好(memento → 内存 + context key)。 */ + /** 装载当前仓库的 List/Tree 偏好(memento → 内存 + context key);勾选镜像置空待 webview 重推。 */ private loadRepoScopedPrefs(): void { const root = this.service.repoRoot; this.detailMode = (root ? this.workspaceState.get<'flat' | 'tree'>(CommitWebviewProvider.dmodeKey(root)) : undefined) ?? 'flat'; + this.checkedPaths = []; CommitWebviewProvider.setCtx('hyperGit.commit.tree', this.detailMode === 'tree'); } @@ -105,6 +110,18 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider, vscode this.pushState(); } + /** + * 标题栏「…」批量 Discard:范围 = 勾选集镜像(已由 webview 调和至当前活动 changelist 文件), + * 确认与执行统一复用 discardChanges(单/多同路径:modal 确认 + 未跟踪 clean / 已跟踪 restore)。 + */ + discardChecked(): void { + if (this.checkedPaths.length === 0) { + void vscode.window.showInformationMessage('No checked files to discard.'); + return; + } + void vscode.commands.executeCommand('hyperGit.discardChanges', this.checkedPaths); + } + private onMessage(msg: WebviewToHostMessage): void { switch (msg.type) { case 'requestState': @@ -127,6 +144,9 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider, vscode case 'commit/fileMenu': void this.handleFileMenu(msg.payload.path); break; + case 'commit/checkedChanged': + this.checkedPaths = msg.payload.paths; + break; } } @@ -204,8 +224,8 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider, vscode busy: false, repoRoot: this.service.repoRoot ?? '', }; - // 标题栏副标题 = 活动 changelist 名(原 webview 头部切换行上移,省一行竖直空间)。 - this.view.description = this.registry.getDef(activeId)?.name ?? 'Default'; + // 标题栏副标题 = 活动 changelist 名(原 webview 头部切换行上移;Default 省略——无信息量不常驻)。 + this.view.description = activeId === 'default' ? undefined : this.registry.getDef(activeId)?.name; this.post({ type: 'state', payload: state }); this.sendValidation(); } @@ -405,6 +425,11 @@ function reconcileChecked(files) { files.forEach(function (f) { present.add(f.path); if (!checked.has(f.path)) checked.add(f.path); }); Array.from(checked).forEach(function (p) { if (!present.has(p)) checked.delete(p); }); saveState(); + notifyChecked(); +} +// 勾选集单向同步 host(标题栏「…」批量 Discard 数据源);与 saveState(含 draft)解耦,仅勾选变更时发送。 +function notifyChecked() { + vscode.postMessage({ type: 'commit/checkedChanged', payload: { paths: Array.from(checked) } }); } function pruneCollapsed(tree) { @@ -428,7 +453,7 @@ function makeLeafRow(f, depth) { cb.checked = checked.has(f.path); cb.addEventListener('change', function () { if (cb.checked) checked.add(f.path); else checked.delete(f.path); - saveState(); syncSelectAll(); updateDirStates(); + saveState(); syncSelectAll(); updateDirStates(); notifyChecked(); }); // 状态字母标记(M/A/U/R/D/C…)替代色点:色盲可辨、对齐官方 SCM 角标(兜底空串)。 const dot = document.createElement('span'); @@ -483,7 +508,7 @@ function renderNode(node, depth, parent, files) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'dir-cb'; cb.dataset.dir = node.path; cb.addEventListener('click', function (e) { e.stopPropagation(); }); - cb.addEventListener('change', function () { setSubtreeChecked(node, cb.checked); saveState(); syncSelectAll(); }); + cb.addEventListener('change', function () { setSubtreeChecked(node, cb.checked); saveState(); syncSelectAll(); notifyChecked(); }); const nm = document.createElement('span'); nm.className = 'tree-name'; nm.textContent = node.name; @@ -586,7 +611,7 @@ selectAllEl.addEventListener('change', function () { const want = selectAllEl.checked; curFiles.forEach(function (f) { if (want) checked.add(f.path); else checked.delete(f.path); }); filesEl.querySelectorAll('.file-cb').forEach(function (cb) { cb.checked = want; }); - saveState(); updateDirStates(); + saveState(); updateDirStates(); notifyChecked(); }); function renderRecent(messages) { diff --git a/src/extension.ts b/src/extension.ts index cded925..f4cc4b3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -243,6 +243,9 @@ export async function activate( // commitView 的 onDidChangeRepository 订阅先于 refreshAll 注册,切库时 dmode 重载先于视图刷新)。 vscode.commands.registerCommand('hyperGit.commit.detailTree', () => commitView.setDetailMode('tree')), vscode.commands.registerCommand('hyperGit.commit.detailFlat', () => commitView.setDetailMode('flat')), + // 标题栏「…」批量 Discard:范围 = 勾选集镜像(webview 经 checkedChanged 单向同步), + // 确认与执行复用 discardChanges(单/多统一路径)。 + vscode.commands.registerCommand('hyperGit.commit.discardSelected', () => commitView.discardChecked()), vscode.commands.registerCommand('hyperGit.showConsole', () => showGitConsole()), vscode.commands.registerCommand('hyperGit.startRebase', () => RebaseWebview.open(service)), vscode.languages.registerCodeLensProvider({ scheme: 'file' }, inlineLens), @@ -301,6 +304,40 @@ export async function activate( // 首帧同步:即便后续无事件也确保角标初值正确。 updateBadge(); + // 视图标题栏图标默认仅在 hover/聚焦时显示(VS Code 平台行为:ViewPane 依 workbench.view.alwaysShowHeaderActions + // 切换 CSS 类 actions-always-visible,扩展贡献点无法覆盖,只能引导用户改设置——改动即时生效,无需重载)。 + // 双入口:① 命令 hyperGit.alwaysShowHeaderActions 随时手动开启;② 首次激活一次性提示(用户确认才写全局设置)。 + const HEADER_ACTIONS_HINT_KEY = 'hyperGit.hint.alwaysShowHeaderActions'; + const enableAlwaysShowHeaderActions = async (): Promise => { + const cfg = vscode.workspace.getConfiguration('workbench.view'); + if (cfg.get('alwaysShowHeaderActions')) { + void vscode.window.showInformationMessage('View toolbar icons are already always visible.'); + return; + } + await cfg.update('alwaysShowHeaderActions', true, vscode.ConfigurationTarget.Global); + await context.globalState.update(HEADER_ACTIONS_HINT_KEY, true); + void vscode.window.showInformationMessage('View toolbar icons are now always visible.'); + }; + context.subscriptions.push( + vscode.commands.registerCommand('hyperGit.alwaysShowHeaderActions', enableAlwaysShowHeaderActions), + ); + void (async (): Promise => { + const cfg = vscode.workspace.getConfiguration('workbench.view'); + if (context.globalState.get(HEADER_ACTIONS_HINT_KEY) || cfg.get('alwaysShowHeaderActions')) { + return; + } + const pick = await vscode.window.showInformationMessage( + 'Show Hyper Git view toolbar icons at all times? VS Code hides view header actions until hover by default.', + 'Always Show', + 'Don\'t Show Again', + ); + if (pick === 'Always Show') { + await enableAlwaysShowHeaderActions(); + } else if (pick === 'Don\'t Show Again') { + await context.globalState.update(HEADER_ACTIONS_HINT_KEY, true); + } + })(); + // 首帧保险:若 repo 在 activate 前已就绪,GitRepositoryService 构造函数的 _onDidChange.fire() // 早于任何订阅者挂载而被丢失,state.onDidChange 此后可能不再触发。主动刷新一次确保 // Branches/Log 不停留在首帧空状态(getChildren 内已对未就绪数据做 CLI 兜底与空安全处理)。 diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 5c90ec2..9a3502e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -78,7 +78,9 @@ export type WebviewToHostMessage = // ── 由旧 Changes 树平移而来的文件操作(webview 点击/右键 → host 复用既有命令); // changelist 切换与管理已上移标题栏(hyperGit.setActiveChangelist QuickPick)── // | { readonly type: 'commit/openFile'; readonly payload: { readonly path: string } } - | { readonly type: 'commit/fileMenu'; readonly payload: { readonly path: string } }; + | { readonly type: 'commit/fileMenu'; readonly payload: { readonly path: string } } + // 勾选集单向同步(webview 事实源 → host 镜像):Commit 标题栏「…」批量 Discard 的数据源。 + | { readonly type: 'commit/checkedChanged'; readonly payload: { readonly paths: readonly string[] } }; // ───────────────────────────────────────────────────────────────────────────── // Log Graph 视图(hyperGit.log,Webview)↔ Extension Host 消息契约。 diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index 0e36ff6..bd6d32a 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -22,6 +22,7 @@ suite('扩展冒烟测试', function () { 'hyperGit.setActiveChangelist', 'hyperGit.commit.detailTree', 'hyperGit.commit.detailFlat', + 'hyperGit.commit.discardSelected', 'hyperGit.renameChangelist', 'hyperGit.deleteChangelist', 'hyperGit.moveChangelist', @@ -45,9 +46,11 @@ suite('扩展冒烟测试', function () { 'hyperGit.stashApply', 'hyperGit.stashPop', 'hyperGit.stashDrop', + 'hyperGit.alwaysShowHeaderActions', 'hyperGit.discardChanges', 'hyperGit.pull', 'hyperGit.push', + 'hyperGit.pushBranch', 'hyperGit.fetch', 'hyperGit.cherryPick', 'hyperGit.revertCommit', diff --git a/tests/unit/branches-titlebar.test.ts b/tests/unit/branches-titlebar.test.ts new file mode 100644 index 0000000..c615963 --- /dev/null +++ b/tests/unit/branches-titlebar.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Branches 视图标题栏/右键菜单护栏:Prune 上移导航位(原 Push 图标位)、Push 下放分支右键、 + * Merge… 收入「…」——纯由 package.json 贡献点声明,拼写漂移即静默消失或错位,此处锁定基线, + * 风格对齐 commit-titlebar / views-layout。 + */ + +interface CommandDef { + command: string; + title?: string; + icon?: string; +} +interface MenuEntry { + command?: string; + when?: string; + group?: string; +} +interface PackageJson { + contributes: { + commands: CommandDef[]; + menus: { + 'view/title': MenuEntry[]; + 'view/item/context': MenuEntry[]; + commandPalette: MenuEntry[]; + }; + }; +} + +const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')) as PackageJson; +const commandById = new Map(pkg.contributes.commands.map((c) => [c.command, c])); +const titleEntries = pkg.contributes.menus['view/title']; +const itemEntries = pkg.contributes.menus['view/item/context']; +const paletteEntries = pkg.contributes.menus.commandPalette; + +describe('branches-titlebar(Branches 标题栏/右键菜单声明护栏)', () => { + it('pushBranch 命令定义齐备:title "Push" 与 $(arrow-up)(同全局 Push 图标)', () => { + expect(commandById.get('hyperGit.pushBranch')).toMatchObject({ + title: 'Push', + icon: '$(arrow-up)', + }); + }); + + it('Prune 图标为 $(sync-ignored),且不与本视图标题栏/右键的删除类 $(trash) 混淆', () => { + // 注意是过去式 sync-ignored——sync-ignore 不是有效 codicon,会静默渲染为空白方块 + // (id 真实性由 codicon-validity 护栏统一把关)。 + expect(commandById.get('hyperGit.pruneRemotes')?.icon).toBe('$(sync-ignored)'); + // 该图标全扩展唯一:避免与 Refresh/Update Project 等同栏同步类图标、以及删除类图标撞脸。 + const sameIcon = pkg.contributes.commands.filter((c) => c.icon === '$(sync-ignored)').map((c) => c.command); + expect(sameIcon).toEqual(['hyperGit.pruneRemotes']); + }); + + it('view/title:Prune 上移 navigation@4(原 Push 位),标题栏不再挂全局 Push,Merge… 收入 1_sync 组', () => { + const branchesEntries = titleEntries.filter((e) => e.when === 'view == hyperGit.branches'); + const byCommand = new Map(branchesEntries.map((e) => [e.command, e])); + expect(byCommand.get('hyperGit.pruneRemotes')).toEqual({ + command: 'hyperGit.pruneRemotes', + when: 'view == hyperGit.branches', + group: 'navigation@4', + }); + // 原 Push 图标下放分支右键:标题栏不再出现全局 Push 条目。 + expect(byCommand.get('hyperGit.push'), 'Branches 标题栏不应再挂全局 Push').toBeUndefined(); + // Merge… 由 navigation@5 收入「…」(1_sync 组,updateProject 与 New Tag… 之间)。 + expect(byCommand.get('hyperGit.mergeDialog')).toEqual({ + command: 'hyperGit.mergeDialog', + when: 'view == hyperGit.branches', + group: '1_sync@2.5', + }); + }); + + it('view/item/context:本地分支右键提供 Push(checkout 之后、Merge 之前),palette 不泄漏', () => { + expect( + itemEntries.find( + (e) => + e.command === 'hyperGit.pushBranch' && + e.when === 'view == hyperGit.branches && viewItem == hyperGit.branch && !listMultiSelection', + ), + '分支右键缺 pushBranch 条目', + ).toEqual({ + command: 'hyperGit.pushBranch', + when: 'view == hyperGit.branches && viewItem == hyperGit.branch && !listMultiSelection', + group: '1_branch@2', + }); + expect(paletteEntries.find((e) => e.command === 'hyperGit.pushBranch')).toEqual({ + command: 'hyperGit.pushBranch', + when: 'false', + }); + }); +}); diff --git a/tests/unit/codicon-validity.test.ts b/tests/unit/codicon-validity.test.ts new file mode 100644 index 0000000..7ec09f3 --- /dev/null +++ b/tests/unit/codicon-validity.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * 图标名有效性护栏:`icon` 写成不存在的 codicon id 时 VS Code **静默渲染为空白方块**—— + * 无告警、无报错,只有实机肉眼可见(曾漏出 `$(sync-ignore)`(正确为 `sync-ignored`) + * 与 `$(compare)`(正确为 `git-compare`)两例)。此处以官方 codicon 库 id 白名单锁定基线。 + * + * 白名单取自 microsoft/vscode-codicons 的 `src/template/mapping.json`(码位→别名,含全部别名), + * 仅收录本扩展在用的子集;新增图标时若此处缺失,先对照官方列表确认 id 真实存在再补入: + * https://code.visualstudio.com/api/references/icons-in-labels + */ +const VALID_CODICONS: ReadonlySet = new Set([ + 'add', + 'archive', + 'arrow-down', + 'arrow-right', + 'arrow-up', + 'check', + 'checklist', + 'clear-all', + 'clock', + 'cloud-upload', + 'diff', + 'diff-ignored', + 'discard', + 'edit', + 'eye', + 'filter', + 'folder-opened', + 'gear', + 'git-branch', + 'git-compare', + 'git-merge', + 'history', + 'inbox', + 'layers', + 'list-flat', + 'list-tree', + 'lock', + 'output', + 'refresh', + 'repo', + 'repo-fetch', + 'repo-pull', + 'repo-push', + 'sign-in', + 'star-full', + 'sync', + 'sync-ignored', + 'tag', + 'trash', +]); + +interface CommandDef { + command: string; + icon?: unknown; +} +interface PackageJson { + contributes: { commands: CommandDef[]; submenus?: { id: string; icon?: unknown }[] }; +} + +const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')) as PackageJson; +const ICON_RE = /^\$\(([a-z0-9-]+)\)$/; + +describe('codicon-validity(图标名有效性护栏)', () => { + it('所有命令图标均为 $(codicon) 形态且 id 真实存在(无效 id 会静默渲染空白)', () => { + const invalid = pkg.contributes.commands + .filter((c): c is CommandDef & { icon: string } => typeof c.icon === 'string') + .filter((c) => { + const id = ICON_RE.exec(c.icon)?.[1]; + return !id || !VALID_CODICONS.has(id); + }) + .map((c) => `${c.command} → ${c.icon}`); + expect(invalid, '无效或未登记的 codicon id').toEqual([]); + }); + + it('submenu 图标同样受校验(若声明了 icon)', () => { + const invalid = (pkg.contributes.submenus ?? []) + .filter((s): s is { id: string; icon: string } => typeof s.icon === 'string') + .filter((s) => { + const id = ICON_RE.exec(s.icon)?.[1]; + return !id || !VALID_CODICONS.has(id); + }) + .map((s) => `${s.id} → ${s.icon}`); + expect(invalid, '无效或未登记的 codicon id').toEqual([]); + }); +}); diff --git a/tests/unit/commit-titlebar.test.ts b/tests/unit/commit-titlebar.test.ts index 0d44d31..3fe93bc 100644 --- a/tests/unit/commit-titlebar.test.ts +++ b/tests/unit/commit-titlebar.test.ts @@ -32,7 +32,7 @@ const titleEntries = pkg.contributes.menus['view/title']; const paletteEntries = pkg.contributes.menus.commandPalette; describe('commit-titlebar(Commit 视图标题栏控件声明护栏)', () => { - it('三个命令定义齐备:changelist 选择器带 … 与 $(checklist),List/Tree 图标与 Graph 版一致', () => { + it('四个命令定义齐备:changelist 选择器带 … 与 $(checklist),List/Tree 图标与 Graph 版一致,批量 Discard 带 $(discard)', () => { expect(commandById.get('hyperGit.setActiveChangelist')).toMatchObject({ title: 'Set Active Changelist…', icon: '$(checklist)', @@ -45,9 +45,13 @@ describe('commit-titlebar(Commit 视图标题栏控件声明护栏)', () => title: 'Show Changed Files as Flat List', icon: '$(list-flat)', }); + expect(commandById.get('hyperGit.commit.discardSelected')).toMatchObject({ + title: 'Discard Changes', + icon: '$(discard)', + }); }); - it('view/title 挂载于 Refresh 左侧且 List/Tree 以 hyperGit.commit.tree 互斥', () => { + it('view/title:批量 Discard 居最左导航位,List/Tree 以 hyperGit.commit.tree 互斥,changelist 两入口入 … 菜单', () => { const pick = (command: string): MenuEntry => { const hit = titleEntries.find( (e) => e.command === command && (e.when ?? '').includes('hyperGit.commit'), @@ -55,8 +59,8 @@ describe('commit-titlebar(Commit 视图标题栏控件声明护栏)', () => expect(hit, `view/title 缺 ${command}`).toBeDefined(); return hit!; }; - expect(pick('hyperGit.setActiveChangelist')).toEqual({ - command: 'hyperGit.setActiveChangelist', + expect(pick('hyperGit.commit.discardSelected')).toEqual({ + command: 'hyperGit.commit.discardSelected', when: 'view == hyperGit.commit', group: 'navigation@0.5', }); @@ -70,6 +74,17 @@ describe('commit-titlebar(Commit 视图标题栏控件声明护栏)', () => when: 'view == hyperGit.commit && hyperGit.commit.tree', group: 'navigation@0.75', }); + // 非 navigation 组 → 落入标题栏「…」菜单:0_changelist(切换/新建)排在 1_sync 同步组之前。 + expect(pick('hyperGit.setActiveChangelist')).toEqual({ + command: 'hyperGit.setActiveChangelist', + when: 'view == hyperGit.commit', + group: '0_changelist@0', + }); + expect(pick('hyperGit.newChangelist')).toEqual({ + command: 'hyperGit.newChangelist', + when: 'view == hyperGit.commit', + group: '0_changelist@1', + }); }); it('commandPalette 三条均限定 Commit 视图聚焦(不全局泄漏)', () => { @@ -77,6 +92,7 @@ describe('commit-titlebar(Commit 视图标题栏控件声明护栏)', () => 'hyperGit.setActiveChangelist', 'hyperGit.commit.detailTree', 'hyperGit.commit.detailFlat', + 'hyperGit.commit.discardSelected', ]) { expect( paletteEntries.find((e) => e.command === command),