diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index d6623ce..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# AGENTS.md - -## Collaboration Protocol (协作协议) - -本文件旨在规范 AI Agent(Claude Code、Antigravity 等)在本项目中的代码与文档协作行为。项目定位详见 [README.md](./README.md)。 - -- **Core Language**: Output MUST be in **Chinese (Simplified)** unless serving code/technical constraints. -- **Tone**: Professional, precise, and evidence-based. - -## Engineering Code of Conduct (工程行为准则) - -**Core Philosophy**: **Entropy Reduction (熵减)**. 通过上下文锚定、复用驱动与标准化流水线,对抗软件系统的无序熵增。 - -### 道 (Mindset - 认知心法) - -- **Context-Driven (上下文驱动)**: 上下文是第一性要素 (Context Quality First)。任何变更需建立在深度理解之上(CDD),拒绝基于关键字匹配的机械式修改。 -- **Minimal Intervention (最小干预)**: 遵循奥卡姆剃刀与 YAGNI 原则,仅实施必要的变更,推崇演进式设计 (Evolutionary Design) 而非过度设计。 -- **Evidence-Based (循证工程)**: 杜绝主观臆断,核心决策需以**最新**且**权威**的文献(IEEE 格式)为佐证,构建“设计-实现-验证”的完整反馈闭环,确保每一项工程行动都能产生可观测的反馈信号(测试、日志、监控),以验证假设并指导迭代。 -- **Systemic Integrity (系统完整性)**: 具备全局视角与二阶思维 (Second-Order Thinking),评估变更对上下游依赖及整个生态(Engine, Adapter, Agent, UI)的“涟漪效应”,不只关注变更的直接结果,更要预测“结果的结果”(如引入缓存导致的陈旧数据、重试机制引发的雪崩),优先保障整体稳定性与逻辑自洽。 -- **Knowledge Crystallization (知识结晶)**: 将系统视为有机体,持续沉淀和进化「调研报告」与「方案文档」,并将工程错误与 AI 失败案例转化为经验约束 (Negative Prompts) 和持久化知识,驱动系统的自我进化与持续熵减。 -- **Proactive Navigation (主动导航)**: 智能体不应止步于被动响应,需即时转化为“领航者”。在交付任务结果的同时,**必须**基于上下文预判并提出**下一步最佳行动建议 (Next Best Action)**,不仅交付“答案”,更要交付“路径”,消除用户决策的认知摩擦。 - -### 法 (Strategy - 架构原则) - -- **Plan-First Default (规划先行)**: 面对任何非琐碎任务(预估步骤 > 3 或涉及架构级决策),**必须**率先进入 Plan 模式。规划产物需明确界定:功能边界、边缘 Case 应对策略、与现有逻辑的交互锚点以及预计改动的爆炸半径。 -- **Subagent Strategy (子代理并发策略)**: 面对高复杂度命题,严禁主 Agent 单点统揽。应贯彻“算力换空间”思路,果断编排 Subagent 进行任务拆解与并行攻坚,主 Agent 的职责需严格收敛于上下文协同与最终成果的组装整合。 -- **Verification Before Done (交付前验证定式)**: 严禁在缺乏确凿运行证据的情况下标记任务为“已完成”。交付阶段**强制要求**提供客观自证材料:Diff 变更分析、测试用例覆盖、实施日志截图及核心链路边缘 Case 验证结果,并时刻以“方案是否能通过 Staff Engineer 严格审查”的视角自检。 -- **Reuse-Driven (复用驱动)**: Compose over Reinvent。系统变更**必须**主动参考业界经典设计模式与最佳实践。在进入实质性编码前,需率先对相关领域的成熟范式进行深度调研,并结合当前项目上下文输出充分的关联分析与方案梳理。坚决贯彻“拿来主义”,优先通过组合与集成来构建系统,防范闭门造车与重复造轮子。 -- **Boundary Management (边界管理)**: 严控模块/Agent 间的职责边界与契约,确保高内聚低耦合,防范隐式依赖穿透。 -- **Orthogonal Decomposition (正交分解)**: 坚持“正交地提取概念主体”。识别系统中独立变化的维度并进行解耦(如机制与策略分离),确保单一概念主体的变更具备局部性,避免逻辑纠缠。 -- **Single Source of Truth (单一事实源)**:严格维护唯一的权威定义源。引用时**必须**使用轻量级指针 (Link/ID) 而非数据副本 (Copy-Paste),从根源消除断裂 (Split-Brain) 风险。 - -### 术 (Tactics - 执行规范) - -- **Structured AI-Pair Pipeline (规范化 AI 结对流水线)**: 遵循 **Specification-Driven (规约驱动)** + **Context-Anchored (上下文锚定)** + **AI-Pair (AI 结对)** 模式,将开发固化为可审计的流水线,避免代码腐化为无法维护的“大泥球 (Big Ball of Mud)”。 -- **Operational Excellence (卓越运营)**: - 1. **Git Discipline**: 默认严禁调用 git commit;当用户显式要求提交时,一律使用 Claude Code 的自定义 Slash Command: `/commit-no-push` 进行操作(若非 Claude Code 运行环境,则读取 /commit-no-push 命令中的规则执行)。严禁执行 Rebase; - 2. **Temp Management**: 临时产物(执行计划等)一律收敛至 `.temp/` 并及时清理; - 3. **Link Validity**: 确保所有引用的 URL 可访问且具备明确的上下文价值; - 4. **Testing**: 统一在 tests/ 下维护测试用例,区分单元测试(unit)和集成测试(integration),所有测试的本地运行总时间控制在 3 min 以内; - 5. **Pre-commit Hooks**: 首次克隆仓库使用 `uv run pre-commit install` 激活本地 Git hooks,使 Ruff lint(含 auto-fix)、Ruff format 及通用代码卫生检查在每次 commit 前自动运行。若 hooks 自动修复了问题,提交会被中断,执行 `git add -p` 审阅修复内容后重新提交即可; - 6. **Issue**: 在 [issue.md](docs/.agents/issue.md) 中维护你处理过的 Issue 摘要(问题描述、表因根因、处理方式、后续防范、同类问题影响与处理注意事项等),便于同类问题的跨上下文处理;注意识别相同 Issue,不要同 Issue 多处维护; -- **Package Management Standardization (包管理规范)**: - 1. **Python**: 严禁使用 pip/poetry,**必须**统一使用 `uv` 进行包管理与脚本执行(如 `uv run`); - 2. **JavaScript/TypeScript**: 严禁使用 npm/yarn,**必须**统一使用 `pnpm` 进行包管理与脚本执行; -- **Database Management**: 谨慎操作,数据迁移、测试等操作严禁将现有数据删除,谨慎操作数据迁移的回滚,防止数据被清理。 -- **Browser Validation Protocol (浏览器验证准则)**:Agent 不得自行完成、绕过或模拟任何 OAuth / SSO 认证流程,所有登录态均来源于用户已认证的 Chrome 主 profile(真实用户登录态)。完整协议(连通性自检、凭证管理、E2E 集成、实机回归等)详见 [浏览器验证协议](./docs/.agents/browser-validation.md); - 1. **安全红线**:禁止在 Sandbox 浏览器中跳转 Google 同意屏;禁止以模拟用户或第三方账号替代真实登录态;禁止要求用户在 chat 中粘贴密码、Cookie 或验证码; -- **Knowledge Map (知识索引)**:项目所有文档索引统一维护在 [知识索引](./docs/.agents/knowledge-map.md),并在文档目录变更时即时同步跟新; -- **Documentation Standards (文档规范)**: - 1. **Visual Documentation (图文并茂)**: 对于复杂逻辑,优先 **Mermaid Visualization Norms (Mermaid 可视化规范)**,构建“图文并茂”的直观文档; - - **色彩语义与兼容性**:为图表节点配置具备语义辨识度的色彩,并确保在深色模式(Dark Mode)下具有极高的对比度与清晰度; - - **逻辑模块化解构**:针对业务跨度较大的架构流程,强制采用 `subgraph` 容器进行层级解构与边界划分,以增强图表的自解说(Self-explaining)能力; - 2. **语言叙事**:用语精准,叙事完备,行文专业,聚焦核心,篇幅精炼,形象具体,体现真实作用与用户吸引性,字数恰当; - 3. **Direct Hyperlinking (直接跳转)**: 在文档中提及 Repo 内其他资源(文档/代码)时,**必须**构建可跳转的相对路径链接(如 `[Doc Name](./path.md)`),严禁使用“死文本”引用,以降低信息检索熵; - 4. **实操截图**:文档需要引入必要的浏览器实操截图时,需自行通过默认浏览器打开相关页面,通过实操现场截图并保留到文档路径进行文档引用; -- **Reference Specifications (IEEE)**:为保障工程决策的可追溯性与学术严谨性,核心引用需遵循 [reference-specifications.md](docs/.agents/reference-specifications.md)IEEE 标准引用格式; diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f3b8d..edb208a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,53 @@ ## [Unreleased] -## [0.0.16] - 2026-09-01 — 多根工作区仓库切换 · 分支分组树 · Agentic Git 预置 · 面板布局与 Diff 修复 +## [0.0.17] - 2026-09-09 — Log 提交详情常驻面板 · VS Code 1.136 最佳实践对齐 · 稳定性与体验修复 + +自 v0.0.16 以来的积累(PR #115 / #116)。核心变更:**Log 提交详情由悬停浮层改为右侧常驻面板**(三区可拖拽分割线、窄视图自适应堆叠),并全面对齐 VS Code 1.136 控件 / 交互 / API 最佳实践(修复 9 项缺陷、Webview 控件全面主题化、能力声明与设置治理);新增长时 git 操作进度反馈、入门导览与默认键位、Stash / Worktrees 批量操作、分支 / 标签名即时校验等体验增强。完整用户视角叙述见 [Release Note v0.0.17](./docs/releases/v0.0.17.md)。 + +### Added + +- **长时 git 操作进度反馈**:pull/push/fetch/merge/rebase/cherry-pick/reset/updateProject 等以 `withProgress` 呈现(默认标题栏非阻塞,Update Project 与交互 Rebase 用通知位);git 操作失败通知附 "Show Output" 按钮直达 Console。 +- **入门导览与默认键位**:新增 `walkthroughs` 贡献点(Get Started with Hyper Git,5 步导览);默认键位 Alt+B 切换 Blame 注解、Alt+G 打开 Graph 过滤(Commit 输入框 Ctrl/Cmd+Enter 提交原有内建保留)。 +- **Stash / Worktrees 批量操作**:两视图启用多选(批量 Drop 按 index 降序防位移、批量 Remove 跳过 main/当前打开项);新建 stash/worktree/shelf 后聚焦对应视图;provider 加载失败经 `TreeView.message` 内联呈现。 +- **分支/标签名即时校验**:新增 `engine/ref/ref-name.validateRefName` 纯函数(git check-ref-format 的 UI 子集,9 组单测矩阵),挂接全部 8 处分支/标签名输入框。 +- **QuickPick 体验**:提交选择器补 `matchOnDescription/matchOnDetail`(hash 前缀可搜);Rebase base 与标签目标选择插 `QuickPickItemKind.Separator` 分段;Commit 文件菜单补 `$(codicon)` 图标。 +- **Commit 文件列表键盘导航**:容器级焦点 + ArrowUp/Down/Home/End/Enter/Escape(复刻 Graph 模式);Rebase 拖拽补 Alt+↑/↓ 键盘替代,确认层补 Escape/焦点圈禁/焦点还原。 + +### Changed + +- **Webview 控件全面主题化**:滚动条走 `--vscode-scrollbarSlider-*`、checkbox `accent-color`、下拉统一 `--vscode-dropdown-*`(`.hg-select` 基类)、阴影 `--vscode-widget-shadow`;字号全线以 `--vscode-font-size` 为基准 calc 派生(随用户字号设置缩放);高对比度主题下 Graph 彩色胶囊改描边呈现;Graph 行高/泳道列宽常量单源化。 +- **文件状态点改字母标记**:Commit 视图 `●` 色点改为 M/A/U/R/D/C 着色字母(色盲可辨,对齐官方 SCM 与 Graph 面板);8 处 Unicode 字符图标全部 SVG 化(折叠箭头/省略号/关闭/导航/空态/merge 标记)。 +- **日志通道现代化**:两 OutputChannel 迁 `LogOutputChannel`(自动时间戳与级别、统一日志视图),懒创建并随扩展释放。 +- **确认对话框多行正文迁 `MessageOptions.detail`**(本地/远程分支删除、Worktree 删除/prune);用户可见文案统一英文(分支删除确认、Conventional Commits 校验提示)。 +- **能力声明与设置治理**:补 `extensionKind: workspace` 与 `untrustedWorkspaces: unsupported`;11 项设置补 `scope`;5 个预留设置(`ai.enabled` + `agent.*`)描述标注 Reserved 且默认值清除个人化内容(**默认值变更**:agent 偏好默认由内置模板改为空,即内置默认——此前内嵌个人签名等内容不再分发);palette 补 when 限定(Graph scope/List-Tree 切换、Accept Ours/Theirs 按冲突态显隐)。 +- **同步 IO 清理**:Shelf 服务全量迁 `fs.promises`(原 `listShelves` 同步读阻塞 UI 线程);Worktree 路径归一 realpath 记忆化。 +- **Graph 视图工具栏整体上移 VS Code 标题栏(省一整行竖直空间)**:scope(All / Current / Checkpoints)改为 `$(layers)` 图标下拉子菜单(`toggled` 勾选、默认 All,置于 Refresh Graph 图标左侧);仓库路径常驻 `WebviewView.description` 副标题(标题「Graph」同行右侧);多仓库态「切换仓库」`$(repo)` 图标按钮与 CI「登录 GitHub」`$(sign-in)` 图标按钮(授权后自动隐藏)条件显隐;Changed Files 的 List ⇄ Tree 切换改为 `$(list-tree)`/`$(list-flat)` 互斥图标(交互同 Branches 分组切换,文案适配为 "Group Changed Files by Directory" / "Show Changed Files as Flat List")。scope 与 List/Tree 偏好随之移交 host `workspaceState` 按仓库持久化(`hyperGit.log.scope:` / `hyperGit.log.dmode:`)——**原 webview state 中的旧偏好一次性重置为默认**(All / flat;选中与目录折叠不受影响)。 +- **提交详情三区分割线可拖拽**:图 ⇄ 面板(横向面板 ≥200px 且图区 ≥280px、纵向 18%–75%)与面板内 Changed Files ⇄ Commit 信息(15%–85%)两根 gutter,实时调比例、按仓库记忆、键盘可达(方向键 ±2%、Home/End 归边界)。 +- **再次点击已选中的提交行 = 反向操作**:收起该提交的 Changed Files 与 Commit 详情面板(300ms 内双击第二击豁免,保留「双击即打开」语义;方向键/Home/End 边界防抖不误触)。 +- **Log 提交详情由悬停浮层改为右侧常驻面板**:点击 Graph 提交行即在图右侧于 webview 内水平分栏打开详情面板(上半 **Changed Files**——List/Tree 切换、点击打开 Diff;下半**提交信息**——作者/时间、HEAD/分支/远程/标签引用分组、完整消息、提交者(异于作者时)、变更统计、完整 SHA、Open on GitHub),深浅主题自适应。面板可见性 ⟺ 选中态:`×` 或 `Esc` 取消选中即收起,刷新 / webview 重载按仓库记忆恢复选中与面板;`log/selectCommit` 一次并行触达文件 + 详情两路数据(沿用切库竞态守卫,迟到 / 失败回包按 `forHash` 丢弃或显式「Details unavailable」占位,见 [`src/shared/protocol.ts`](./src/shared/protocol.ts))。原 `#commit-tip` 悬停浮层与 `i` 快捷键整体移除,行悬停不再弹层;底部 Changed Files 区随之并入面板上半区。窄视图(`#main` < 560px)自动退化为上下堆叠(面板 45%、图区保 55%),避免面板 280px 下限把提交图挤成零宽;面板收起守卫同时校验选中态与可见性,切到无记忆选中的仓库不残留上一仓库的面板内容。完整叙述见 [Log 提交详情面板](./docs/features/log-commit-detail-panel.md)。 + +### Fixed + +- **Merge 编辑器「保存→拒绝强制保存→取消」路径失效**:webview 回调内重复调用 `acquireVsCodeApi()`(每个 webview 仅允许调用一次,第二次抛异常),改为脚本顶部获取后全程复用;交互式 Rebase 确认执行同理([issue #17](./docs/.agents/issue.md))。 +- **Commit 草稿丢失**:视图隐藏销毁后 message/amend/signoff/skipHooks 全部丢失且模板重灌覆盖。webview state 升 v3 按仓库持久化草稿,输入即时落盘(消除 200ms 防抖尾丢),提交成功清空([issue #18](./docs/.agents/issue.md))。 +- **切换深浅主题后 Graph 泳道色/引用胶囊停留旧主题**:泳道色原为启动期一次性快照,补 `MutationObserver` 监听 body 属性热重算并重渲(webview 不随主题切换重载)。 +- **分支树文件夹节点出现分支级菜单**:11 处 `viewItem =~` 正则未锚定误匹配 `hyperGit.branchFolder`(含 `hyperGit.branch` 子串),全部补 `^…$` 锚定。 +- **CLI 通道失败不可诊断**:`execFile` 丢弃 stderr,现透传挂到错误对象并记入 Console(与 vscode.git API 通道的 GitError.stderr 提取对齐)。 +- **交互 Rebase 的 reword 可能拉起新 VS Code 窗口**:`GIT_EDITOR` 经 `process.execPath`(扩展宿主内为 Electron 二进制)运行 helper,补 `ELECTRON_RUN_AS_NODE=1`。 +- **窗口 reload 后 Rebase / Merge 面板空白**:两个 `retainContextWhenHidden` 面板补 `registerWebviewPanelSerializer`(Rebase 按 webview state 恢复用户编辑后的 todo,Merge 按 filePath 重拉冲突三阶段)。 +- **git 扩展不可用时 Branches/Stash/Shelf 视图报 "no tree view registered"**:补空 provider(viewsWelcome 正常触发),Commit/Graph 渲染静态占位说明页。 +- **Blame 注解**:悬浮信息换行折叠(改 MarkdownString);split 编辑器无注解、已关闭文档条目泄漏(订阅可见编辑器变化统一挂载/清理)。 + +### Removed + +- **Graph 悬停浮层专用消息与死类型**:移除 `log/showCommitDetail`(webview → host,随浮层消亡)与编辑器区 Commit 详情面板实验遗留、全仓零引用的 `CommitDetailHostToWebviewMessage` / `CommitDetailWebviewToHostMessage` 协议类型([`src/shared/protocol.ts`](./src/shared/protocol.ts))。 + +### Docs + +- 新增 [Log 提交详情面板](./docs/features/log-commit-detail-panel.md)(布局分栏 / 交互数据流 Mermaid、边缘 Case、`forHash` 守卫语义);移除被替代的 `docs/features/log-commit-tooltip.md`,同步更新 [知识索引](./docs/.agents/knowledge-map.md)、[文档中心](./docs/README.md) 与 [变更文件目录树](./docs/features/file-list-group-by-directory.md) 表述。 + + - 2026-09-01 — 多根工作区仓库切换 · 分支分组树 · Agentic Git 预置 · 面板布局与 Diff 修复 自 v0.0.14 以来的全量积累(承载 0.0.15 版本号预置——该版本号已就位但未单独发布,本版一并发布)。核心新特性:多根工作区多仓库切换、Branches 按 `/` 前缀分组树、Agentic Git 偏好配置与 Claude Code 配置预置(M5 前置铺垫)、Diff 缺失端修复与面板默认布局调整;工程侧升级 pnpm 12 与一批开发依赖。完整用户视角叙述见 [Release Note v0.0.16](./docs/releases/v0.0.16.md)。 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index c0ba737..d1e3284 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -148,3 +148,19 @@ - **处理方式**:落地全局活跃仓库切换(Git Graph 模式)——① 选取逻辑下沉纯函数 `engine/git-state/repo-selection.ts`(三级优先:持久化恢复 → folder0 匹配 → 首个仓库,路径归一化跨平台稳定),`GitRepositoryService` 新增 `selectRepository`/`listRepositories`/`onDidChangeRepository`,活跃仓库持久化 `hyperGit.activeRepoRoot`;② **三重顺序不变量**保证 rebind 先于刷新:`applyRepository` 内先 fire 切换事件(同步 rebind:registry/favorites/branchesTree.setRepoRoot + context key 同步 + blame 清理 + filter 清空)后 fire onDidChange(防抖刷新),且 rebind 订阅先于 `refreshAll` 注册;③ Shelf 目录随 `service.repoRoot` 动态求值(`shelves/./`)+ 旧平铺数据一次性安全迁移(仅 rename、目标存在即跳过、失败可重试);④ Graph 工具栏仓库名升级可点击按钮(单仓库退化纯文本)+ `hyperGit.selectRepository` palette 命令(带参=程序化切换接缝,`activate()` 导出 `{ service }` 供集成测试);⑤ webview 视图状态(scope/选中/勾选集等)v2 `byRepo` 分区 + 最近提交消息 per-repo key(旧 key 回落一次平滑迁移)。集成测试新增 multi-root 双仓库 fixture 套件(`tests/suite/multi-root.test.js`)。 - **后续防范**:① **新增组件若构造期快照 repoRoot(拼 memento key / 存储目录),必须订阅 `service.onDidChangeRepository` 重绑**——可用 `grep -rn 'repoRoot' src/adapter/ | grep -v 'service.repoRoot'` 扫描快照点;② 集成测试需 UI 交互时,优先给命令 handler 加可选参数(无参=QuickPick,带参=程序化)+ `activate()` 导出接缝,勿依赖无法自动化的原生弹窗;③ 顺带修复了既有激活竞态(repo 发现晚于 activate 时 memento key 落 workspaceRoot 占位),rebind 事件会纠正——但根治应在「窗口内无写入路径」前提下依赖该纠正,勿在激活早期引入持久化写入。 - **同类问题影响**:所有围绕「单一活跃仓库」装配的 VS Code Git 扩展;凡按 repoRoot 构造 workspaceState/globalStorage 键或存储目录、却不在仓库集合变化时重绑的实现;以及 macOS 上 VS Code ≥ 1.110 主二进制 `Electron`→`Code` 更名导致 `@vscode/test-electron` 旧版 ENOENT 的集成测试环境(升 3.1.0 解决,见 PR #102)。 + +## #17 Webview 内联脚本回调中重复调用 acquireVsCodeApi() + +- **表因**:Merge 编辑器点击 Save → 拒绝「强制保存」确认 → 点击 Cancel 时按钮无响应;交互 Rebase 冲突失败后重试同理。控制台报 `An instance of the VS Code API has already been acquired`。 +- **根因**:`acquireVsCodeApi()` 每个 webview 全局仅允许成功调用一次(VS Code API 设计约束),第二次调用抛异常。merge-editor 与 rebase-webview 在事件回调内现场调用(`document.getElementById('save').onclick = function(){ acquireVsCodeApi().postMessage(...) }`),首次执行某回调成功后,任何其他回调再调用即崩溃。 +- **处理方式**:脚本顶部 `const vscode = acquireVsCodeApi();` 获取一次,全部回调复用(commit/log webview 本就是此写法)。落地见 `fix(Webview)` 批次。 +- **后续防范**:webview 内联脚本一律顶部单次获取;review 时 `grep -n "acquireVsCodeApi" src/adapter/webview/` 应只命中每文件脚本顶部一处调用。 +- **同类问题影响**:所有自绘 webview 的扩展;回调/异步分支中按需获取 API 的惰性写法均存在此雷。 + +## #18 Commit 视图草稿随视图销毁丢失(state 未覆盖 + 模板重灌覆盖) + +- **表因**:Commit 视图切走再切回(或折叠 Panel 展开),已输入的提交信息与 amend/signoff/skipHooks 勾选全部丢失,模板重新灌入。 +- **根因**:复合缺陷——① WebviewView 未设 `retainContextWhenHidden`,隐藏即销毁;② `vscode.getState()` 只持久化 checked/mode/collapsed,不含草稿;③ message 输入经 200ms debounce 才 postMessage 到 host,销毁前最后一段输入连 host 侧 `currentMessage` 都未到达;④ 重建时模板注入逻辑因 textarea 为空而重新灌模板。内置 SCM 输入框草稿跨销毁可恢复,对比形成 UX 回归。 +- **处理方式**:webview state v3 在 `byRepo` 分区新增 `draft`(message + 三勾选);输入/勾选变更即时 `saveState()` 落盘(消除 debounce 尾丢);回灌仅限「重建首帧/切换仓库」两时机且先于 `reconcileChecked`(避免 state 推送往返覆盖当前编辑);提交成功清空草稿。未采用 `retainContextWhenHidden`(钉死整个 webview 内存,且不跨窗口 reload)。 +- **后续防范**:webview 内用户编辑态一律显式入 `setState`(含 debounce 之外的即时落盘点);回灌逻辑必须有时机守卫(一次性/切换触发),并注意「先回灌 DOM、后执行任何从 DOM 取数回写 state」的顺序。 +- **同类问题影响**:所有自绘 WebviewView 承载编辑器的扩展;凡输入经防抖上报宿主的模式都存在尾丢窗口。 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 4763135..ff492da 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -21,15 +21,15 @@ - [Commit 视图整合](../docs/features/commit-view-consolidation.md) — 移除 CHANGES 视图,其文件操作/changelist 管理/Git 工具栏/未提交角标零回归平移入 Commit 视图。 - [变更文件目录树](../docs/features/file-list-group-by-directory.md) — Commit / Log 文件列表「平铺 ⇄ 按目录分组」切换:host 侧 `buildFileTree` 构树下发、compact 折叠、目录三态。 - [分支前缀分组树](../docs/features/branch-tree-group-by-prefix.md) — Branches 视图 Local/Remote/Tags「平铺 ⇄ 按 `/` 前缀分组」切换:`buildRefTree` 构树、分支感知排序、compact 折叠、隐藏 `origin/HEAD`、按仓库持久化。 -- [Log 提交悬浮详情](../docs/features/log-commit-tooltip.md) — 悬停提交行浮层展示 分支/标签/HEAD/完整消息/作者·提交者/时间/SHA;复用 CI 浮层范式、与其互斥。 +- [Log 提交详情面板](../docs/features/log-commit-detail-panel.md) — 点击提交行于图右侧 webview 内分栏打开常驻详情面板(上半 Changed Files、下半提交信息:引用分组/完整消息/统计/SHA/GitHub);面板可见性 ⟺ 选中态,再次点击取反;三区两根 gutter 可拖拽调比例,替代原悬停浮层。 - [Claude Code 配置](../docs/features/claude-code-config.md) — Agentic Git 预置:`hyperGit.claudeCode.executablePath` 设置 + `~/.claude/settings.json` 快捷入口(原生设置 + 命令;纯路径逻辑沉淀 `engine/agent/`)。 - [Agentic Git 偏好配置](../docs/features/agentic-git-preferences.md) — Agentic Git 预置:`hyperGit.agent.*` 四项(baseBranch + commit/createPr/review Preferences;多行文本域 + 内置默认模板,Commit 模板中文规约已英译)。 -- [多根工作区仓库切换](../docs/features/multi-root-repo-selection.md) — Graph 工具栏仓库名按钮 + `hyperGit.selectRepository` 切换全局活跃仓库:七视图级联跟随、changelist/收藏/Shelf/最近消息按仓库隔离、三重顺序不变量与安全迁移。 +- [多根工作区仓库切换](../docs/features/multi-root-repo-selection.md) — Graph 标题栏切换仓库图标按钮 + `hyperGit.selectRepository` 切换全局活跃仓库:七视图级联跟随、changelist/收藏/Shelf/最近消息按仓库隔离、三重顺序不变量与安全迁移。 - [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 - [Git 功能矩阵](../docs/requirements/idea-feature-matrix.md) — 56 功能点 / 8 组(**验收基线**,参考 IDEA 等成熟实现)。 - [调研报告](../docs/research/README.md) — SCM 集成 / 工程蓝图 / 发布 CI / AI 接缝四路循证报告。 -- [发布说明](../releases/README.md) — 各正式版 Release Notes(GitHub Release 正文单一事实源;最新 [v0.0.16](../releases/v0.0.16.md))。 +- [发布说明](../releases/README.md) — 各正式版 Release Notes(GitHub Release 正文单一事实源;最新 [v0.0.17](../releases/v0.0.17.md))。 ## 架构分层(src/) > 依赖方向单向:`UI → Adapter → Engine`;`Agent` 以接口注入 `Engine`/`CommitPipeline`,不反向依赖 UI。 diff --git a/docs/README.md b/docs/README.md index d3cf858..e43d718 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,11 +9,12 @@ ## 功能文档 - [Log 视图 CI 状态](./features/log-ci-status.md) — 按提交显示 GitHub CI 最终状态(绿勾/红叉 + 悬停 Tooltip 明细):认证、限流、懒加载、边界与配置。 +- [Log 提交详情面板](./features/log-commit-detail-panel.md) — 点击提交行于图右侧打开常驻详情面板(上半 Changed Files、下半提交信息);面板可见性 ⟺ 选中态,替代原悬停浮层。 - [Claude Code 配置](./features/claude-code-config.md) — Agentic Git 预置:Claude Code 可执行路径设置 + `~/.claude/settings.json` 快捷入口(原生设置 + 命令;M5 前置铺垫)。 - [Agentic Git 偏好配置](./features/agentic-git-preferences.md) — Agentic Git 预置:Base Branch + Commit / Create PR / Review 三项 agent 指令偏好(多行文本域 + 内置默认模板;M5 前置铺垫)。 ## 发布说明 -- [Release Notes 目录](./releases/README.md) — 各正式版发布说明(GitHub Release 正文单一事实源);最新:[v0.0.16 多根工作区仓库切换 · 分支分组树 · Agentic Git 预置](./releases/v0.0.16.md)。 +- [Release Notes 目录](./releases/README.md) — 各正式版发布说明(GitHub Release 正文单一事实源);最新:[v0.0.17 Log 提交详情常驻面板 · VS Code 1.136 最佳实践对齐](./releases/v0.0.17.md)。 ## 调研报告(循证依据) - [02 · VS Code SCM API 与 vscode.git 集成路径](./research/02-vscode-scm-integration.md) — 路径 B 决策依据、SCM 稳定/proposed API 边界、changelist 模型映射。 diff --git a/docs/features/file-list-group-by-directory.md b/docs/features/file-list-group-by-directory.md index 6aefeaa..1f3793b 100644 --- a/docs/features/file-list-group-by-directory.md +++ b/docs/features/file-list-group-by-directory.md @@ -23,19 +23,20 @@ flowchart LR ## 两视图差异 -| | Commit 视图 | Log 视图(详情面板) | +| | Commit 视图 | Log 视图(右侧详情面板上半区) | |---|---|---| | 数据源 | 活动 Changelist 的 `CommitFileItem[]` | 选中提交 `diff-tree` 的 `LogCommitFileItem[]` | | 建树路径 | 条目 `path`(仓库相对) | `CommitFileChange.path`(干净新路径,重命名归位到新目录) | | 复选框 | 有:叶子勾选 + **目录三态**级联;Select All 基于全量文件 | 无(只读浏览) | | 叶子单击 | 打开 Diff(`commit/openFile`) | 打开单文件 Diff(`log/openFile`,`data-path` 保持展示串不变) | -| 偏好持久化 | `setState({mode, collapsed, checked})` | `setState({dmode, dcollapsed, ...})` | +| 偏好持久化 | `setState({mode, collapsed, checked})` | host `workspaceState`(`hyperGit.log.dmode:`)+ webview `dcollapsed` 按仓库记忆 | +| 切换控件 | webview 内 List/Tree 段控 | Graph 标题栏 `$(list-tree)`/`$(list-flat)` 互斥图标(同 Branches 分组切换范式) | ## 实现 - 引擎:[`engine/tree/file-tree.ts`](../../src/engine/tree/file-tree.ts)(+ [`tests/unit/file-tree.test.ts`](../../tests/unit/file-tree.test.ts),15 用例)。 - 协议:[`shared/protocol.ts`](../../src/shared/protocol.ts)(`FileTreeNode`;`CommitViewState.tree`;`log/commitFiles` payload `tree`)。 -- 渲染:[`commit-webview.ts`](../../src/adapter/webview/commit-webview.ts)(List/Tree 段控、`renderFlat`/`renderTree`、`makeLeafRow`、目录三态 `updateDirStates`)、[`log-webview.ts`](../../src/adapter/webview/log-webview.ts)(`renderDetails` 分派、`renderDetailNode`)。 +- 渲染:[`commit-webview.ts`](../../src/adapter/webview/commit-webview.ts)(List/Tree 段控、`renderFlat`/`renderTree`、`makeLeafRow`、目录三态 `updateDirStates`)、[`log-webview.ts`](../../src/adapter/webview/log-webview.ts)(`renderDetails` 分派、`renderDetailNode`;Log 侧切换控件为 Graph 标题栏 `hyperGit.log.detailTree/detailFlat` 命令图标,模式经 `log/detailMode` 定向下发免整图重拉)。 ## 验证 diff --git a/docs/features/log-ci-status.md b/docs/features/log-ci-status.md index f976421..4675912 100644 --- a/docs/features/log-ci-status.md +++ b/docs/features/log-ci-status.md @@ -34,8 +34,8 @@ flowchart LR - 复用 **VS Code 内置 GitHub 认证**(`vscode.authentication`),凭证由编辑器托管,**绝不经过 chat / 日志 / webview**。 - 范围 `repo`:覆盖私有仓库的 Checks(Actions)+ Commit Status 读取(`repo:status` 不覆盖 Checks API)。 -- **静默优先**:加载时 `getSession({createIfNone:false})` 仅复用已有会话,**绝不自动弹窗**;仅当用户点击工具栏「登录 GitHub」 - 按钮时才以 `{createIfNone:true}` 触发原生授权 UI。未登录 → 显示登录提示、不渲染图标、不发请求。 +- **静默优先**:加载时 `getSession({createIfNone:false})` 仅复用已有会话,**绝不自动弹窗**;仅当用户点击 Graph 标题栏「登录 GitHub」 + 图标按钮(`$(sign-in)`,`when: hyperGit.log.ciNeedsSignIn`,授权完成自动隐藏)时才以 `{createIfNone:true}` 触发原生授权 UI。未登录 → 仅显该按钮、不渲染图标、不发请求。 - **反 SSRF**:Tooltip 的跳转链接(`detailsUrl` / `targetUrl`,属「观察内容」)由 host 校验 `https` 且主机 ∈ {仓库主机、`*.github.com`} 后才 `openExternal`。 ## 限流与性能 diff --git a/docs/features/log-commit-detail-panel.md b/docs/features/log-commit-detail-panel.md new file mode 100644 index 0000000..4155735 --- /dev/null +++ b/docs/features/log-commit-detail-panel.md @@ -0,0 +1,120 @@ +# Log 提交详情面板(Commit Detail Panel) + +> 点击 Log 视图任一提交行,在**图右侧**于 webview 内水平分栏打开常驻详情面板:上半区 **Changed Files**(List ⇄ Tree 切换、点击打开 Diff),下半区**提交信息**——作者(`name `)+ 时间(绝对 + 相对)、**HEAD / 本地分支 / 远程分支 / 标签**引用分组、完整消息(subject + body)、(与作者不同时的)提交者、变更统计、完整 SHA、Open on GitHub。**面板可见性 ⟺ 选中态**:`×` 或 `Esc` 取消选中即收起,**再次点击已选中的提交行同样取反收起**(300ms 内的双击第二击豁免)。**三区两根 gutter 可拖拽**:图 ⇄ 面板、上半区 ⇄ 下半区的宽高比例随拖实时调整、按仓库记忆。原悬停浮层(`#commit-tip`)与 `i` 快捷键整体移除;原 webview 工具栏(scope 段控 / 仓库名 / CI 登录)整体上移 VS Code 标题栏,仓库路径改由 `WebviewView.description` 呈现,省一整行竖直空间。 + +## 布局与定位 + +VS Code 的 panel 视图容器(`hyper-git`)内多视图只能**垂直堆叠**,无法原生并排两个子视图;编辑器区 `WebviewPanel` 方案亦曾实验后回退。故「右侧平级视窗」落地为 **Graph webview 内部左右分栏**(对齐 Git Graph 扩展的 Details 列形态)。视图级控件全部上移 VS Code 原生标题栏(`view/title` 贡献点): + +| 控件 | 形态 | 事实源 / 显隐 | +|---|---|---| +| Scope(All / Current / Checkpoints) | `$(layers)` 图标下拉子菜单,`toggled` 勾选当前项,默认 All | host `workspaceState`(`hyperGit.log.scope:`)+ `hyperGit.log.scope` context key | +| Changed Files List ⇄ Tree | `$(list-tree)` / `$(list-flat)` 互斥图标(同 Branches 分组切换范式) | host `workspaceState`(`hyperGit.log.dmode:`)+ `hyperGit.log.tree` | +| 切换仓库(多仓库态) | `$(repo)` 图标按钮 | `hyperGit.log.multiRepo` | +| 登录 GitHub(CI) | `$(sign-in)` 图标按钮,授权完成自动隐藏 | `hyperGit.log.ciNeedsSignIn` | +| 仓库路径 | `WebviewView.description` 副标题(标题「Graph」右侧常驻) | `pushState` 时随 `repoRoot` 更新 | + +```mermaid +flowchart TB + subgraph shell["VS Code 标题栏(view/title)"] + H["Graph + description(仓库路径)
… [layers Scope▾] [list-tree/flat] [refresh] [sign-in*] [repo*] …"] + end + subgraph main["#main(≥560px 横向 flex / <560px .stacked 纵向)"] + VP["#viewport
提交图(flex:1,虚拟滚动)"] + G1["#gutter-main
拖拽 ⇄ 面板宽/高(18%–75%,
横向面板≥200px 且图≥280px)"] + subgraph panel["#commit-panel(flex-basis 拖拽钳制)"] + D["#details(独立滚动)
Changed Files + ×"] + G2["#gutter-meta
拖拽 ⇄ 上/下半区高(15%–85%)"] + M["#commit-meta(独立滚动)
作者/引用分组/消息/统计/SHA"] + end + end + H --- main + VP --- G1 --- panel + D --- G2 --- M + style H fill:#6e7681,color:#fff + style VP fill:#1f6feb,color:#fff + style G1 fill:#8250df,color:#fff + style G2 fill:#8250df,color:#fff + style panel fill:#21262d,stroke:#d29922,color:#fff + style D fill:#d29922,color:#fff + style M fill:#238636,color:#fff +``` + +gutter 交互:`pointerdown` 捕获指针 → `pointermove` 实时更新 flex-basis → `pointerup` 持久化(`panelPct`/`detailPct` 随选中/目录折叠一起按仓库入 webview state);`role="separator"` + `tabindex` 键盘可达(方向键 ±2%、Home/End 归边界),`.dragging`/`:hover` 高亮分隔线。 + +- **可见性不变式**:`#commit-panel.show` ⟺ `selectedHash !== null`。所有路径经三个漏斗收敛——`requestPanelData(hash)`(开面板 + Loading)、`deselectRow()`(收面板 + 清选中 + 焦点回落图区)、`log/graphData` 的恢复/消失分支。`deselectRow()` 的幂等守卫**同时校验选中态与面板可见性**:`graphData` 解析出「无选中」(如切到无记忆选中的仓库)时 `selectedHash` 本已为 `null`,仅按选中态早退会把上一仓库的面板连内容一起留在原地。 +- **窄视口降级**:面板 `min-width: 280px` 不可收缩,横向并排会把 `#viewport` 挤至零宽(`flex: 1` + `min-width: 0`)。故 `#main` 宽度 < 560px 时切 `.stacked` 退化为上下堆叠(面板 45%,图区保 55%)——观察 `#main` 而非 `#viewport`,因后者宽度随面板开合变化会形成「开面板→变窄→堆叠→变宽」反馈环。面板开合引起的 `#viewport` 宽度变化仍由既有 `ResizeObserver` 切 `.narrow`(隐 author/date、留 CI 列)。 + +## 交互与数据流 + +```mermaid +flowchart LR + A["点击/方向键选中行"] --> B["selectRow → log/selectCommit"] + B --> C["host 并行取数
sendCommitFiles + showCommitDetail"] + C -->|"log/commitFiles{hash}"| D["renderDetails → #details"] + C -->|"log/commitDetail{forHash, vm}"| E["renderCommitMeta → #commit-meta"] + B --> F["Loading 占位(两区即时反馈)"] + G["× / Esc → deselectRow"] --> H["面板隐藏 + 选中清除 + persist"] + I["log/graphData(刷新/切库/重载)"] -->|"选中消失"| G + I -->|"选中在 + 未装载"| B + style A fill:#1f6feb,color:#fff + style C fill:#8250df,color:#fff + style D fill:#d29922,color:#fff + style E fill:#238636,color:#fff + style G fill:#cf222e,color:#fff +``` + +- **一次选中、两路并行**:host `log/selectCommit` 分支同时发起变更文件与详情取数,均沿用 `rootAtStart` 切库竞态守卫(issue #107)。 +- **过期回包丢弃**:`log/commitFiles` 按 `hash`、`log/commitDetail` 按 **`forHash`** 回显校验——失败回包 `vm=null` 无 hash 可比对,`forHash` 补齐该盲区,杜绝迟到失败在新选中下闪现占位。 +- **失败非静默**:详情取数失败显示 "Details unavailable" 占位;同 hash 重点击放行重试(`metaFailHash` 例外)。 +- **同 hash 重点击取反**:点击已选中的提交行 = 反向操作收起面板(方向键/Home/End 边界仍走防抖早退,不误触);距选中 <300ms 的第二击视为双击成分、豁免取反(双击=「打开」语义保留,无开合抖动);取数失败态(`metaFailHash`)例外放行重试。 +- **刷新语义**:提交对象不可变——文件不重拉;`meta` 按新行集重渲(新推分支/标签等引用 chips 变化即时反映)。 +- **重载恢复**:选中 hash 本就按仓库持久化(state v2),`graphData` 到达且选中行存在时补拉面板数据,实现跨重载/切库恢复。 +- **XSS 安全**:分支名/标签/作者/正文等一切外部内容均经 `esc()` 转义后注入。 +- **标题栏切换通路**:scope 切换 → host `setScope`(memento + context key + 防抖重拉);List/Tree 切换 → host `setDetailMode`(`log/detailMode` 定向下发,免整图重拉);两者均为 host 侧事实源,随 `graphData`(`scope`/`dmode` 字段)在重载/切库后恢复。 + +## 边缘 Case + +| Case | 行为 | +|---|---| +| 快速连点 A→B→C | 每击即显新 hash Loading;过期回包按 `hash`/`forHash` 丢弃 | +| 取数途中切库 | host 竞态守卫 + `forHash` 丢弃;新库 `graphData` 恢复其记忆选中 | +| 选中期间 git 刷新 | 面板保持,文件不重拉,引用分组按新行集重渲 | +| 根提交 / merge | "No changed files (may be a root or merge commit)" 占位 + 详情正常 | +| CI 图标 | 点击不选行、CI 浮层原样(原互斥代码随浮层一并消失) | +| 非 GitHub 远程 | 无 Open on GitHub 链接(host 不下发 `githubUrl`) | +| 键盘 | 方向键/Home/End 移动选中即更新面板;`Enter` 菜单、右键菜单原样;`Esc` 取消选中;gutter 聚焦后方向键 ±2%、Home/End 归边界 | +| 再次点击已选中行 | 反向操作收起面板;双击(<300ms 第二击)豁免;取数失败态重点击 = 重试 | +| 切到无记忆选中的仓库 | 面板收起并清空(不残留上一仓库的文件列表 / 详情,杜绝旧 hash 误发 `log/openFile`) | +| 视图窄于 560px | `.stacked` 上下堆叠,图区恒占满宽、不被面板压缩;`≥560px` 恢复左右分栏 | +| gutter 极限拖拽 | 横向钳制:面板 ≥200px 且图区 ≥280px;纵向钳制 18%–75% / 上半区 15%–85%,比例按仓库记忆 | +| 切库后拖拽比例 | `loadPersistedFor` 重装载该仓库记忆的 `panelPct`/`detailPct`(选中/目录折叠同批) | + +## 实现 + +- 面板与漏斗:[`adapter/webview/log-webview.ts`](../../src/adapter/webview/log-webview.ts)(`#main`/`#commit-panel` 分栏、`selectRow`/`requestPanelData`/`deselectRow`、`renderCommitMeta`、Loading 占位、消息守卫、gutter 拖拽/键盘、再次点击取反与 300ms 双击守卫)。 +- 标题栏控件:[`package.json`](../../package.json)(`view/title` 贡献:`hyperGit.log.scopeMenu` 子菜单 + `toggled` 勾选、`detailTree/detailFlat` 互斥图标、`selectRepository`/`ci.signIn` 条件按钮)+ host `setScope`/`setDetailMode`(memento + context key);命令注册见 [`extension.ts`](../../src/extension.ts)。 +- 协议:[`shared/protocol.ts`](../../src/shared/protocol.ts)(`log/commitDetail` 增 `forHash`、`LogGraphState` 增 `dmode`、新增 `log/detailMode`;移除 `log/showCommitDetail`、`log/setScope`、`log/selectRepo`、`log/ciSignIn` 与编辑器区面板遗留死类型)。 +- 复用:[`engine/tree/file-tree.ts`](../../src/engine/tree/file-tree.ts)(目录树)、[`engine/log/commit-files.ts`](../../src/engine/log/commit-files.ts)(name-status 解析)、[`engine/ci/remote-parser.ts`](../../src/engine/ci/remote-parser.ts)(GitHub URL)、`log/openFile` → `hyperGit.openCommitFileDiff` Diff 链路不变。 + +## 验证 + +`pnpm run check-types` + `pnpm run lint` + `pnpm run test:unit`(403 用例)全绿;Extension Development Host(F5):标题栏 Scope 下拉勾选切换、List/Tree 图标切换(免整图重拉)、仓库切换/CI 登录按钮条件显隐、description 路径常驻;点击行开面板(Loading → 文件计数 + 详情)、**再次点击取反收起、双击不抖动**、`×`/`Esc` 收起且方向键仍可用、gutter 鼠标拖拽/键盘微调、快速连点终态正确、选中态下 commit 后面板保持且 chips 更新、切分支选中消失自动收起、切库/重载恢复(含拖拽比例)、根/merge 占位、窄视图堆叠自适应。 + +分栏与状态机另经**离屏夹具实测**(抽取本文件真实 CSS + webview 脚本、桩掉 `acquireVsCodeApi` 后在浏览器中量测): + +| 视图宽度 | 降级前图区宽 | 降级后 | +|---|---|---| +| 1200px | 696px | 左右分栏,图区 696px | +| 640px | 360px | 左右分栏,图区 360px | +| 560px | 280px | 左右分栏,图区 280px(阈值边界) | +| 480px | 200px | `.stacked`,图区 480px | +| 320px | 40px | `.stacked`,图区 320px | +| 280px | **0px** | `.stacked`,图区 280px | + +状态机六步序列(repo1 选中 → 切 repo2 无记忆选中 → 迟到 `vm=null` 回包 → 切回 repo1 → `×` 取消 → 重复取消)全部命中预期:面板可见性与 `selectedHash` 始终同步,重复 `deselectRow()` 零额外 `postMessage`,横向无溢出(`scrollWidth - clientWidth === 0`)。本轮标题栏迁移 + gutter + 取反交互复验:工具栏 DOM 零残留;真实鼠标拖拽主 gutter 150px → 面板 462→614px、图区 634→482px、`panelPct=0.5582` 入库;meta gutter 拖拽 80px → 上半区 275→357px、`detailPct=0.714`;极限拖拽钳制面板 200px / 图区 280px;键盘 ±2% 生效;堆叠态(420px)拖拽双向生效且图区恒满宽;`log/detailMode` 与 `graphData.dmode` 均正确驱动 Tree 渲染;webview 持久化键收敛为 `selectedHash/dcollapsed/panelPct/detailPct`。 + +## 展望 + +- 分栏比例持久化迁移至 host `workspaceState`(现随 webview state 按仓库记忆,与 scope/dmode 的宿主对齐;低优先)。 +- 详情区随 `host` 防抖合并(方向键连续导航时减少单提交 `git` 调用,现为每停 2 次,毫秒级可接受)。 diff --git a/docs/features/log-commit-tooltip.md b/docs/features/log-commit-tooltip.md deleted file mode 100644 index 62a0eeb..0000000 --- a/docs/features/log-commit-tooltip.md +++ /dev/null @@ -1,41 +0,0 @@ -# Log 提交悬浮详情(Commit Tooltip) - -> 鼠标悬停 Log 视图任一提交行,以浮层快速展示该提交的完整信息:所在**本地/远程分支、标签、HEAD**、**完整提交消息**(subject + body)、**作者**(`name `)、(与作者不同时的)**提交者**、**作者/提交时间**(绝对 + 相对)、**完整 SHA**。 - -## 数据流与取数 - -引用标签(分支/标签/HEAD)本就随 `GraphRowVM.chips` 下发;本特性补齐正文与提交者,随首帧一并送达,故悬停**零往返、零延迟**。 - -```mermaid -flowchart LR - A["git log --format=
…%cn %cI %s %b"] --> B["parseLogLines → RawCommit"] - B --> C["GraphRowVM
+authorEmail/committer/body(capBody)"] - C -->|"log/graphData(首帧全量)"| D["webview model.rows"] - D -->|"悬停行 ~400ms"| E["buildCommitTip → #commit-tip"] - style A fill:#1f6feb,color:#fff - style E fill:#d29922,color:#fff -``` - -- **格式扩展**([`engine/log/log-line.ts`](../../src/engine/log/log-line.ts)):`LOG_GRAPH_FORMAT` 增 `%cn`(提交者)、`%cI`(提交时间)、`%b`(正文)。`%b` **置于末字段**:记录以 RS `%x1e` 终止、字段以 NUL `%x00` 分隔,二者不出现于 git 文本,故正文内换行/管道/反斜杠不被误判为边界;解析用 `slice(8).join(NUL)` 稳健重组末字段。 -- **payload 控制**:正文 host 侧 `capBody` 截断至上限(超限省略号、裁尾空白),避免 1000 行图的 `postMessage` 膨胀;浮层内另设滚动区兜底长正文。 - -## 复用 CI 浮层范式 - -沿用既有 CI 状态浮层(`#ci-tip`)的自定义浮层设计,新增 `#commit-tip`: - -- **置于 `#rows` 之外**:虚拟滚动重写行 DOM 时浮层不被销毁。 -- **与 CI 浮层互斥**:悬停 `.ci` 图标只显 CI 浮层(提交浮层隐藏并取消在途显示);悬停行其余区域显提交浮层(显示前先 `hideTip()` 关 CI 浮层)。 -- **延迟与消隐**:悬停 ~400ms 显、离开 ~200ms 隐;滚动、`log/graphData`(刷新/切范围)、`log/ciMeta`、`log/appendData`(整体重绘 `#rows`)均即时消隐并取消在途显示(避免对已脱离节点定位)。 -- **键盘可达**:选中行按 `i` 打开、`Esc` 关闭(`stopPropagation` 避与 `Enter`→提交菜单冲突)。 -- **无双重气泡**:移除提交行 chips 的原生 `title`,引用明细统一由本浮层展示。 -- **XSS 安全**:分支名/标签/作者/正文等一切外部内容均经 `esc()` 转义后注入。 - -## 实现 - -- 数据:[`engine/log/log-line.ts`](../../src/engine/log/log-line.ts)(格式 + `RawCommit` + 解析;[`tests/unit/log-line.test.ts`](../../tests/unit/log-line.test.ts)/[`log-query.test.ts`](../../tests/unit/log-query.test.ts) 更新含多行/空正文/提交者异于作者用例)。 -- 协议:[`shared/protocol.ts`](../../src/shared/protocol.ts)(`GraphRowVM` 增 `authorEmail/committerName/committerDate/body`)。 -- 浮层:[`adapter/webview/log-webview.ts`](../../src/adapter/webview/log-webview.ts)(`#commit-tip` + `buildCommitTip`/`positionCommitTip`/`scheduleShowCommit`/`hideCommitTip`、行 hover 处理、`fmtAbs`/`fmtRel`)。 - -## 验证 - -`pnpm run test:unit` 全绿;Extension Development Host(F5):悬停提交行 ~400ms 出浮层含各引用组 + 消息(多行保留、超长滚动 + 截断)+ 作者/提交者/时间/完整 SHA;悬停 CI 图标只出 CI 浮层(互斥);chips 无原生气泡;滚动/切换范围/刷新自动消隐;`i` 开 `Esc` 关且不误触菜单;合并 / 无正文 / detached HEAD / 多引用均正常。 diff --git a/docs/features/multi-root-repo-selection.md b/docs/features/multi-root-repo-selection.md index 24e8636..c38533a 100644 --- a/docs/features/multi-root-repo-selection.md +++ b/docs/features/multi-root-repo-selection.md @@ -1,6 +1,6 @@ # 多根工作区仓库切换(Multi-root Repository Selection) -> 多根工作区(multi-root)含多个 Git 仓库时,**Graph 工具栏仓库名升级为可切换按钮**(对标 Git Graph 的 repo selector),Command Palette 亦可直达。切换的是**全局活跃仓库**:Graph / Branches / Commit(changelist)/ Stash / Shelf / Worktrees / 未提交角标**全部视图跟随**,changelist 分配、分支收藏、分支分组偏好、Shelf 存储、最近提交消息**按仓库隔离记忆**,重开窗口回到上次操作的仓库。 +> 多根工作区(multi-root)含多个 Git 仓库时,**Graph 标题栏出现「切换仓库」图标按钮**(`$(repo)`,对标 Git Graph 的 repo selector;仓库路径常驻 `WebviewView.description` 副标题),Command Palette 亦可直达。切换的是**全局活跃仓库**:Graph / Branches / Commit(changelist)/ Stash / Shelf / Worktrees / 未提交角标**全部视图跟随**,changelist 分配、分支收藏、分支分组偏好、Shelf 存储、最近提交消息**按仓库隔离记忆**,重开窗口回到上次操作的仓库。 ## 交互形态 @@ -17,7 +17,7 @@ flowchart LR style E fill:#8957e5,color:#fff ``` -- **入口一(主)**:Graph 工具栏右侧仓库名按钮——多仓库时显示 `basename ▾`、hover 高亮,单仓库退化为纯文本(现状观感,多数用户零感知); +- **入口一(主)**:Graph 标题栏右侧「切换仓库」图标按钮(`when: hyperGit.log.multiRepo` 显隐;原 webview 工具栏仓库名按钮上移,省一行竖直空间); - **入口二**:Command Palette → `Hyper Git: Select Repository…`(单仓库时提示 no-op); - **持久化**:活跃仓库记于 `workspaceState`(`hyperGit.activeRepoRoot`,per-workspace 天然隔离),重开窗口恢复。 diff --git a/docs/releases/README.md b/docs/releases/README.md index 2baf36a..6462bd7 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -8,6 +8,7 @@ | 版本 | 说明 | 日期 | |---|---|---| +| [v0.0.17](./v0.0.17.md) | Log 提交详情常驻面板 · VS Code 1.136 最佳实践对齐 · 稳定性与体验修复 | 2026-09-09 | | [v0.0.16](./v0.0.16.md) | 多根工作区仓库切换 · 分支前缀分组树 · Agentic Git 预置 · Diff 修复与面板布局优化 | 2026-09-01 | | [v0.0.14](./v0.0.14.md) | 修复 GRAPH 视图提交记录时间倒序(`--topo-order` → `--author-date-order`) | 2026-07-06 | | [v0.0.13](./v0.0.13.md) | 视图容器由活动栏迁移至底部 Panel(Terminal 之后) | 2026-07-05 | diff --git a/docs/releases/v0.0.17.md b/docs/releases/v0.0.17.md new file mode 100644 index 0000000..e38cbf0 --- /dev/null +++ b/docs/releases/v0.0.17.md @@ -0,0 +1,86 @@ +# Hyper Git - Agentic Git v0.0.17 — Log 提交详情常驻面板 · VS Code 1.136 最佳实践对齐 · 稳定性与体验修复 + +> 本版核心:**Log 提交详情由悬停浮层升级为右侧常驻面板**(三区可拖拽分割),并**全面对齐 VS Code 1.136 控件 / 交互 / API 最佳实践**——修复 9 项缺陷、Webview 控件全面主题化;新增长时 git 操作进度反馈、入门导览与默认键位、Stash / Worktrees 批量操作、分支 / 标签名即时校验等体验增强。 + +--- + +## ✨ 亮点速览 + +- **Log 提交详情常驻面板**:点击 Graph 提交行即在图右侧常驻打开详情面板——上半 **Changed Files**(List / Tree 切换、点击打开 Diff),下半**提交信息**(作者 / 时间、HEAD / 分支 / 远程 / 标签引用分组、完整消息、变更统计、完整 SHA、Open on GitHub)。`×` 或 `Esc` 取消选中即收起,刷新 / 重载按仓库记忆恢复;窄视图自动退化为上下堆叠。 +- **VS Code 1.136 最佳实践对齐(9 项缺陷修复)**:Merge 编辑器「保存 → 拒绝强制保存 → 取消」路径失效(`acquireVsCodeApi` 重复调用)、Commit 草稿丢失(按仓库持久化)、深浅主题切换后 Graph 泳道色停留旧主题、窗口 reload 后 Rebase / Merge 面板空白、git 扩展不可用时视图报错等全部修复。 +- **长时操作进度反馈**:pull / push / fetch / merge / rebase / cherry-pick / reset 等以 `withProgress` 呈现进度,失败通知附 "Show Output" 直达 Console。 +- **入门导览与默认键位**:新增 Get Started 五步导览(walkthroughs);`Alt+B` 切换 Blame 注解、`Alt+G` 打开 Graph 过滤。 +- **Stash / Worktrees 批量操作**:两视图支持多选批量 Drop / Remove(跳过 main 与当前打开项),操作后自动聚焦。 +- **Graph 工具栏上移标题栏**:scope 改图标下拉、仓库路径常驻副标题、多仓库切换与 GitHub 登录图标按钮——省一整行竖直空间。 +- **Webview 控件全面主题化**:滚动条 / checkbox / 下拉 / 阴影 / 字号全部走 VS Code 主题变量(随用户字号设置缩放);高对比度主题下 Graph 胶囊改描边呈现。 +- **文件状态字母标记**:`●` 色点改为 M / A / U / R / D / C 着色字母(色盲可辨,对齐官方 SCM)。 + +--- + +## 🧩 特性详述 + +### 1. Log 提交详情常驻面板(#115) + +原 `i` 悬停浮层整体升级为**右侧常驻面板**:点击 Graph 提交行即在图右侧于 webview 内水平分栏打开——上半 **Changed Files**(List ⇄ Tree 切换、点击打开 Diff),下半**提交信息**(作者 / 时间、HEAD / 分支 / 远程 / 标签引用分组、完整消息、提交者(异于作者时)、变更统计、完整 SHA、Open on GitHub),深浅主题自适应。 + +- **面板可见性 ⟺ 选中态**:`×` 或 `Esc` 取消选中即收起;刷新 / webview 重载按仓库记忆恢复选中与面板; +- **窄视图自适应**:`#main` < 560px 时自动退化为上下堆叠(面板 45% / 图区 55%),避免面板把提交图挤成零宽; +- **三区分割线可拖拽**:图 ⇄ 面板与面板内 Changed Files ⇄ 提交信息两根 gutter,实时调比例、按仓库记忆、键盘可达(方向键 ±2%、`Home` / `End` 归边界); +- **再次点击已选中的提交行 = 反向操作**:收起详情面板(300ms 内双击第二击豁免,保留「双击即打开」语义)。 + +原悬停浮层与 `i` 快捷键移除。完整叙述见 [Log 提交详情面板](../features/log-commit-detail-panel.md)。 + +### 2. VS Code 1.136 最佳实践对齐(#116,9 项缺陷修复) + +- **Merge / Rebase 编辑器路径失效**:webview 回调内重复调用 `acquireVsCodeApi()`(每个 webview 仅允许一次,第二次抛异常)——改为脚本顶部获取后全程复用; +- **Commit 草稿丢失**:视图隐藏销毁后 message / amend / signoff / skipHooks 全部丢失且模板重灌覆盖——webview state 升 v3 **按仓库持久化草稿**,输入即时落盘,提交成功清空; +- **主题热切换泳道色停留旧主题**:泳道色原为启动期一次性快照,补 `MutationObserver` 监听 body 属性热重算并重渲; +- **分支树文件夹节点出现分支级菜单**:11 处 `viewItem =~` 正则未锚定误匹配,全部补 `^…$` 锚定; +- **CLI 通道失败不可诊断**:`execFile` 丢弃 stderr,现透传挂到错误对象并记入 Console; +- **交互 Rebase 的 reword 可能拉起新 VS Code 窗口**:补 `ELECTRON_RUN_AS_NODE=1`; +- **窗口 reload 后 Rebase / Merge 面板空白**:补 `registerWebviewPanelSerializer`(Rebase 恢复编辑后的 todo,Merge 重拉冲突三阶段); +- **git 扩展不可用时视图报 "no tree view registered"**:补空 provider(viewsWelcome 正常触发); +- **Blame 注解**:悬浮信息换行折叠(MarkdownString);split 编辑器无注解、已关闭文档条目泄漏修复。 + +### 3. 体验增强 + +- **长时操作进度反馈**:pull / push / fetch / merge / rebase / cherry-pick / reset / updateProject 以 `withProgress` 呈现(默认标题栏非阻塞);git 操作失败通知附 "Show Output" 按钮直达 Console; +- **入门导览与默认键位**:Get Started with Hyper Git 五步导览(walkthroughs);`Alt+B` 切换 Blame 注解、`Alt+G` 打开 Graph 过滤(Commit 输入框 `Ctrl/Cmd+Enter` 提交原有内建保留); +- **Stash / Worktrees 批量操作**:多选批量 Drop(按 index 降序防位移)/ Remove(跳过 main 与当前打开项);新建后聚焦对应视图; +- **分支 / 标签名即时校验**:git check-ref-format 的 UI 子集纯函数挂接全部 8 处输入框; +- **QuickPick 体验**:提交选择器 hash 前缀可搜、Rebase / 标签目标分段 Separator、Commit 文件菜单 codicon 图标; +- **Commit 文件列表键盘导航**:ArrowUp / Down / Home / End / Enter / Escape 容器级焦点;Rebase 拖拽补 `Alt+↑/↓` 键盘替代。 + +### 4. 工程现代化 + +- **Webview 控件全面主题化**:滚动条走 `--vscode-scrollbarSlider-*`、checkbox `accent-color`、下拉统一 `--vscode-dropdown-*`、阴影 `--vscode-widget-shadow`;字号以 `--vscode-font-size` 为基准 calc 派生;高对比度主题 Graph 胶囊改描边;Graph 行高 / 泳道列宽常量单源化; +- **文件状态字母标记**:`●` 色点改为 M / A / U / R / D / C 着色字母;8 处 Unicode 字符图标全部 SVG 化; +- **日志通道现代化**:两 OutputChannel 迁 `LogOutputChannel`(自动时间戳与级别),懒创建并随扩展释放; +- **能力声明与设置治理**:补 `extensionKind: workspace` 与 `untrustedWorkspaces: unsupported`;11 项设置补 `scope`;palette 补 `when` 限定;确认对话框多行正文迁 `MessageOptions.detail`;Shelf 服务全量迁 `fs.promises`(消除同步 IO 阻塞 UI 线程)。 + +--- + +## ⚙️ 升级与兼容 + +- **agent 偏好默认值变更**:`hyperGit.agent.commitPreferences` / `createPrPreferences` / `reviewPreferences` 默认值由内嵌个人化模板改为**空(= 内置默认)**——此前默认值中的个人化内容不再分发;已自定义过的用户不受影响; +- **Graph scope / List-Tree 偏好一次性重置为默认**(All / flat):偏好自 webview state 移交 host `workspaceState` 按仓库持久化,旧偏好不迁移(选中与目录折叠不受影响); +- **悬停浮层与 `i` 快捷键移除**:提交详情统一走点击行的右侧常驻面板; +- **不信任工作区不激活**:补 `untrustedWorkspaces: unsupported` 能力声明(对齐官方安全基线); +- **无破坏性命令 / 视图变更**:全部命令、视图、配置项保留(悬停浮层消息通道为内部实现移除)。 + +--- + +## 📦 安装 + +- **VS Code Marketplace**:搜索 `Hyper Git - Agentic Git` 直接安装或更新。 +- **手动**:从 [Releases](https://github.com/ThreeFish-AI/hyper-git/releases/tag/v0.0.17) 下载 `hyper-git-agentic-git-0.0.17.vsix` → 命令面板 `Extensions: Install from VSIX`(Cursor / Windsurf / VSCodium 等用户可经此方式安装)。 + +**系统要求**:VS Code ≥ 1.85.0,且启用内置 Git 扩展(`vscode.git`,默认随附)。仅支持本地 git 仓库,不支持虚拟 / Web 工作区。 + +--- + +## 💬 反馈 + +欢迎在 [GitHub Issues](https://github.com/ThreeFish-AI/hyper-git/issues) 反馈问题与建议。完整工程视角变更记录见 [CHANGELOG](../../CHANGELOG.md)。 + +许可证:[MIT](https://github.com/ThreeFish-AI/hyper-git/blob/master/LICENSE)。 diff --git a/media/hyper-git-icon.svg b/media/hyper-git-icon.svg index d68ce61..dd13eae 100644 --- a/media/hyper-git-icon.svg +++ b/media/hyper-git-icon.svg @@ -1,4 +1,4 @@ - + diff --git a/media/overview.png b/media/overview.png index a0cc9d6..93f378d 100644 Binary files a/media/overview.png and b/media/overview.png differ diff --git a/media/walkthrough/branches.md b/media/walkthrough/branches.md new file mode 100644 index 0000000..57de6db --- /dev/null +++ b/media/walkthrough/branches.md @@ -0,0 +1,7 @@ +The **Branches** view covers ref management: + +- Group branches **by `/` prefix** or keep them flat +- Multi-select to delete, favorite, or copy refs in bulk +- Right-click for checkout, merge, rebase, compare, and cleanup of merged branches + +[Open the Branches view](command:hyperGit.branches.focus) diff --git a/media/walkthrough/commit.md b/media/walkthrough/commit.md new file mode 100644 index 0000000..c7eab69 --- /dev/null +++ b/media/walkthrough/commit.md @@ -0,0 +1,10 @@ +The **Commit** view consolidates the whole commit flow: + +- Group work into multiple **changelists** and commit them independently +- Pick files with checkboxes, browse **flat or directory-tree** layouts +- Write the message with live **Conventional Commits** validation +- Reuse recent messages, amend, sign off, or skip hooks under *Advanced Options* + +Press `Ctrl/Cmd+Enter` in the message box to commit. + +[Open the Commit view](command:hyperGit.commit.focus) diff --git a/media/walkthrough/graph.md b/media/walkthrough/graph.md new file mode 100644 index 0000000..fb44483 --- /dev/null +++ b/media/walkthrough/graph.md @@ -0,0 +1,7 @@ +The **Graph** view renders the commit graph with lanes, ref chips and CI status: + +- Switch scope between **All commits**, the **current branch**, and **checkpoints** +- Filter by author, path, message (text or regex), date, and merge mode +- Click a commit to open the resident details panel — changed files, message, and stats + +[Open the Graph view](command:hyperGit.log.focus) diff --git a/media/walkthrough/panel.md b/media/walkthrough/panel.md new file mode 100644 index 0000000..fbee80e --- /dev/null +++ b/media/walkthrough/panel.md @@ -0,0 +1,8 @@ +All Hyper Git views live in the **Hyper Git** container in the bottom panel: + +- **Commit** — the IntelliJ-style commit window +- **Graph** — commit graph with filters and CI status +- **Branches** — local / remote branches and tags +- **Worktrees**, **Stash**, **Shelf** — parallel work and shelving + +[Open the Hyper Git panel](command:hyperGit.commit.focus) diff --git a/media/walkthrough/stash.md b/media/walkthrough/stash.md new file mode 100644 index 0000000..8141190 --- /dev/null +++ b/media/walkthrough/stash.md @@ -0,0 +1,6 @@ +Beyond stash, Hyper Git brings two IntelliJ favorites: + +- **Shelf** — store patches per changelist independently of git stash +- **Worktrees** — check out multiple branches in parallel working directories + +[Open the Worktrees view](command:hyperGit.worktrees.focus) diff --git a/package.json b/package.json index a92adef..0238a46 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "theme": "dark" }, "description": "Hyper Git brings IntelliJ IDEA's powerful Git & Commit modules to VS Code, enhanced with AI-powered autonomous agents for intelligent repository management. Experience Git in a higher dimension.", - "version": "0.0.16", + "version": "0.0.17", "publisher": "ThreeFish-AI", "license": "MIT", "preview": true, @@ -39,6 +39,10 @@ "virtualWorkspaces": { "supported": false, "description": "Hyper Git 依赖本地 git,不支持虚拟/web 工作区。" + }, + "untrustedWorkspaces": { + "supported": false, + "description": "Hyper Git runs git commands that can commit, push and discard changes. Enable it only in workspaces you trust." } }, "extensionDependencies": [ @@ -46,7 +50,60 @@ ], "activationEvents": [], "main": "./dist/extension.js", + "extensionKind": [ + "workspace" + ], "contributes": { + "walkthroughs": [ + { + "id": "hyperGit.getStarted", + "title": "Get Started with Hyper Git", + "description": "Bring IntelliJ IDEA's Git & Commit workflow into VS Code — changelists, an interactive commit window, a rich graph, shelf, stash, and worktrees.", + "steps": [ + { + "id": "panel", + "title": "Open the Hyper Git panel", + "description": "Find every Hyper Git view in the bottom panel.", + "media": { + "markdown": "media/walkthrough/panel.md" + } + }, + { + "id": "commit", + "title": "Commit with changelists", + "description": "Group changes into changelists and commit them independently, with live Conventional Commits validation.", + "media": { + "markdown": "media/walkthrough/commit.md" + }, + "primary": true + }, + { + "id": "graph", + "title": "Explore the commit graph", + "description": "Filter the graph, check CI status, and inspect any commit in the details panel.", + "media": { + "markdown": "media/walkthrough/graph.md" + } + }, + { + "id": "branches", + "title": "Manage branches and tags", + "description": "Group, multi-select, and act on refs from one tree.", + "media": { + "markdown": "media/walkthrough/branches.md" + } + }, + { + "id": "stash", + "title": "Shelf, stash, and worktrees", + "description": "Shelve patches per changelist, stash changes, and work on branches in parallel worktrees.", + "media": { + "markdown": "media/walkthrough/stash.md" + } + } + ] + } + ], "viewsContainers": { "panel": [ { @@ -63,38 +120,44 @@ "name": "Commit", "type": "webview", "visibility": "collapsed", - "initialSize": 3 + "initialSize": 3, + "contextualTitle": "Hyper Git" }, { "id": "hyperGit.branches", "name": "Branches", "visibility": "visible", - "initialSize": 2 + "initialSize": 2, + "contextualTitle": "Hyper Git" }, { "id": "hyperGit.log", "name": "Graph", "type": "webview", "visibility": "visible", - "initialSize": 3 + "initialSize": 3, + "contextualTitle": "Hyper Git" }, { "id": "hyperGit.worktrees", "name": "Worktrees", "visibility": "collapsed", - "initialSize": 1 + "initialSize": 1, + "contextualTitle": "Hyper Git" }, { "id": "hyperGit.stash", "name": "Stash", "visibility": "hidden", - "initialSize": 1 + "initialSize": 1, + "contextualTitle": "Hyper Git" }, { "id": "hyperGit.shelf", "name": "Shelf", "visibility": "hidden", - "initialSize": 1 + "initialSize": 1, + "contextualTitle": "Hyper Git" }, { "id": "hyperGit.changesBadge", @@ -182,6 +245,33 @@ "category": "Hyper Git", "icon": "$(refresh)" }, + { + "command": "hyperGit.log.scopeAll", + "title": "All Commits", + "category": "Hyper Git" + }, + { + "command": "hyperGit.log.scopeCurrent", + "title": "Current Branch", + "category": "Hyper Git" + }, + { + "command": "hyperGit.log.scopeCheckpointer", + "title": "Checkpoints", + "category": "Hyper Git" + }, + { + "command": "hyperGit.log.detailTree", + "title": "Group Changed Files by Directory", + "category": "Hyper Git", + "icon": "$(list-tree)" + }, + { + "command": "hyperGit.log.detailFlat", + "title": "Show Changed Files as Flat List", + "category": "Hyper Git", + "icon": "$(list-flat)" + }, { "command": "hyperGit.selectRepository", "title": "Select Repository…", @@ -278,7 +368,7 @@ "command": "hyperGit.rebaseBranch", "title": "Rebase Current Branch onto…", "category": "Hyper Git", - "icon": "$(call-out)" + "icon": "$(arrow-right)" }, { "command": "hyperGit.showBlame", @@ -372,7 +462,7 @@ "command": "hyperGit.ignorePath", "title": "Add to .gitignore", "category": "Hyper Git", - "icon": "$(eye-closed)" + "icon": "$(diff-ignored)" }, { "command": "hyperGit.compareBranches", @@ -598,7 +688,7 @@ "command": "hyperGit.startRebase", "title": "Interactive Rebase…", "category": "Hyper Git", - "icon": "$(call-out)" + "icon": "$(arrow-right)" }, { "command": "hyperGit.moveHunkToChangelist", @@ -697,7 +787,34 @@ "icon": "$(clear-all)" } ], + "submenus": [ + { + "id": "hyperGit.log.scopeMenu", + "label": "Scope", + "icon": "$(layers)" + } + ], "menus": { + "hyperGit.log.scopeMenu": [ + { + "command": "hyperGit.log.scopeAll", + "when": "view == hyperGit.log", + "group": "navigation@1", + "toggled": "hyperGit.log.scope == 'all'" + }, + { + "command": "hyperGit.log.scopeCurrent", + "when": "view == hyperGit.log", + "group": "navigation@2", + "toggled": "hyperGit.log.scope == 'current'" + }, + { + "command": "hyperGit.log.scopeCheckpointer", + "when": "view == hyperGit.log", + "group": "navigation@3", + "toggled": "hyperGit.log.scope == 'checkpointer'" + } + ], "view/title": [ { "command": "hyperGit.refresh", @@ -744,11 +861,36 @@ "when": "view == hyperGit.commit", "group": "1_sync@6" }, + { + "submenu": "hyperGit.log.scopeMenu", + "when": "view == hyperGit.log", + "group": "navigation@0.5" + }, + { + "command": "hyperGit.log.detailTree", + "when": "view == hyperGit.log && !hyperGit.log.tree", + "group": "navigation@0.75" + }, + { + "command": "hyperGit.log.detailFlat", + "when": "view == hyperGit.log && hyperGit.log.tree", + "group": "navigation@0.75" + }, { "command": "hyperGit.refreshLog", "when": "view == hyperGit.log", "group": "navigation@1" }, + { + "command": "hyperGit.ci.signIn", + "when": "view == hyperGit.log && hyperGit.log.ciNeedsSignIn", + "group": "navigation@1.2" + }, + { + "command": "hyperGit.selectRepository", + "when": "view == hyperGit.log && hyperGit.log.multiRepo", + "group": "navigation@1.5" + }, { "command": "hyperGit.logFilter", "when": "view == hyperGit.log", @@ -863,7 +1005,7 @@ "view/item/context": [ { "command": "hyperGit.branchCheckout", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", "group": "1_branch@1" }, { @@ -878,12 +1020,12 @@ }, { "command": "hyperGit.mergeBranch", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", "group": "1_branch@3" }, { "command": "hyperGit.rebaseBranch", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", "group": "1_branch@4" }, { @@ -893,32 +1035,32 @@ }, { "command": "hyperGit.compareBranches", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", "group": "1_branch@6" }, { "command": "hyperGit.copyBranchRef", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/", "group": "1_branch@7" }, { "command": "hyperGit.toggleFavorite", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/", "group": "inline" }, { "command": "hyperGit.toggleFavorite", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/", "group": "1_branch@0" }, { "command": "hyperGit.checkoutAsNew", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", "group": "1_branch@1.5" }, { "command": "hyperGit.compareWithCurrent", - "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch|hyperGit.tag/ && !listMultiSelection", + "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch|hyperGit.tag)$/ && !listMultiSelection", "group": "1_branch@8" }, { @@ -968,7 +1110,7 @@ }, { "command": "hyperGit.worktreeOpen", - "when": "view == hyperGit.worktrees && viewItem =~ /hyperGit.worktree|hyperGit.worktreeMain/", + "when": "view == hyperGit.worktrees && viewItem =~ /^(hyperGit.worktree|hyperGit.worktreeMain)$/", "group": "1_wt@1" }, { @@ -988,7 +1130,7 @@ }, { "command": "hyperGit.worktreeCopyPath", - "when": "view == hyperGit.worktrees && viewItem =~ /hyperGit.worktree|hyperGit.worktreeMain/", + "when": "view == hyperGit.worktrees && viewItem =~ /^(hyperGit.worktree|hyperGit.worktreeMain)$/", "group": "1_wt@5" }, { @@ -1212,6 +1354,34 @@ { "command": "hyperGit.logFilterDate", "when": "view == hyperGit.log" + }, + { + "command": "hyperGit.log.scopeAll", + "when": "view == hyperGit.log" + }, + { + "command": "hyperGit.log.scopeCurrent", + "when": "view == hyperGit.log" + }, + { + "command": "hyperGit.log.scopeCheckpointer", + "when": "view == hyperGit.log" + }, + { + "command": "hyperGit.log.detailTree", + "when": "view == hyperGit.log" + }, + { + "command": "hyperGit.log.detailFlat", + "when": "view == hyperGit.log" + }, + { + "command": "hyperGit.acceptOurs", + "when": "hyperGit.hasConflicts" + }, + { + "command": "hyperGit.acceptTheirs", + "when": "hyperGit.hasConflicts" } ] }, @@ -1221,55 +1391,65 @@ "hyperGit.commit.template": { "type": "string", "default": "", - "markdownDescription": "Commit message template (injected into the Commit view editor)." + "markdownDescription": "Commit message template (injected into the Commit view editor).", + "scope": "resource" }, "hyperGit.commit.conventional": { "type": "boolean", "default": true, - "markdownDescription": "Run real-time Conventional Commits validation on commit messages." + "markdownDescription": "Run real-time Conventional Commits validation on commit messages.", + "scope": "resource" }, "hyperGit.ai.enabled": { "type": "boolean", "default": false, - "markdownDescription": "Enable AI agent capabilities (currently reserved as an architectural seam; not yet active)." + "markdownDescription": "**Reserved** for the upcoming Agentic Git release. This switch has no effect in the current version.", + "scope": "resource" }, "hyperGit.claudeCode.executablePath": { "type": "string", "default": "", - "markdownDescription": "Path to the Claude Code CLI executable used by Hyper Git's Agentic Git features. Leave empty to auto-detect the `claude` binary from your `PATH` (recommended).\n\n[Browse for executable…](command:hyperGit.setClaudeCodePath) · [Open Claude settings (`~/.claude/settings.json`)](command:hyperGit.openClaudeSettings)" + "markdownDescription": "Path to the Claude Code CLI executable used by Hyper Git's Agentic Git features. Leave empty to auto-detect the `claude` binary from your `PATH` (recommended).\n\n[Browse for executable…](command:hyperGit.setClaudeCodePath) · [Open Claude settings (`~/.claude/settings.json`)](command:hyperGit.openClaudeSettings)", + "scope": "machine-overridable" }, "hyperGit.agent.baseBranch": { "type": "string", "default": "", - "markdownDescription": "Base branch used as the baseline when creating a pull request. Leave empty to use the repository's default branch." + "markdownDescription": "**Reserved** for the upcoming Agentic Git release (no effect yet). Base branch used as the baseline when creating a pull request. Leave empty to use the repository's default branch.", + "scope": "window" }, "hyperGit.agent.commitPreferences": { "type": "string", "editPresentation": "multilineText", - "default": "---\ndescription: \"Commit changes to Git\"\n---\n\nCommit the project's file changes to Git, keeping the commit message clear and concise.\n\n## Workflow\n\n1. Before committing, pull the latest version of the current branch from the remote:\n\n ```bash\n git pull\n ```\n\n2. Commit the changes in well-planned batches with prepared commit messages.\n3. Run `git push` to push the current branch to the GitHub remote.\n\n## Notes\n\n- Do not modify the repository contents again at this point; deleting repository content (or files) is never allowed at any time.\n- When appropriate, split the commit into multiple batches by content category; for a file move, do not split it into separate delete and add operations — commit it as a single file-move operation.\n- Write commit messages in Chinese, using the following signature format:\n ```\n 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist)\n Co-Authored-By: Aurelius Huang\n ```\n\n## Commit Message Convention\n\n```\n{Header}({Topic}): Commit Message;\n```\n\n- Header (Required): identifier for the type of change\n - feat: new feature\n - fix: bug fix\n - docs: documentation update\n - style: code-style change that does not affect behavior\n - refactor: change to existing logic\n - pref: performance optimization\n - test: test-case update\n - build: dependency update\n - ci: CI-related configuration change\n - chore: changes outside source and test code\n - revert: revert previous commits\n- Topic (Required): the subject, module, or feature name of the change; **use a short English label**.\n- Commit Message: a summary of the change; **describe it in Chinese**.\n\nExample:\n\n```\nci(Jenkins): 修改配置文件以支持 staging 环境;\n```", - "markdownDescription": "Custom instructions sent to the agent when you click the Commit button." + "default": "", + "markdownDescription": "**Reserved** for the upcoming Agentic Git release (no effect yet). Custom instructions to send to the agent for this action. Leave empty to use the built-in defaults.", + "scope": "window" }, "hyperGit.agent.createPrPreferences": { "type": "string", "editPresentation": "multilineText", - "default": "The user likes the current state of the code.\n\nThere are 3 uncommitted changes.\nThe current branch is ${YOUR_BRANCH}.\nThe target branch is origin/${TARGET_BRANCH}.\nAn upstream branch exists.\n\nThe user requested a PR.\n\nFollow these steps to create a PR:\n\nIf you have any skills related to creating PRs, invoke them now. Instructions there should take precedence over these instructions.\nRun git diff to review uncommitted changes\nCommit them. Follow any instructions the user gave you about writing commit messages.\nPush with git push -u origin HEAD:${YOUR_BRANCH}.\nUse the mcp__conductor__GetWorkspaceDiff tool to review the PR diff\nUse gh pr create --base ${TARGET_BRANCH} --title --body to create a PR onto the target branch.\nIf any of these steps fail, ask the user for help.\n\nPR Title\n\nThe user has provided this PR title. Use it exactly as-is:\n${PR_TITLE}\n\nPR Description\n\nThe user has provided this PR description. Use it exactly as-is:\n${PR_DESCRIPTION}\n\nIMPORTANT: The following are the user's custom preferences. These preferences take precedence over any default guidelines or instructions provided above. When there is a conflict, always follow the user's preferences.\n\nUser Preferences\n\nUpdate the PR that was just created with a better title and description. The PR number is #{pr_number} and the URL is {pr_url}.\nAnalyze the changes in this branch and write:\n\nA concise, descriptive title that summarizes the changes, follow the commit message format specified in ~/.claude/commands/commit.md\nA detailed description that explains:\nWhat changes were made\nWhy they were made (based on the task context)\nAny important implementation details\nUse the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps).", - "markdownDescription": "Custom instructions sent to the agent when you click the Create PR button." + "default": "", + "markdownDescription": "**Reserved** for the upcoming Agentic Git release (no effect yet). Custom instructions to send to the agent for this action. Leave empty to use the built-in defaults.", + "scope": "window" }, "hyperGit.agent.reviewPreferences": { "type": "string", "editPresentation": "multilineText", - "default": "Review guidelines:\n\nYou are acting as a reviewer for a proposed code change made by another engineer.\n\nBelow are some default guidelines for determining whether the original author would appreciate the issue being flagged.\n\nThese are not the final word in determining whether an issue is a bug. In many cases, you will encounter other, more specific guidelines. These may be present elsewhere in a developer message, a user message, a file, or even elsewhere in this system message.\nThose guidelines should be considered to override these general instructions.\n\nHere are the general guidelines for determining whether something is a bug and should be flagged.\n\nIt meaningfully impacts the accuracy, performance, security, or maintainability of the code.\nThe bug is discrete and actionable (i.e. not a general issue with the codebase or a combination of multiple issues).\nFixing the bug does not demand a level of rigor that is not present in the rest of the codebase (e.g. one doesn’t need very detailed comments and input validation in a repository of one-off scripts in personal projects)\nThe bug was introduced in the commit (pre-existing bugs should not be flagged).\nThe author of the original PR would likely fix the issue if they were made aware of it.\nThe bug does not rely on unstated assumptions about the codebase or author’s intent.\nIt is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected.\nThe bug is clearly not just an intentional change by the original author.\nWhen flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment — defer to any subsequent guidelines that you encounter.\n\nThe comment should be clear about why the issue is a bug.\nThe comment should appropriately communicate the severity of the issue. It should not claim that an issue is more severe than it actually is.\nThe comment should be brief. The body should be at most 1 paragraph. It should not introduce line breaks within the natural language flow unless it is necessary for the code fragment.\nThe comment should not include any chunks of code longer than 3 lines. Any code chunks should be wrapped in markdown inline code tags or a code block.\nThe comment should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue’s severity depends on these factors.\nThe comment’s tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.\nThe comment should be written such that the original author can immediately grasp the idea without close reading.\nThe comment should avoid excessive flattery and comments that are not helpful to the original author. The comment should avoid phrasing like “Great job …”, “Thanks for …”.\nBelow are some more detailed guidelines that you should apply to this specific review.\n\nHOW MANY FINDINGS TO RETURN:\n\nOutput all findings that the original author would fix if they knew about it. If there is no finding that a person would definitely love to see and fix, prefer outputting no findings. Do not stop at the first qualifying finding. Continue until you’ve listed every qualifying finding.\n\nGUIDELINES:\n\nIgnore trivial style unless it obscures meaning or violates documented standards.\nUse one comment per distinct issue (or a multi-line range if necessary).\nUse ```suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block).\nIn every ```suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces).\nDo NOT introduce or remove outer indentation levels unless that is the actual fix.\nThe comments will be presented in the code review as inline comments. You should avoid providing unnecessary location details in the comment body. Always keep the line range as short as possible for interpreting the issue. Avoid ranges longer than 5–10 lines; instead, choose the most suitable subrange that pinpoints the problem.\n\nGetting the diff\n\nUse the mcp__conductor__GetWorkspaceDiff tool to review the workspace diff. Start with stat: true to understand the files that changed, then request specific files as needed.\n\nFallback: if you don’t have access to the workspace diff tool\n\nIf you don’t have access to the mcp__conductor__GetWorkspaceDiff tool, use the following git commands to get the diff:\n\n# Get the merge base between this branch and the target\nMERGE_BASE=$(git merge-base origin/${TARGET_BRANCH} HEAD)\n\n# Get the committed diff against the merge base\ngit diff $MERGE_BASE HEAD\n\n# Get any uncommitted changes (staged and unstaged)\ngit diff HEAD\nReview the combination of both outputs: the first shows all committed changes on this branch relative to the target, and the second shows any uncommitted work in progress.\n\nNo need to mention in your report whether or not you used one of the fallback strategies; it’s usually irrelevant.\n\nOutput format\n\nPost inline comments for each issue using mcp__conductor__DiffComment:\n\nIMPORTANT: Only post ONE comment per unique issue.\n\nWrite out a list of issues found, along with the location of the comment. For example:\n\n### **#1 Empty input causes crash**\nIf the input field is empty when page loads, the app will crash.\n\nFile: src/client/frontends/desktop/ui/Input.tsx\n\n#2 Dead code\nThe getUserData function is now unused. It should be deleted.\n\nFile: src/client/frontends/desktop/core/UserData.ts", - "markdownDescription": "Custom instructions sent to the agent when you click the Review button." + "default": "", + "markdownDescription": "**Reserved** for the upcoming Agentic Git release (no effect yet). Custom instructions to send to the agent for this action. Leave empty to use the built-in defaults.", + "scope": "window" }, "hyperGit.log.ci.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Show the final CI status on each commit in the Graph view (GitHub Actions + Commit Statuses). Green check = passed, red cross = failed; hover to see each check and any failure reasons. Requires a GitHub remote." + "markdownDescription": "Show the final CI status on each commit in the Graph view (GitHub Actions + Commit Statuses). Green check = passed, red cross = failed; hover to see each check and any failure reasons. Requires a GitHub remote.", + "scope": "resource" }, "hyperGit.log.ci.remote": { "type": "string", "default": "", - "markdownDescription": "Remote name used to query CI status (empty = auto, prefers `origin`). For multi-remote repos, specify e.g. `upstream`." + "markdownDescription": "Remote name used to query CI status (empty = auto, prefers `origin`). For multi-remote repos, specify e.g. `upstream`.", + "scope": "resource" }, "hyperGit.log.ci.provider": { "type": "string", @@ -1284,10 +1464,23 @@ "GitHub Enterprise (self-hosted)" ], "default": "auto", - "markdownDescription": "CI data source. `auto` is resolved by the origin host (github.com → github.com, otherwise GitHub Enterprise)." + "markdownDescription": "CI data source. `auto` is resolved by the origin host (github.com → github.com, otherwise GitHub Enterprise).", + "scope": "resource" } } - } + }, + "keybindings": [ + { + "command": "hyperGit.toggleBlameAnnotation", + "key": "alt+b", + "when": "editorTextFocus" + }, + { + "command": "hyperGit.logFilter", + "key": "alt+g", + "when": "focusedView == hyperGit.log" + } + ] }, "scripts": { "vscode:prepublish": "pnpm run package", diff --git a/src/adapter/advanced-commands.ts b/src/adapter/advanced-commands.ts index 8139164..2242edb 100644 --- a/src/adapter/advanced-commands.ts +++ b/src/adapter/advanced-commands.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { showGitError } from './notify'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; import type { GitRepositoryService } from './git-repository-service'; import type { LogNode } from './webview/log-webview'; @@ -30,7 +31,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches branchesTree.refresh(); void vscode.window.showInformationMessage('Undo last commit complete (soft)'); } catch (e) { - void vscode.window.showErrorMessage(`Failed to undo: ${errMsg(e)}`); + void showGitError(`Failed to undo: ${errMsg(e)}`); } }), ); @@ -58,7 +59,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches branchesTree.refresh(); void vscode.window.showInformationMessage(`Dropped commit ${hash.slice(0, 7)}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to drop (may need manual conflict resolution): ${errMsg(e)}`); + void showGitError(`Failed to drop (may need manual conflict resolution): ${errMsg(e)}`); } }), ); @@ -89,7 +90,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches branchesTree.refresh(); void vscode.window.showInformationMessage(`Fixup into ${hash.slice(0, 7)} complete`); } catch (e) { - void vscode.window.showErrorMessage(`Fixup failed: ${errMsg(e)}`); + void showGitError(`Fixup failed: ${errMsg(e)}`); } }), ); @@ -107,7 +108,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches const out = await service.execGit(['branch', '--merged', base]); merged = filterMergeable(out, base, headName ? [headName] : []); } catch (e) { - void vscode.window.showErrorMessage(`Failed to query merged branches: ${errMsg(e)}`); + void showGitError(`Failed to query merged branches: ${errMsg(e)}`); return; } if (merged.length === 0) { @@ -173,7 +174,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches const doc = await vscode.workspace.openTextDocument({ content, language: 'markdown' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`3-way diff failed: ${errMsg(e)}`); + void showGitError(`3-way diff failed: ${errMsg(e)}`); } }), ); @@ -192,6 +193,6 @@ async function pickCommitHash(service: GitRepositoryService): Promise (e instanceof Error ? e.message : String( * 编辑器内 Blame 注解(逐行作者 / 日期 / 提交注解)。 * * Toggle:对当前文件执行 `git blame --line-porcelain`,解析每行作者/日期,用行内 - * before 装饰渲染在每行行首(gutter 风格)。再次 toggle 关闭。切换编辑器/文档变更时清理。 + * before 装饰渲染在每行行首(gutter 风格)。再次 toggle 关闭。 + * 生命周期:同一文档 split 出的每个可见编辑器都同步挂载/卸载装饰;文档在所有编辑器 + * 关闭后自动移出注解表;文档编辑(行号失配)与仓库切换时清除对应注解。 */ export class BlameAnnotationController implements vscode.Disposable { private readonly decoration: vscode.TextEditorDecorationType; - private readonly annotated = new Set(); // 已注解的 document uri + private readonly annotations = new Map(); // uri → 装饰选项 private readonly disposables: vscode.Disposable[] = []; constructor(private readonly service: GitRepositoryService) { @@ -26,11 +28,28 @@ export class BlameAnnotationController implements vscode.Disposable { // 文档变更后清除注解(行号失配) this.disposables.push( vscode.workspace.onDidChangeTextDocument((e) => { - if (this.annotated.has(e.document.uri.toString())) { + if (this.annotations.has(e.document.uri.toString())) { this.clear(e.document.uri); } }), ); + // 可见编辑器变化:新 split 出的编辑器补挂装饰;文档全部关闭则移出注解表(防泄漏)。 + this.disposables.push( + vscode.window.onDidChangeVisibleTextEditors((editors) => { + const openKeys = new Set(editors.map((e) => e.document.uri.toString())); + for (const [key, options] of this.annotations) { + if (!openKeys.has(key)) { + this.annotations.delete(key); + continue; + } + for (const e of editors) { + if (e.document.uri.toString() === key) { + e.setDecorations(this.decoration, options); + } + } + } + }), + ); // 活跃仓库切换(issue #107)后清除注解:blame 数据属于旧仓库,不清理会残留误导。 this.disposables.push(service.onDidChangeRepository(() => this.clearAll())); } @@ -43,7 +62,7 @@ export class BlameAnnotationController implements vscode.Disposable { return; } const key = editor.document.uri.toString(); - if (this.annotated.has(key)) { + if (this.annotations.has(key)) { this.clear(editor.document.uri); return; } @@ -66,6 +85,8 @@ export class BlameAnnotationController implements vscode.Disposable { if (!b) { continue; } + // hover 用 MarkdownString.appendText:换行真实生效(纯字符串会把 \n 折叠成空格)且转义作者名/摘要中的 markdown 字符。 + const hover = new vscode.MarkdownString().appendText(`${b.sha.slice(0, 7)} · ${b.author}`).appendText('\n\n').appendText(b.summary); options.push({ range: new vscode.Range(line, 0, line, 0), renderOptions: { @@ -74,26 +95,40 @@ export class BlameAnnotationController implements vscode.Disposable { fontStyle: 'italic', }, }, - hoverMessage: `${b.sha.slice(0, 7)} · ${b.author}\n${b.summary}`, + hoverMessage: hover, }); } - editor.setDecorations(this.decoration, options); - this.annotated.add(key); + this.annotations.set(key, options); + this.applyToVisibleEditors(editor.document.uri); + } + + /** 对展示同一文档的全部可见编辑器(含 split)统一挂载当前注解。 */ + private applyToVisibleEditors(uri: vscode.Uri): void { + const key = uri.toString(); + const options = this.annotations.get(key) ?? []; + for (const e of vscode.window.visibleTextEditors) { + if (e.document.uri.toString() === key) { + e.setDecorations(this.decoration, options); + } + } } private clear(uri: vscode.Uri): void { - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()); - editor?.setDecorations(this.decoration, []); - this.annotated.delete(uri.toString()); + const key = uri.toString(); + const empty: vscode.DecorationOptions[] = []; + for (const e of vscode.window.visibleTextEditors) { + if (e.document.uri.toString() === key) { + e.setDecorations(this.decoration, empty); + } + } + this.annotations.delete(key); } - /** 清除全部注解(仓库切换时,annotated 中的 uri 逐个对可见编辑器复位装饰)。 */ + /** 清除全部注解(仓库切换时,对可见编辑器复位装饰)。 */ private clearAll(): void { - for (const key of [...this.annotated]) { - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === key); - editor?.setDecorations(this.decoration, []); + for (const key of [...this.annotations.keys()]) { + this.clear(vscode.Uri.parse(key)); } - this.annotated.clear(); } dispose(): void { diff --git a/src/adapter/editor/inline-commit-codelens.ts b/src/adapter/editor/inline-commit-codelens.ts index d557b84..b37df7b 100644 --- a/src/adapter/editor/inline-commit-codelens.ts +++ b/src/adapter/editor/inline-commit-codelens.ts @@ -16,11 +16,12 @@ function repoRelative(root: string, fsPath: string): string | null { /** * 行内提交 CodeLensProvider(编辑器内逐 Hunk 提交)。 * - * 对当前文件每个未暂存 hunk,在其起始行上方渲染可点击 CodeLens「✓ 提交此 Hunk (+N -M)」。 + * 对当前文件每个未暂存 hunk,在其起始行上方渲染可点击 CodeLens「$(check) Commit this Hunk (+N -M)」 + * (codicon 主题图标,随 product icon theme 渲染)。 * 点击 → 仅暂存该 hunk(patch 重建 + `git apply --cached`)→ 输入 message → `git commit`。 * gutter 视觉标记(绿/红/蓝)由原生 git quickDiff 提供,不重复造。 */ -export class InlineCommitCodeLensProvider implements vscode.CodeLensProvider { +export class InlineCommitCodeLensProvider implements vscode.CodeLensProvider, vscode.Disposable { private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; @@ -30,7 +31,11 @@ export class InlineCommitCodeLensProvider implements vscode.CodeLensProvider { this._onDidChangeCodeLenses.fire(); } - async provideCodeLenses(doc: vscode.TextDocument): Promise { + dispose(): void { + this._onDidChangeCodeLenses.dispose(); + } + + async provideCodeLenses(doc: vscode.TextDocument, token: vscode.CancellationToken): Promise { const repo = this.service.repo; if (!repo) { return []; @@ -41,7 +46,7 @@ export class InlineCommitCodeLensProvider implements vscode.CodeLensProvider { } try { const diff = await this.service.execGit(['diff', '-U3', '--', rel]); - if (!diff.trim()) { + if (token.isCancellationRequested || !diff.trim()) { return []; } const files = parseUnifiedDiff(diff); @@ -53,7 +58,7 @@ export class InlineCommitCodeLensProvider implements vscode.CodeLensProvider { const line = Math.max(0, r.startLine - 1); return new vscode.CodeLens(new vscode.Range(line, 0, line, 0), { command: 'hyperGit.inlineCommitHunk', - title: `✓ Commit this Hunk (+${r.addedCount} -${r.removedCount})`, + title: `$(check) Commit this Hunk (+${r.addedCount} -${r.removedCount})`, arguments: [rel, r.hunkIndex], }); }); @@ -97,15 +102,13 @@ export function registerInlineCommitCommand(service: GitRepositoryService, provi } const patch = buildPatch(files[0], [hunkIndex]); const tmp = path.join(os.tmpdir(), `hg-inline-${Date.now()}.diff`); - fs.writeFileSync(tmp, patch); + await fs.promises.writeFile(tmp, patch, 'utf8'); try { await service.execGit(['apply', '--cached', '--whitespace=nowarn', tmp]); } finally { - try { - fs.unlinkSync(tmp); - } catch { + void fs.promises.unlink(tmp).catch(() => { /* ignore */ - } + }); } await service.execGit(['commit', '-m', message.trim()]); provider.refresh(); diff --git a/src/adapter/git-cli-commands.ts b/src/adapter/git-cli-commands.ts index f45ca9a..dac6ecc 100644 --- a/src/adapter/git-cli-commands.ts +++ b/src/adapter/git-cli-commands.ts @@ -1,6 +1,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { showGitError } from './notify'; +import { runWithProgress } from './task-progress'; +import { validateRefName } from '../engine/ref/ref-name'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; import type { LogFilterControl, LogNode } from './webview/log-webview'; @@ -26,13 +29,13 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr return; } try { - await service.execGit(['cherry-pick', hash]); + await runWithProgress(`Cherry-picking ${hash.slice(0, 7)}…`, () => service.execGit(['cherry-pick', hash])); branchesTree.refresh(); logTree.refresh(); void vscode.window.showInformationMessage(`Cherry-pick ${hash.slice(0, 7)} complete`); } catch (e) { if (!(await handleGitConflict(service, 'Cherry-pick'))) { - void vscode.window.showErrorMessage(`Cherry-pick failed: ${errMsg(e)}`); + void showGitError(`Cherry-pick failed: ${errMsg(e)}`); } } }), @@ -49,13 +52,13 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr return; } try { - await service.execGit(['revert', '--no-edit', hash]); + await runWithProgress(`Reverting ${hash.slice(0, 7)}…`, () => service.execGit(['revert', '--no-edit', hash])); branchesTree.refresh(); logTree.refresh(); void vscode.window.showInformationMessage(`Revert ${hash.slice(0, 7)} complete`); } catch (e) { if (!(await handleGitConflict(service, 'Revert'))) { - void vscode.window.showErrorMessage(`Revert failed: ${errMsg(e)}`); + void showGitError(`Revert failed: ${errMsg(e)}`); } } }), @@ -91,12 +94,12 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr } } try { - await service.execGit(['reset', `--${pick.label}`, target]); + await runWithProgress(`Resetting to ${target.slice(0, 7)}…`, () => service.execGit(['reset', `--${pick.label}`, target])); branchesTree.refresh(); logTree.refresh(); void vscode.window.showInformationMessage(`Reset (--${pick.label} ${target.slice(0, 7)}) complete`); } catch (e) { - void vscode.window.showErrorMessage(`Reset failed: ${errMsg(e)}`); + void showGitError(`Reset failed: ${errMsg(e)}`); } }), ); @@ -108,7 +111,11 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr return; } const oldName = node.ref.shortName; - const newName = await vscode.window.showInputBox({ prompt: `Rename branch "${oldName}"`, value: oldName }); + const newName = await vscode.window.showInputBox({ + prompt: `Rename branch "${oldName}"`, + value: oldName, + validateInput: (v) => validateRefName(v, 'branch'), + }); if (!newName || !newName.trim() || newName === oldName) { return; } @@ -116,7 +123,7 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr await service.execGit(['branch', '-m', oldName, newName.trim()]); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Rename failed: ${errMsg(e)}`); + void showGitError(`Rename failed: ${errMsg(e)}`); } }), ); @@ -138,7 +145,7 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr }); void vscode.window.showInformationMessage(`Added to .gitignore: ${rel}`); } catch (e) { - void vscode.window.showErrorMessage(`Ignore failed: ${errMsg(e)}`); + void showGitError(`Ignore failed: ${errMsg(e)}`); } }), ); @@ -166,7 +173,7 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr const doc = await vscode.workspace.openTextDocument({ content: `$ git diff --stat ${base}...${target}\n\n${out}`, language: 'plaintext' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Compare failed: ${errMsg(e)}`); + void showGitError(`Compare failed: ${errMsg(e)}`); } }), ); @@ -185,7 +192,7 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr await repo.commit(message.trim(), { amend: true }); void vscode.window.showInformationMessage('Rewrote latest commit'); } catch (e) { - void vscode.window.showErrorMessage(`Rewrite failed: ${errMsg(e)}`); + void showGitError(`Rewrite failed: ${errMsg(e)}`); } }), ); @@ -204,7 +211,8 @@ async function pickCommitHash(service: GitRepositoryService): Promise { const repo = this._repo; if (!repo) { - throw new Error('未找到 Git 仓库'); + throw new Error('No Git repository found'); } return new Promise((resolve, reject) => { - execFile(this.api.git.path, args, { cwd: repo.rootUri.fsPath, maxBuffer: 20 * 1024 * 1024, encoding: 'utf8', env: options?.env }, (err, stdout) => { + execFile(this.api.git.path, args, { cwd: repo.rootUri.fsPath, maxBuffer: 20 * 1024 * 1024, encoding: 'utf8', env: options?.env }, (err, stdout, stderr) => { if (err) { - logGit(args, undefined, err.message); - reject(err); + // execFile 的 err.message 是通用串;真实失败原因在 stderr——透传挂到错误对象并记入 Console, + // 否则 CLI 通道失败在通知与日志中均不可诊断(与 API 通道 GitError.stderr 提取对齐)。 + const gitErr = err as NodeJS.ErrnoException & { stderr?: string }; + gitErr.stderr = stderr?.trim() || undefined; + logGit(args, undefined, gitErr.stderr ? `${err.message}\n${gitErr.stderr}` : err.message); + reject(gitErr); } else { logGit(args, stdout); resolve(stdout); diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index ac392dc..ef41e00 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -1,5 +1,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { showGitError } from './notify'; +import { runWithProgress } from './task-progress'; +import { validateRefName } from '../engine/ref/ref-name'; import type { BranchNode } from './tree/branches-tree'; import type { BranchesTreeProvider } from './tree/branches-tree'; import type { BranchFavorites } from './branch-favorites'; @@ -103,13 +106,16 @@ export function registerHistoryCommands( if (!repo) { return; } - const name = await vscode.window.showInputBox({ prompt: 'New branch name' }); + const name = await vscode.window.showInputBox({ + prompt: 'New branch name', + validateInput: (v) => validateRefName(v, 'branch'), + }); if (name && name.trim()) { try { await repo.createBranch(name.trim(), true); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${errMsg(e)}`); } } }), @@ -125,7 +131,7 @@ export function registerHistoryCommands( await repo.checkout(node.ref.shortName); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Failed to checkout: ${errMsg(e)}`); + void showGitError(`Failed to checkout: ${errMsg(e)}`); } }), ); @@ -156,7 +162,8 @@ export function registerHistoryCommands( const { merged, unmerged } = partitionByMerged(mergedOut, names); const mergedSet = new Set(merged); const { detail, confirmLabel } = formatBranchDeleteConfirm(merged, unmerged); - const choice = await vscode.window.showWarningMessage(detail, { modal: true }, confirmLabel); + // 多行明细走 MessageOptions.detail(VS Code 模态框的次要文字载体),标题保持单行。 + const choice = await vscode.window.showWarningMessage('Delete local branch(es)?', { modal: true, detail }, confirmLabel); if (choice !== confirmLabel) { return; } @@ -195,7 +202,7 @@ export function registerHistoryCommands( branchesTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Merge'))) { - void vscode.window.showErrorMessage(`Failed to merge: ${errMsg(e)}`); + void showGitError(`Failed to merge: ${errMsg(e)}`); } } }), @@ -217,7 +224,7 @@ export function registerHistoryCommands( branchesTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Rebase'))) { - void vscode.window.showErrorMessage(`Failed to rebase: ${errMsg(e)}`); + void showGitError(`Failed to rebase: ${errMsg(e)}`); } } }), @@ -244,7 +251,7 @@ export function registerHistoryCommands( const doc = await vscode.workspace.openTextDocument({ content: blame, language: 'plaintext' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to Blame: ${errMsg(e)}`); + void showGitError(`Failed to Blame: ${errMsg(e)}`); } }), ); @@ -256,11 +263,11 @@ export function registerHistoryCommands( return; } try { - await repo.pull(); + await runWithProgress('Pulling…', () => repo.pull()); branchesTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Pull'))) { - void vscode.window.showErrorMessage(`Failed to Pull: ${errMsg(e)}`); + void showGitError(`Failed to Pull: ${errMsg(e)}`); } } }), @@ -280,7 +287,7 @@ export function registerHistoryCommands( try { if (head.upstream) { // 已配置上游:按 push.default 推送到追踪分支(正确处理本地名/上游名不一致)。 - await repo.push(); + await runWithProgress('Pushing…', () => repo.push()); } else { // 无上游:选定 remote 并以 -u 建立追踪(修复「Failed to execute git」根因)。 const remotes = repo.state.remotes.map((r) => r.name); @@ -297,12 +304,12 @@ export function registerHistoryCommands( if (!remote) { return; } - await repo.push(remote, head.name, true); + await runWithProgress('Pushing…', () => repo.push(remote, head.name, true)); } branchesTree.refresh(); void vscode.window.showInformationMessage(`Pushed ${head.name}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to Push: ${errMsg(e)}`); + void showGitError(`Failed to Push: ${errMsg(e)}`); } }), ); @@ -321,11 +328,11 @@ export function registerHistoryCommands( return; } try { - await repo.fetch(pick.includes('Prune') ? { prune: true } : undefined); + await runWithProgress('Fetching…', () => repo.fetch(pick.includes('Prune') ? { prune: true } : undefined)); branchesTree.refresh(); logTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Failed to Fetch: ${errMsg(e)}`); + void showGitError(`Failed to Fetch: ${errMsg(e)}`); } }), ); @@ -348,7 +355,7 @@ export function registerHistoryCommands( // 逐 remote 执行 fetch --prune:API 的 FetchOptions 仅接受单 remote,遍历以覆盖多远程场景。 for (const remote of remotes) { try { - await repo.fetch({ remote, prune: true }); + await runWithProgress('Pruning remotes…', () => repo.fetch({ remote, prune: true })); } catch { failed.push(remote); } @@ -389,7 +396,11 @@ export function registerHistoryCommands( return; } const source = node.ref.shortName; - const name = await vscode.window.showInputBox({ prompt: `Create and checkout a new local branch from "${source}"`, placeHolder: 'New branch name' }); + const name = await vscode.window.showInputBox({ + prompt: `Create and checkout a new local branch from "${source}"`, + placeHolder: 'New branch name', + validateInput: (v) => validateRefName(v, 'branch'), + }); if (!name || !name.trim()) { return; } @@ -397,7 +408,7 @@ export function registerHistoryCommands( await repo.createBranch(name.trim(), true, source); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${errMsg(e)}`); } }), ); @@ -414,7 +425,7 @@ export function registerHistoryCommands( const doc = await vscode.workspace.openTextDocument({ content: `$ git diff --stat HEAD...${selected}\n\n${out}`, language: 'plaintext' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to compare: ${errMsg(e)}`); + void showGitError(`Failed to compare: ${errMsg(e)}`); } }), ); @@ -425,29 +436,35 @@ export function registerHistoryCommands( if (!repo) { return; } - const name = await vscode.window.showInputBox({ prompt: 'Tag name (e.g. v1.0.0)' }); + const name = await vscode.window.showInputBox({ + prompt: 'Tag name (e.g. v1.0.0)', + validateInput: (v) => validateRefName(v, 'tag'), + }); if (!name || !name.trim()) { return; } const commits = await repo.log({ maxEntries: 20 }); - const items = [ + // Separator 分段 HEAD 与历史提交(QuickPickItemKind.Separator,1.53+);description 带 hash 供模糊搜索。 + const items: Array<{ label: string; description?: string; target: string } | { label: string; kind: vscode.QuickPickItemKind.Separator }> = [ { label: 'HEAD', description: 'Current commit', target: 'HEAD' }, + { label: 'Recent Commits', kind: vscode.QuickPickItemKind.Separator }, ...commits.map((c) => ({ label: (c.message.split('\n', 1)[0] ?? c.hash).slice(0, 50), description: c.hash.slice(0, 7), target: c.hash, })), ]; - const pick = await vscode.window.showQuickPick(items, { placeHolder: 'Choose a commit to tag' }); - if (!pick) { + const picked = await vscode.window.showQuickPick(items, { placeHolder: 'Choose a commit to tag', matchOnDescription: true, matchOnDetail: true }); + if (!picked || !('target' in picked)) { return; } + const pick = picked; try { await service.execGit(['tag', name.trim(), pick.target]); branchesTree.refresh(); void vscode.window.showInformationMessage(`Created tag ${name.trim()} @ ${pick.target === 'HEAD' ? 'HEAD' : pick.target.slice(0, 7)}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create tag: ${errMsg(e)}`); + void showGitError(`Failed to create tag: ${errMsg(e)}`); } }), ); @@ -498,7 +515,7 @@ export function registerHistoryCommands( await repo.checkout(name); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Failed to checkout tag: ${errMsg(e)}`); + void showGitError(`Failed to checkout tag: ${errMsg(e)}`); } }), ); @@ -527,7 +544,7 @@ export function registerHistoryCommands( try { await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to open diff: ${errMsg(e)}`); + void showGitError(`Failed to open diff: ${errMsg(e)}`); } }), ); @@ -561,7 +578,7 @@ export function registerHistoryCommands( void vscode.window.showInformationMessage(`Reset (--${pick.label} ${hash.slice(0, 7)}) complete`); } catch (e) { if (!(await handleGitConflict(service, 'Reset'))) { - void vscode.window.showErrorMessage(`Failed to Reset: ${errMsg(e)}`); + void showGitError(`Failed to Reset: ${errMsg(e)}`); } } }), @@ -573,7 +590,11 @@ export function registerHistoryCommands( if (!repo || node?.kind !== 'commit') { return; } - const name = await vscode.window.showInputBox({ prompt: `Create and checkout a new branch from ${node.commit.hash.slice(0, 7)}`, placeHolder: 'New branch name' }); + const name = await vscode.window.showInputBox({ + prompt: `Create and checkout a new branch from ${node.commit.hash.slice(0, 7)}`, + placeHolder: 'New branch name', + validateInput: (v) => validateRefName(v, 'branch'), + }); if (!name || !name.trim()) { return; } @@ -582,7 +603,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Created and checked out ${name.trim()} @ ${node.commit.hash.slice(0, 7)}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${errMsg(e)}`); } }), ); @@ -592,7 +613,11 @@ export function registerHistoryCommands( if (node?.kind !== 'commit') { return; } - const name = await vscode.window.showInputBox({ prompt: `Create a new tag at ${node.commit.hash.slice(0, 7)}`, placeHolder: 'e.g. v1.0.0' }); + const name = await vscode.window.showInputBox({ + prompt: `Create a new tag at ${node.commit.hash.slice(0, 7)}`, + placeHolder: 'e.g. v1.0.0', + validateInput: (v) => validateRefName(v, 'tag'), + }); if (!name || !name.trim()) { return; } @@ -601,7 +626,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Created tag ${name.trim()} @ ${node.commit.hash.slice(0, 7)}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create tag: ${errMsg(e)}`); + void showGitError(`Failed to create tag: ${errMsg(e)}`); } }), ); @@ -619,7 +644,7 @@ export function registerHistoryCommands( }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to query: ${errMsg(e)}`); + void showGitError(`Failed to query: ${errMsg(e)}`); } }), ); diff --git a/src/adapter/misc-commands.ts b/src/adapter/misc-commands.ts index 7a04a61..480031d 100644 --- a/src/adapter/misc-commands.ts +++ b/src/adapter/misc-commands.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; +import { showGitError } from './notify'; import type { GitRepositoryService } from './git-repository-service'; import type { LogFilterControl } from './webview/log-webview'; import type { BranchesTreeProvider } from './tree/branches-tree'; @@ -42,7 +43,7 @@ export function registerMiscCommands( try { patch = await service.execGit(scope.args); } catch (e) { - void vscode.window.showErrorMessage(`Failed to generate patch: ${errMsg(e)}`); + void showGitError(`Failed to generate patch: ${errMsg(e)}`); return; } if (!patch.trim()) { @@ -60,7 +61,7 @@ export function registerMiscCommands( await fs.promises.writeFile(target.fsPath, patch, 'utf8'); void vscode.window.showInformationMessage(`Patch exported: ${target.fsPath}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to write patch: ${errMsg(e)}`); + void showGitError(`Failed to write patch: ${errMsg(e)}`); } }), ); @@ -97,7 +98,7 @@ export function registerMiscCommands( void vscode.window.showInformationMessage('Patch applied'); } catch (e) { if (!(await handleGitConflict(service, 'Apply Patch'))) { - void vscode.window.showErrorMessage(`Failed to apply patch: ${errMsg(e)}`); + void showGitError(`Failed to apply patch: ${errMsg(e)}`); } } }), @@ -114,7 +115,7 @@ export function registerMiscCommands( const doc = await vscode.workspace.openTextDocument({ content: `# git reflog (latest 200 entries)\n\n${out}`, language: 'plaintext' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to read reflog: ${errMsg(e)}`); + void showGitError(`Failed to read reflog: ${errMsg(e)}`); } }), ); diff --git a/src/adapter/notify.ts b/src/adapter/notify.ts new file mode 100644 index 0000000..6cdcbc3 --- /dev/null +++ b/src/adapter/notify.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; +import { showGitConsole } from '../infra/git-console'; + +/** + * git 操作失败通知 + "Show Output" 动作按钮(联动 Hyper Git Console 命令誊录通道)。 + * 与裸 showErrorMessage 的差别:给用户一条直达诊断信息的路径(含 execGit 透传的 stderr)。 + */ +export async function showGitError(message: string): Promise { + const choice = await vscode.window.showErrorMessage(message, 'Show Output'); + if (choice === 'Show Output') { + showGitConsole(); + } +} diff --git a/src/adapter/partial-commands.ts b/src/adapter/partial-commands.ts index 39134b1..dec37eb 100644 --- a/src/adapter/partial-commands.ts +++ b/src/adapter/partial-commands.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; +import { showGitError } from './notify'; import { buildPatch, parseUnifiedDiff } from '../engine/diff/hunk-parser'; import type { ChangelistRegistry } from './changelist-registry'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; @@ -81,7 +82,7 @@ export function registerPartialCommands(service: GitRepositoryService, registry: await applyToIndex(buildPatch(file, sel.indices), false); void vscode.window.showInformationMessage(`Staged ${sel.indices.length} hunk(s)`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to stage: ${errMsg(e)}`); + void showGitError(`Failed to stage: ${errMsg(e)}`); } }), ); @@ -110,7 +111,7 @@ export function registerPartialCommands(service: GitRepositoryService, registry: await applyToIndex(buildPatch(file, sel.indices), true); void vscode.window.showInformationMessage(`Unstaged ${sel.indices.length} hunk(s)`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to unstage: ${errMsg(e)}`); + void showGitError(`Failed to unstage: ${errMsg(e)}`); } }), ); @@ -146,7 +147,7 @@ export function registerPartialCommands(service: GitRepositoryService, registry: await applyToIndex(buildPatch(file, overlapping.map((o) => o.i)), false); void vscode.window.showInformationMessage('Staged hunk at cursor'); } catch (e) { - void vscode.window.showErrorMessage(`Failed to stage: ${errMsg(e)}`); + void showGitError(`Failed to stage: ${errMsg(e)}`); } }), ); @@ -188,7 +189,7 @@ export function registerPartialCommands(service: GitRepositoryService, registry: void vscode.window.showInformationMessage(`Assigned Hunk ${hunkIdx + 1} (${rel}) to "${pick.label}"`); } } catch (e) { - void vscode.window.showErrorMessage(`Failed to assign: ${errMsg(e)}`); + void showGitError(`Failed to assign: ${errMsg(e)}`); } }), ); diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index 8a7318f..0c31fb7 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import { showGitError } from './notify'; +import { runWithProgress } from './task-progress'; import { selectedBranchRefs } from './branch-selection'; import { handleGitConflict } from './conflict-ui'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; @@ -58,7 +60,7 @@ export function registerRemoteCommands( [ { label: 'Normal', description: 'Normal push', force: undefined as 0 | 1 | undefined }, { label: 'Force-with-lease', description: 'Safe force push (recommended)', force: 1 as const }, - { label: 'Force', description: '⚠ Force overwrite remote (dangerous)', force: 0 as const }, + { label: 'Force', description: '$(alert) Force overwrite remote (dangerous)', force: 0 as const }, ], { placeHolder: 'Push mode' }, ); @@ -70,16 +72,18 @@ export function registerRemoteCommands( return; } try { - await repo.push(remote, undefined, false, mode.force); - if (tags === 'Yes') { - await service.execGit(['push', remote, '--tags']); - } + await runWithProgress('Pushing…', async () => { + await repo.push(remote, undefined, false, mode.force); + if (tags === 'Yes') { + await service.execGit(['push', remote, '--tags']); + } + }); branchesTree.refresh(); logTree.refresh(); void vscode.window.showInformationMessage(`Pushed to ${remote} ${previewCount}`.trim()); } catch (e) { if (!(await handleGitConflict(service, 'Push'))) { - void vscode.window.showErrorMessage(`Push failed: ${errMsg(e)}`); + void showGitError(`Push failed: ${errMsg(e)}`); } } }), @@ -102,13 +106,15 @@ export function registerRemoteCommands( return; } try { - await service.execGit(pick.args); + await runWithProgress(`Updating project (${pick.args.join(' ')})…`, () => service.execGit(pick.args), { + location: vscode.ProgressLocation.Notification, + }); branchesTree.refresh(); logTree.refresh(); void vscode.window.showInformationMessage('Update Project complete'); } catch (e) { if (!(await handleGitConflict(service, 'Update'))) { - void vscode.window.showErrorMessage(`Update failed: ${errMsg(e)}`); + void showGitError(`Update failed: ${errMsg(e)}`); } } }), @@ -144,13 +150,13 @@ export function registerRemoteCommands( } } try { - await service.execGit(['merge', ...mode.args, ...msgArgs, target]); + await runWithProgress(`Merging ${target}…`, () => service.execGit(['merge', ...mode.args, ...msgArgs, target])); branchesTree.refresh(); logTree.refresh(); void vscode.window.showInformationMessage(`Merged ${target}`); } catch (e) { if (!(await handleGitConflict(service, 'Merge'))) { - void vscode.window.showErrorMessage(`Merge failed: ${errMsg(e)}`); + void showGitError(`Merge failed: ${errMsg(e)}`); } } }), @@ -195,7 +201,8 @@ export function registerRemoteCommands( protectedTargets.length > 0 ? `${detail}\n\nAutomatically skipped protected branches: ${truncateNames(protectedTargets.map((t) => t.shortName))}` : detail; - const choice = await vscode.window.showWarningMessage(fullDetail, { modal: true }, confirmLabel); + // 多行明细走 MessageOptions.detail(模态框次要文字载体),标题保持单行。 + const choice = await vscode.window.showWarningMessage('Delete remote branch(es)?', { modal: true, detail: fullDetail }, confirmLabel); if (choice !== confirmLabel) { return; } diff --git a/src/adapter/shelf.ts b/src/adapter/shelf.ts index 9af6046..8c9abea 100644 --- a/src/adapter/shelf.ts +++ b/src/adapter/shelf.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; +import { showGitError } from './notify'; import type { GitRepositoryService } from './git-repository-service'; import { handleGitConflict } from './conflict-ui'; import { mdTooltip, relativeDate } from './tree/tree-tooltip'; @@ -67,25 +68,33 @@ export class ShelfService { * 仅 rename(零删除)、目标已存在即跳过(零覆盖)、失败静默可重试(已移入保留,未移入下次续迁)。 * 多仓库历史混仓数据无法事后归因,归属当前活跃仓库(Known Limitation)。 */ - private migrateLegacyShelves(targetDir: string): void { + private async migrateLegacyShelves(targetDir: string): Promise { try { - if (fs.existsSync(targetDir)) { - return; // 本仓库已迁移/已使用(幂等出口) - } - if (!fs.existsSync(this.shelvesBase)) { - return; - } - const legacyFiles = fs.readdirSync(this.shelvesBase).filter((f) => f.endsWith('.json')); + await fs.promises.access(targetDir); + return; // 本仓库已迁移/已使用(幂等出口) + } catch { + /* 目标目录不存在 → 检查旧版平铺数据 */ + } + try { + await fs.promises.access(this.shelvesBase); + } catch { + return; // 基目录不存在(从未创建过 shelf):无旧数据可迁,静默退出(ENOENT 非失败,勿污染 Console) + } + try { + const legacyFiles = (await fs.promises.readdir(this.shelvesBase)).filter((f) => f.endsWith('.json')); if (legacyFiles.length === 0) { return; } - fs.mkdirSync(targetDir, { recursive: true }); + await fs.promises.mkdir(targetDir, { recursive: true }); for (const f of legacyFiles) { const dest = path.join(targetDir, f); - if (fs.existsSync(dest)) { + try { + await fs.promises.access(dest); continue; // 绝不覆盖 + } catch { + /* 目标不存在 → 迁移 */ } - fs.renameSync(path.join(this.shelvesBase, f), dest); + await fs.promises.rename(path.join(this.shelvesBase, f), dest); } logGit(['shelf:migrateLegacy'], targetDir); } catch (e) { @@ -107,21 +116,21 @@ export class ShelfService { if (!patch.trim()) { throw new Error('Selected files have no changes (or are untracked)'); } - this.migrateLegacyShelves(dir); - fs.mkdirSync(dir, { recursive: true }); + await this.migrateLegacyShelves(dir); + await fs.promises.mkdir(dir, { recursive: true }); const entry: ShelfEntry = { name, paths, timestamp, patch }; - fs.writeFileSync(path.join(dir, `${sanitize(name)}.json`), JSON.stringify(entry, null, 2)); + await fs.promises.writeFile(path.join(dir, `${sanitize(name)}.json`), JSON.stringify(entry, null, 2), 'utf8'); // 移除工作区改动(变更已保存在 patch) await this.service.execGit(['checkout', '--', ...paths]); } async unshelve(name: string, threeWay: boolean): Promise { - const entry = this.readEntry(name); + const entry = await this.readEntry(name); if (!entry) { throw new Error(`Shelf "${name}" does not exist`); } const tmp = path.join(os.tmpdir(), `hg-unshelve-${Date.now()}.patch`); - fs.writeFileSync(tmp, entry.patch); + await fs.promises.writeFile(tmp, entry.patch, 'utf8'); try { const args = ['apply']; if (threeWay) { @@ -130,64 +139,64 @@ export class ShelfService { args.push(tmp); await this.service.execGit(args); } finally { - try { - fs.unlinkSync(tmp); - } catch { + void fs.promises.unlink(tmp).catch(() => { /* ignore */ - } + }); } } async unshelveAndDrop(name: string, threeWay: boolean): Promise { await this.unshelve(name, threeWay); - this.drop(name); + await this.drop(name); } - drop(name: string): void { + async drop(name: string): Promise { const dir = this.currentDir(); if (!dir) { return; } const file = path.join(dir, `${sanitize(name)}.json`); - if (fs.existsSync(file)) { - fs.unlinkSync(file); + try { + await fs.promises.unlink(file); + } catch { + /* 不存在/占用:静默(幂等) */ } } - listShelves(): ShelfNode[] { + /** 异步枚举(fs.promises):此前同步 readdir/readFile 阻塞扩展宿主,getChildren 处 UI 请求路径。 */ + async listShelves(): Promise { const dir = this.currentDir(); if (!dir) { return []; } - this.migrateLegacyShelves(dir); - if (!fs.existsSync(dir)) { + await this.migrateLegacyShelves(dir); + let files: string[]; + try { + files = (await fs.promises.readdir(dir)).filter((f) => f.endsWith('.json')); + } catch { return []; } - return fs - .readdirSync(dir) - .filter((f) => f.endsWith('.json')) - .map((f) => { + const nodes = await Promise.all( + files.map(async (f) => { try { - const entry = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ShelfEntry; + const entry = JSON.parse(await fs.promises.readFile(path.join(dir, f), 'utf8')) as ShelfEntry; return { kind: 'shelf' as const, name: entry.name, paths: entry.paths, timestamp: entry.timestamp }; } catch { return null; } - }) - .filter((n): n is ShelfNode => n !== null); + }), + ); + return nodes.filter((n): n is ShelfNode => n !== null); } - private readEntry(name: string): ShelfEntry | null { + private async readEntry(name: string): Promise { const dir = this.currentDir(); if (!dir) { return null; } const file = path.join(dir, `${sanitize(name)}.json`); - if (!fs.existsSync(file)) { - return null; - } try { - return JSON.parse(fs.readFileSync(file, 'utf8')) as ShelfEntry; + return JSON.parse(await fs.promises.readFile(file, 'utf8')) as ShelfEntry; } catch { return null; } @@ -205,7 +214,7 @@ export class ShelfTreeProvider implements vscode.TreeDataProvider this._onDidChange.fire(undefined); } - getChildren(element?: ShelfTreeNode): ShelfTreeNode[] { + async getChildren(element?: ShelfTreeNode): Promise { if (!element) { return this.shelfService.listShelves(); } @@ -274,9 +283,11 @@ export function registerShelfCommands(service: GitRepositoryService, shelfServic try { await shelfService.shelve(name.trim(), picks.map((p) => p.label), new Date().toISOString()); shelfTree.refresh(); + // Shelf 视图默认隐藏:创建后聚焦引导定位(reveal 需要 provider 重建的元素实例,focus 更稳)。 + void vscode.commands.executeCommand('hyperGit.shelf.focus'); void vscode.window.showInformationMessage(`Shelved "${name.trim()}" (${picks.length} files)`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to shelve: ${errMsg(e)}`); + void showGitError(`Failed to shelve: ${errMsg(e)}`); } }), ); @@ -291,7 +302,7 @@ export function registerShelfCommands(service: GitRepositoryService, shelfServic shelfTree.refresh(); void vscode.window.showInformationMessage(`Unshelved "${node.name}"`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to unshelve: ${errMsg(e)}`); + void showGitError(`Failed to unshelve: ${errMsg(e)}`); } }), ); @@ -307,7 +318,7 @@ export function registerShelfCommands(service: GitRepositoryService, shelfServic void vscode.window.showInformationMessage(`Unshelved "${node.name}" (3-way)`); } catch (e) { if (!(await handleGitConflict(service, 'Unshelve'))) { - void vscode.window.showErrorMessage(`Failed to unshelve: ${errMsg(e)}`); + void showGitError(`Failed to unshelve: ${errMsg(e)}`); } } }), @@ -320,7 +331,7 @@ export function registerShelfCommands(service: GitRepositoryService, shelfServic } const ok = await vscode.window.showWarningMessage(`Delete shelf "${node.name}"?`, { modal: true }, 'Delete'); if (ok === 'Delete') { - shelfService.drop(node.name); + await shelfService.drop(node.name); shelfTree.refresh(); } }), diff --git a/src/adapter/stash-commands.ts b/src/adapter/stash-commands.ts index 5ba9930..fefdb42 100644 --- a/src/adapter/stash-commands.ts +++ b/src/adapter/stash-commands.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; +import { showGitError } from './notify'; +import { validateRefName } from '../engine/ref/ref-name'; import type { GitRepositoryService } from './git-repository-service'; -import type { StashNode, StashTreeProvider } from './tree/stash-tree'; +import type { StashEntryNode, StashNode, StashTreeProvider } from './tree/stash-tree'; import { handleGitConflict } from './conflict-ui'; /** @@ -30,7 +32,7 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: const doc = await vscode.workspace.openTextDocument({ content: patch, language: 'diff' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to show stash: ${errMsg(e)}`); + void showGitError(`Failed to show stash: ${errMsg(e)}`); } }), ); @@ -45,8 +47,10 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: try { await repo.createStash({ message: message && message.trim() ? message.trim() : undefined, includeUntracked: true }); stashTree.refresh(); + // Stash 视图默认隐藏:创建后聚焦引导定位。 + void vscode.commands.executeCommand('hyperGit.stash.focus'); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create stash: ${errMsg(e)}`); + void showGitError(`Failed to create stash: ${errMsg(e)}`); } }), ); @@ -62,7 +66,7 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: await repo.applyStash(index); stashTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Failed to apply stash: ${errMsg(e)}`); + void showGitError(`Failed to apply stash: ${errMsg(e)}`); } }), ); @@ -79,28 +83,45 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: stashTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Stash pop'))) { - void vscode.window.showErrorMessage(`Failed to pop stash: ${errMsg(e)}`); + void showGitError(`Failed to pop stash: ${errMsg(e)}`); } } }), ); subs.push( - vscode.commands.registerCommand('hyperGit.stashDrop', async (node?: StashNode) => { + vscode.commands.registerCommand('hyperGit.stashDrop', async (node?: StashNode, nodes?: StashNode[]) => { const repo = service.repo; if (!repo) { return; } - const index = node?.kind === 'stash' ? node.index : 0; - const choice = await vscode.window.showWarningMessage(`Drop stash@{${index}}?`, { modal: true }, 'Drop'); - if (choice === 'Drop') { + // 多选批量(canSelectMany):drop stash@{n} 会使更高序号前移,按 index 降序删除防位移错删。 + const targets = (nodes?.length ? nodes : node ? [node] : []) + .filter((n): n is StashEntryNode => n?.kind === 'stash') + .sort((a, b) => b.index - a.index); + if (targets.length === 0) { + return; + } + const label = + targets.length === 1 + ? `Drop stash@{${targets[0].index}}?` + : `Drop ${targets.length} stashes?`; + const choice = await vscode.window.showWarningMessage(label, { modal: true }, 'Drop'); + if (choice !== 'Drop') { + return; + } + const failures: string[] = []; + for (const t of targets) { try { - await repo.dropStash(index); - stashTree.refresh(); - } catch (e) { - void vscode.window.showErrorMessage(`Failed to drop stash: ${errMsg(e)}`); + await repo.dropStash(t.index); + } catch { + failures.push(`stash@{${t.index}}`); } } + stashTree.refresh(); + if (failures.length > 0) { + void vscode.window.showWarningMessage(`Dropped ${targets.length - failures.length} stash(es), ${failures.length} failed: ${failures.join(', ')}`); + } }), ); @@ -122,7 +143,7 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: stashTree.refresh(); void vscode.window.showInformationMessage('Stashed (index kept)'); } catch (e) { - void vscode.window.showErrorMessage(`Failed to stash: ${errMsg(e)}`); + void showGitError(`Failed to stash: ${errMsg(e)}`); } }), ); @@ -142,7 +163,7 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: stashTree.refresh(); void vscode.window.showInformationMessage('Cleared all stashes'); } catch (e) { - void vscode.window.showErrorMessage(`Failed to clear stashes: ${errMsg(e)}`); + void showGitError(`Failed to clear stashes: ${errMsg(e)}`); } }), ); @@ -176,11 +197,15 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: } index = pick.index; } catch (e) { - void vscode.window.showErrorMessage(`Failed to read stash list: ${errMsg(e)}`); + void showGitError(`Failed to read stash list: ${errMsg(e)}`); return; } } - const name = await vscode.window.showInputBox({ prompt: `Create and checkout a new branch from stash@{${index}}`, placeHolder: 'New branch name' }); + const name = await vscode.window.showInputBox({ + prompt: `Create and checkout a new branch from stash@{${index}}`, + placeHolder: 'New branch name', + validateInput: (v) => validateRefName(v, 'branch'), + }); if (!name || !name.trim()) { return; } @@ -189,7 +214,7 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: stashTree.refresh(); void vscode.window.showInformationMessage(`Created branch ${name.trim()} from stash@{${index}}`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${errMsg(e)}`); } }), ); diff --git a/src/adapter/task-progress.ts b/src/adapter/task-progress.ts new file mode 100644 index 0000000..9d594a8 --- /dev/null +++ b/src/adapter/task-progress.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode'; + +/** + * 长时 git 操作的进度反馈封装(对齐全内置 Git 视图行为)。 + * + * 默认 ProgressLocation.Window(标题栏非阻塞、不遮挡交互);重量级或可能长时间 + * 无响应的操作(updateProject、交互 rebase)以 Notification 位置显式告知。 + * cancellable 恒 false:execGit 无 kill 通道,真取消需先打通进程终止,延后实现。 + * 只应包裹用户交互(QuickPick/确认框)完成后的 git 执行段,避免进度条覆盖对话框。 + */ +export async function runWithProgress( + title: string, + task: (progress: vscode.Progress<{ message?: string; increment?: number }>) => Promise, + opts?: { location?: vscode.ProgressLocation }, +): Promise { + return vscode.window.withProgress({ location: opts?.location ?? vscode.ProgressLocation.Window, title, cancellable: false }, task); +} diff --git a/src/adapter/tree/stash-tree.ts b/src/adapter/tree/stash-tree.ts index f2045dc..467f6a5 100644 --- a/src/adapter/tree/stash-tree.ts +++ b/src/adapter/tree/stash-tree.ts @@ -22,9 +22,15 @@ export type StashNode = StashEntryNode; export class StashTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChange.event; + private viewMessageSink?: (message: string | undefined) => void; constructor(private readonly service: GitRepositoryService) {} + /** 视图内联消息通道(extension.ts 接线到 TreeView.message;加载失败不再静默成空树)。 */ + setViewMessageSink(sink: (message: string | undefined) => void): void { + this.viewMessageSink = sink; + } + refresh(): void { this._onDidChange.fire(undefined); } @@ -35,6 +41,7 @@ export class StashTreeProvider implements vscode.TreeDataProvider, vs return []; } try { + this.viewMessageSink?.(undefined); const out = await this.service.execGit(['stash', 'list', '--date=relative']); return out .split('\n') @@ -52,15 +59,16 @@ export class StashTreeProvider implements vscode.TreeDataProvider, vs } return { kind: 'stash', index, message: rest, date }; }); - } catch { + } catch (e) { + this.viewMessageSink?.(`Failed to list stashes: ${(e as Error).message}`); return []; } } getTreeItem(node: StashNode): vscode.TreeItem { + // label 不预截断:VS Code 树渲染自带省略,完整 subject 保留给 tooltip。 const subject = node.message.split(':').slice(1).join(':').trim() || node.message; - const trimmed = subject.length > 60 ? `${subject.slice(0, 60)}…` : subject; - const item = new vscode.TreeItem(trimmed, vscode.TreeItemCollapsibleState.None); + const item = new vscode.TreeItem(subject, vscode.TreeItemCollapsibleState.None); item.id = `stash:${node.index}`; item.description = node.date || `stash@{${node.index}}`; item.contextValue = 'hyperGit.stash'; diff --git a/src/adapter/tree/tree-tooltip.ts b/src/adapter/tree/tree-tooltip.ts index 5de255b..a0776c8 100644 --- a/src/adapter/tree/tree-tooltip.ts +++ b/src/adapter/tree/tree-tooltip.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { formatRelative } from '../../engine/log/format-time'; /** * 树 Tooltip 共享构造器(MarkdownString,单一下沉入口)。 @@ -7,9 +8,8 @@ import * as vscode from 'vscode'; * 取代各树 `\n` 拼接的纯文本 tooltip,统一粗体标签 + code 样式,深/浅主题自适应。 */ export function mdTooltip(rows: ReadonlyArray, opts?: { title?: string }): vscode.MarkdownString { + // 构造器第二参即 supportThemeIcons(无冗余属性赋值);isTrusted 缺省 false(安全默认)。 const md = new vscode.MarkdownString('', true); - md.isTrusted = false; - md.supportThemeIcons = true; if (opts?.title) { md.appendMarkdown(`**${escapeMd(opts.title)}**\n\n`); } @@ -29,31 +29,10 @@ function escapeMd(s: string): string { } /** - * ISO 时间 → 相对人可读描述("just now" / "N min ago" / "N hr ago" / "N days ago" / "Mon D")。 - * 用于 stash/shelf 行内描述;技术标识(stash@{n} / 原始 ISO)保留在 Tooltip。 - * 解析失败时原样返回,不阻断渲染。 + * ISO 时间 → 相对人可读描述,委托 engine/log/format-time.formatRelative(单一事实源, + * 措辞对齐官方 GRAPH)。用于 stash/shelf 行内描述;技术标识保留在 Tooltip。 + * 解析失败时原样返回(formatRelative 回空串),不阻断渲染。 */ export function relativeDate(iso: string): string { - const t = new Date(iso).getTime(); - if (Number.isNaN(t)) { - return iso; - } - const diff = Date.now() - t; - const min = 60_000; - const hr = 3_600_000; - const day = 86_400_000; - if (diff < min) { - return 'just now'; - } - if (diff < hr) { - return `${Math.floor(diff / min)} min ago`; - } - if (diff < day) { - return `${Math.floor(diff / hr)} hr ago`; - } - const days = Math.floor(diff / day); - if (days < 30) { - return `${days} day${days === 1 ? '' : 's'} ago`; - } - return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return formatRelative(iso) || iso; } diff --git a/src/adapter/tree/worktree-tree.ts b/src/adapter/tree/worktree-tree.ts index 778b773..5eb0c65 100644 --- a/src/adapter/tree/worktree-tree.ts +++ b/src/adapter/tree/worktree-tree.ts @@ -34,6 +34,12 @@ export class WorktreeTreeProvider implements vscode.TreeDataProvider | undefined; + private viewMessageSink?: (message: string | undefined) => void; + + /** 视图内联消息通道(extension.ts 接线到 TreeView.message;加载失败不再静默成空树)。 */ + setViewMessageSink(sink: (message: string | undefined) => void): void { + this.viewMessageSink = sink; + } constructor(private readonly service: GitRepositoryService) { this.disposables.push(service.onDidChange(() => this.refresh())); @@ -60,8 +66,10 @@ export class WorktreeTreeProvider implements vscode.TreeDataProvider this.toNode(p)); this.cache = nodes; + this.viewMessageSink?.(undefined); return nodes; - } catch { + } catch (e) { + this.viewMessageSink?.(`Failed to list worktrees: ${(e as Error).message}`); return []; } finally { this.inFlight = undefined; @@ -154,7 +162,10 @@ export class WorktreeTreeProvider implements vscode.TreeDataProvider(); + function isSameWorktreePath(a: string, b: string | undefined): boolean { if (!b) { return false; @@ -163,11 +174,17 @@ function isSameWorktreePath(a: string, b: string | undefined): boolean { } function normalizeWorktreePath(p: string): string { + const cached = normalizedPathCache.get(p); + if (cached !== undefined) { + return cached; + } let resolved = p; try { resolved = fs.realpathSync(p); } catch { // 路径不存在(prunable)或不可读 → 用原值(不阻断比较)。 } - return path.normalize(resolved).replace(/[\\/]+$/, ''); + const normalized = path.normalize(resolved).replace(/[\\/]+$/, ''); + normalizedPathCache.set(p, normalized); + return normalized; } diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index db5f974..16b4c70 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -1,4 +1,3 @@ -import * as crypto from 'crypto'; import * as path from 'path'; import * as vscode from 'vscode'; import { getDecoration } from '../../engine/scm-mapping/status-decoration'; @@ -14,7 +13,8 @@ import type { WebviewToHostMessage, } from '../../shared/protocol'; import type { CommitService } from '../commit/commit-service'; -import { getBaseStyles } from './shared-styles'; +import { getBaseStyles, ICON_CHEVRON_DOWN, ICON_ELLIPSIS } from './shared-styles'; +import { getNonce } from './nonce'; /** * Commit 提交窗口(WebviewView,自绘提交面板)。 @@ -93,14 +93,15 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { if (!change) { return; } + // QuickPick label 支持 $(codicon) 内联图标(与 changelist 菜单对齐)。 const actions: ReadonlyArray<{ readonly label: string; readonly command: string }> = [ - { label: 'Open Diff', command: 'hyperGit.openDiff' }, - { label: 'Move to Changelist…', command: 'hyperGit.moveChangelist' }, - { label: 'Show History', command: 'hyperGit.showHistory' }, - { label: 'Stage Hunks…', command: 'hyperGit.partialStage' }, - { label: 'Unstage Hunks…', command: 'hyperGit.partialUnstage' }, - { label: 'Add to .gitignore', command: 'hyperGit.ignorePath' }, - { label: 'Discard Changes', command: 'hyperGit.discardChanges' }, + { label: '$(diff) Open Diff', command: 'hyperGit.openDiff' }, + { label: '$(symbol-enum) Move to Changelist…', command: 'hyperGit.moveChangelist' }, + { label: '$(history) Show History', command: 'hyperGit.showHistory' }, + { label: '$(diff-added) Stage Hunks…', command: 'hyperGit.partialStage' }, + { label: '$(diff-removed) Unstage Hunks…', command: 'hyperGit.partialUnstage' }, + { label: '$(diff-ignored) Add to .gitignore', command: 'hyperGit.ignorePath' }, + { label: '$(discard) Discard Changes', command: 'hyperGit.discardChanges' }, ]; const pick = await vscode.window.showQuickPick(actions.slice(), { placeHolder: relativePath }); if (!pick) { @@ -157,6 +158,7 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { label: path.basename(c.relativePath), dir: path.dirname(c.relativePath), themeColor: decoration.themeColor, + letter: decoration.letter, }; } @@ -201,45 +203,54 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { @@ -247,8 +258,8 @@ details.advanced[open] summary { margin-bottom: 4px; }
Active Changelist: - - + +
-
+
@@ -275,12 +286,14 @@ details.advanced[open] summary { margin-bottom: 4px; } diff --git a/src/adapter/webview/merge-editor.ts b/src/adapter/webview/merge-editor.ts index 9eff82c..2a63580 100644 --- a/src/adapter/webview/merge-editor.ts +++ b/src/adapter/webview/merge-editor.ts @@ -1,11 +1,12 @@ -import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { showGitError } from '../notify'; import type { GitRepositoryService } from '../git-repository-service'; import { diff3, type MergeHunk } from '../../engine/merge/diff3'; import { parseConflictState } from '../../engine/git-state/conflict-detector'; -import { getBaseStyles } from './shared-styles'; +import { getBaseStyles, ICON_CHEVRON_DOWN } from './shared-styles'; +import { getNonce } from './nonce'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -34,7 +35,7 @@ export class MergeEditorWebview { service.execGit(['show', `:3:${filePath}`]), ]); } catch (e) { - void vscode.window.showErrorMessage(`Failed to read conflict stages (file may have no conflicts): ${errMsg(e)}`); + void showGitError(`Failed to read conflict stages (file may have no conflicts): ${errMsg(e)}`); return; } const hunks = diff3(splitLines(base), splitLines(ours), splitLines(theirs)); @@ -53,8 +54,19 @@ export class MergeEditorWebview { vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true }, ); + MergeEditorWebview.attachPanel(service, panel, filePath, hunks, conflicts); + } + + /** 面板装配:渲染 HTML + 消息接线(订阅随 panel dispose 释放)。 */ + private static attachPanel( + service: GitRepositoryService, + panel: vscode.WebviewPanel, + filePath: string, + hunks: readonly MergeHunk[], + conflicts: number, + ): void { panel.webview.html = MergeEditorWebview.renderHtml(filePath, hunks, conflicts); - panel.webview.onDidReceiveMessage(async (msg) => { + const msgSub = panel.webview.onDidReceiveMessage(async (msg) => { if (msg?.type === 'save' && typeof msg.content === 'string') { await MergeEditorWebview.saveResult(service, filePath, msg.content); panel.dispose(); @@ -62,6 +74,61 @@ export class MergeEditorWebview { panel.dispose(); } }); + panel.onDidDispose(() => msgSub.dispose()); + } + + /** + * 窗口 reload 后的面板恢复:按 state 中的 filePath 重拉三阶段冲突数据重渲染。 + * 已编辑未保存的 Result 不随 state 保留(known limitation);若冲突已在他处解决则直接关闭恢复的面板。 + */ + static registerSerializer(service: GitRepositoryService): vscode.Disposable { + return vscode.window.registerWebviewPanelSerializer('hyperGit.mergeEditor', { + async deserializeWebviewPanel(panel, state) { + const filePath = (state as { filePath?: unknown } | undefined)?.filePath; + if (typeof filePath !== 'string' || !service.repo) { + panel.webview.html = MergeEditorWebview.renderStaleHtml(); + return; + } + try { + const [base, ours, theirs] = await Promise.all([ + service.execGit(['show', `:1:${filePath}`]), + service.execGit(['show', `:2:${filePath}`]), + service.execGit(['show', `:3:${filePath}`]), + ]); + const hunks = diff3(splitLines(base), splitLines(ours), splitLines(theirs)); + const conflicts = hunks.filter((h) => h.kind === 'conflict').length; + if (conflicts === 0) { + // reload 前冲突已被解决:恢复无意义,直接关闭。 + void vscode.window.showInformationMessage(`"${filePath}" has no unresolved conflicts; the restored merge editor was closed.`); + panel.dispose(); + return; + } + MergeEditorWebview.attachPanel(service, panel, filePath, hunks, conflicts); + } catch { + panel.webview.html = MergeEditorWebview.renderStaleHtml(); + } + }, + }); + } + + /** 面板过期(无有效 state / 冲突数据已不可读)时的占位页。 */ + private static renderStaleHtml(): string { + return ` + + + + + + + +

Merge

+

This merge session is no longer available. Close this tab and re-open it from Resolve Conflicts… if needed.

+ +`; } private static async saveResult(service: GitRepositoryService, filePath: string, content: string): Promise { @@ -85,12 +152,12 @@ export class MergeEditorWebview { await service.execGit(['add', '--', filePath]); void vscode.window.showInformationMessage(`"${filePath}" saved and marked resolved`); } catch (e) { - void vscode.window.showErrorMessage(`Failed to save: ${errMsg(e)}`); + void showGitError(`Failed to save: ${errMsg(e)}`); } } private static renderHtml(filePath: string, hunks: readonly MergeHunk[], conflicts: number): string { - const nonce = crypto.randomBytes(16).toString('base64'); + const nonce = getNonce(); // JSON 注入 破出。 // 不可用 escapeHtml——其产出 " 在 `; @@ -311,7 +386,7 @@ export function registerMergeCommands(service: GitRepositoryService): vscode.Dis await service.execGit(['add', '--', file]); void vscode.window.showInformationMessage(`"${file}" resolved with ours`); } catch (e) { - void vscode.window.showErrorMessage(`Failed: ${errMsg(e)}`); + void showGitError(`Failed: ${errMsg(e)}`); } }), ); @@ -331,7 +406,7 @@ export function registerMergeCommands(service: GitRepositoryService): vscode.Dis await service.execGit(['add', '--', file]); void vscode.window.showInformationMessage(`"${file}" resolved with theirs`); } catch (e) { - void vscode.window.showErrorMessage(`Failed: ${errMsg(e)}`); + void showGitError(`Failed: ${errMsg(e)}`); } }), ); diff --git a/src/adapter/webview/nonce.ts b/src/adapter/webview/nonce.ts new file mode 100644 index 0000000..6ca575b --- /dev/null +++ b/src/adapter/webview/nonce.ts @@ -0,0 +1,9 @@ +import * as crypto from 'crypto'; + +/** + * CSP nonce(单一实现,替代各 webview 内联的 randomBytes 拼接)。 + * randomUUID 产 hex(无 base64 的 +/= 字符),对 CSP 属性值更稳。 + */ +export function getNonce(): string { + return crypto.randomUUID().replace(/-/g, ''); +} diff --git a/src/adapter/webview/rebase-webview.ts b/src/adapter/webview/rebase-webview.ts index c8f8566..3e1f7fa 100644 --- a/src/adapter/webview/rebase-webview.ts +++ b/src/adapter/webview/rebase-webview.ts @@ -3,16 +3,26 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; +import { showGitError } from '../notify'; +import { runWithProgress } from '../task-progress'; import type { GitRepositoryService } from '../git-repository-service'; import { handleGitConflict } from '../conflict-ui'; import { type RebaseTodoItem, isValidAction, serializeTodo } from '../../engine/rebase/todo'; import { getBaseStyles } from './shared-styles'; +import { getNonce } from './nonce'; interface RebaseCommit { readonly hash: string; readonly subject: string; } +/** 渲染行(初始 = 原始提交默认 pick;恢复 = 序列化保存的 action/subject/DOM 顺序)。 */ +interface RebaseRow { + readonly hash: string; + readonly subject: string; + readonly action?: string; +} + const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); /** @@ -53,16 +63,15 @@ export class RebaseWebview { void vscode.window.showWarningMessage('No Git repository found'); return; } - // 选择 base + // 选择 base:快捷项与提交列表用 Separator 分段;matchOnDescription 支持按 subject 模糊搜索。 const baseOptions = ['HEAD~5', 'HEAD~10', 'HEAD~20', 'HEAD~3', 'HEAD~2']; const commits = await repo.log({ maxEntries: 30 }); - const basePick = await vscode.window.showQuickPick( - [ - ...baseOptions.map((b) => ({ label: b, description: `Rebase from ${b}` })), - ...commits.slice(1).map((c) => ({ label: c.hash.slice(0, 7), description: (c.message.split('\n', 1)[0] ?? '').slice(0, 60) })), - ], - { placeHolder: 'Select rebase base' }, - ); + const baseItems: Array<{ label: string; description?: string } | { label: string; kind: vscode.QuickPickItemKind.Separator }> = [ + ...baseOptions.map((b) => ({ label: b, description: `Rebase from ${b}` })), + { label: 'Recent Commits', kind: vscode.QuickPickItemKind.Separator }, + ...commits.slice(1).map((c) => ({ label: c.hash.slice(0, 7), description: (c.message.split('\n', 1)[0] ?? '').slice(0, 60) })), + ]; + const basePick = await vscode.window.showQuickPick(baseItems, { placeHolder: 'Select rebase base', matchOnDescription: true }); if (!basePick) { return; } @@ -81,7 +90,7 @@ export class RebaseWebview { return { hash, subject: subj.join('|') }; }); } catch (e) { - void vscode.window.showErrorMessage(`Failed to load commits: ${errMsg(e)}`); + void showGitError(`Failed to load commits: ${errMsg(e)}`); return; } if (rebaseCommits.length === 0) { @@ -93,9 +102,18 @@ export class RebaseWebview { enableScripts: true, retainContextWhenHidden: true, }); - panel.webview.html = RebaseWebview.renderHtml(rebaseCommits); + RebaseWebview.attachPanel(service, panel, base, rebaseCommits.map((c) => ({ hash: c.hash, subject: c.subject }))); + } - panel.webview.onDidReceiveMessage(async (msg) => { + /** 面板装配:渲染 HTML + 消息接线(订阅随 panel dispose 释放,避免悬挂监听)。 */ + private static attachPanel( + service: GitRepositoryService, + panel: vscode.WebviewPanel, + base: string, + rows: readonly RebaseRow[], + ): void { + panel.webview.html = RebaseWebview.renderHtml(base, rows); + const msgSub = panel.webview.onDidReceiveMessage(async (msg) => { if (msg.type === 'rebase') { await RebaseWebview.executeRebase( service, @@ -107,6 +125,53 @@ export class RebaseWebview { panel.dispose(); } }); + panel.onDidDispose(() => msgSub.dispose()); + } + + /** + * 窗口 reload 后的面板恢复:webview state(base + 用户编辑后的 actions,按 DOM 顺序)重渲染 todo 列表。 + * 恢复的 panel 是纯 todo 编辑器——若 reload 前 rebase 已执行完毕,重放需用户再次确认 Start Rebase(可 Cancel)。 + */ + static registerSerializer(service: GitRepositoryService): vscode.Disposable { + return vscode.window.registerWebviewPanelSerializer('hyperGit.rebase', { + async deserializeWebviewPanel(panel, state) { + const actions = (state as { base?: unknown; actions?: unknown } | undefined)?.actions; + const base = (state as { base?: unknown } | undefined)?.base; + if (typeof base !== 'string' || !Array.isArray(actions) || actions.length === 0) { + panel.webview.html = RebaseWebview.renderStaleHtml(); + return; + } + const rows: RebaseRow[] = []; + for (const a of actions) { + if (typeof a?.hash !== 'string' || typeof a?.subject !== 'string') { + panel.webview.html = RebaseWebview.renderStaleHtml(); + return; + } + rows.push({ hash: a.hash, subject: a.subject, action: typeof a.action === 'string' ? a.action : undefined }); + } + RebaseWebview.attachPanel(service, panel, base, rows); + }, + }); + } + + /** 面板过期(无有效 state / 数据不完整)时的占位页。 */ + private static renderStaleHtml(): string { + return ` + + + + + + + +

Interactive Rebase

+

This rebase session is no longer available. Close this tab and run Interactive Rebase… to start a new one.

+ +`; } private static async executeRebase( @@ -143,6 +208,9 @@ export class RebaseWebview { tmpState = path.join(os.tmpdir(), `hg-reword-state-${tag}.json`); fs.writeFileSync(tmpEditor, REWORD_EDITOR_JS); fs.writeFileSync(tmpState, JSON.stringify({ counter: 0, subjects: rewordSubjects })); + // 扩展宿主内 process.execPath 是 Electron 二进制而非独立 node:必须以 node 模式运行 helper + // (否则 git 每次调用 GIT_EDITOR 会拉起新的编辑器进程)。 + env.ELECTRON_RUN_AS_NODE = '1'; env.GIT_EDITOR = `"${process.execPath}" "${tmpEditor}"`; env.HYPERGIT_REWORD_STATE = tmpState; } else { @@ -150,7 +218,12 @@ export class RebaseWebview { } try { - await service.execGit(['rebase', '-i', base], { env }); + // 交互 rebase 可能耗时较长且无中间输出:Notification 进度显式告知(避免 UI 疑似冻结)。 + await runWithProgress( + `Rebasing onto ${base}…`, + () => service.execGit(['rebase', '-i', base], { env }), + { location: vscode.ProgressLocation.Notification }, + ); // rebase 可能因 edit / squash 暂停(exit 0 但 rebase-merge 仍在):检测并提示 const gitDir = (await service.execGit(['rev-parse', '--absolute-git-dir'])).trim(); if (fs.existsSync(path.join(gitDir, 'rebase-merge'))) { @@ -163,7 +236,7 @@ export class RebaseWebview { } } catch (e) { if (!(await handleGitConflict(service, 'Rebase'))) { - void vscode.window.showErrorMessage(`Rebase failed: ${errMsg(e)}`); + void showGitError(`Rebase failed: ${errMsg(e)}`); } } finally { for (const f of [tmpTodo, tmpEditor, tmpState]) { @@ -179,24 +252,23 @@ export class RebaseWebview { } } - private static renderHtml(commits: RebaseCommit[]): string { - const nonce = crypto.randomBytes(16).toString('base64'); - const rows = commits - .map( - (c) => ` + private static renderHtml(base: string, rows: readonly RebaseRow[]): string { + const nonce = getNonce(); + // base 注入 破出)。 + const baseJson = JSON.stringify(base).replace(/ { + const action = isValidAction(r.action ?? 'pick') ? (r.action ?? 'pick') : 'pick'; + const options = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop'] + .map((a) => ``) + .join(''); + return ` - -${escapeHtml(c.hash.slice(0, 7))} - -`, - ) + +${escapeHtml(r.hash.slice(0, 7))} + +`; + }) .join('\n'); return ` @@ -207,31 +279,31 @@ export class RebaseWebview { ${getBaseStyles()} body { margin: 0; padding: 12px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); font-size: var(--vscode-font-size); background: var(--vscode-editor-background); } h3 { margin: 0 0 4px; font-weight: 600; } -.legend { margin: 0 0 8px; font-size: 11px; color: var(--vscode-descriptionForeground); line-height: 1.6; } +.legend { margin: 0 0 8px; font-size: calc(var(--vscode-font-size) - 2px); color: var(--vscode-descriptionForeground); line-height: 1.6; } .legend code { font-family: var(--vscode-editor-font-family); color: var(--vscode-textPreformat-foreground, var(--vscode-foreground)); } -.summary { margin: 0 0 10px; font-size: 11px; color: var(--vscode-descriptionForeground); font-family: var(--vscode-editor-font-family); } +.summary { margin: 0 0 10px; font-size: calc(var(--vscode-font-size) - 2px); color: var(--vscode-descriptionForeground); font-family: var(--vscode-editor-font-family); } table { width: 100%; border-collapse: collapse; } th, td { padding: 4px 6px; border-bottom: 1px solid var(--vscode-editorWidget-border, rgba(128,128,128,.2)); text-align: left; vertical-align: middle; } -th { font-weight: 600; font-size: 12px; color: var(--vscode-descriptionForeground); } +th { font-weight: 600; font-size: calc(var(--vscode-font-size) - 1px); color: var(--vscode-descriptionForeground); } td.drag { color: var(--vscode-descriptionForeground); cursor: grab; user-select: none; width: 18px; } td.drag .grip { display: inline-block; pointer-events: none; } -td.hash { color: var(--vscode-descriptionForeground); font-family: var(--vscode-editor-font-family); font-size: 12px; width: 70px; } +td.hash { color: var(--vscode-descriptionForeground); font-family: var(--vscode-editor-font-family); font-size: calc(var(--vscode-font-size) - 1px); width: 70px; } tr { background: transparent; } tr.action-drop { opacity: 0.5; } tr.action-drop input.subject { text-decoration: line-through; } tr.action-squash, tr.action-fixup { background: var(--vscode-editor-inactiveSelectionBackground, rgba(128,128,128,.12)); } tr.dragging { opacity: 0.4; } tr.drop-target { border-top: 2px solid var(--vscode-focusBorder, #007fd4); } -select, input.subject { background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); padding: 2px 4px; font-size: 12px; font-family: var(--vscode-font-family); } +input.subject { background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); padding: 2px 4px; font-size: calc(var(--vscode-font-size) - 1px); font-family: var(--vscode-font-family); } /* select 视觉走共享 .hg-select(dropdown token) */ select { width: 92px; } input.subject { width: 100%; } input.subject:disabled { color: var(--vscode-descriptionForeground); opacity: 0.85; } input.subject:not(:disabled) { border-color: var(--vscode-focusBorder, #007fd4); } .row-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; } .confirm-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); align-items: center; justify-content: center; z-index: 100; } -.confirm-box { background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-editorWidget-border, rgba(128,128,128,.4)); border-radius: 6px; padding: 16px 20px; max-width: 440px; box-shadow: 0 4px 16px rgba(0,0,0,.4); } -.confirm-box .confirm-count { font-size: 13px; margin-bottom: 6px; } -.confirm-box .confirm-summary { font-family: var(--vscode-editor-font-family); font-size: 11px; color: var(--vscode-descriptionForeground); margin-bottom: 14px; } +.confirm-box { background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-editorWidget-border, rgba(128,128,128,.4)); border-radius: 6px; padding: 16px 20px; max-width: 440px; box-shadow: 0 4px 16px var(--vscode-widget-shadow, rgba(0,0,0,.4)); } +.confirm-box .confirm-count { font-size: var(--vscode-font-size); margin-bottom: 6px; } +.confirm-box .confirm-summary { font-family: var(--vscode-editor-font-family); font-size: calc(var(--vscode-font-size) - 2px); color: var(--vscode-descriptionForeground); margin-bottom: 14px; } .confirm-actions { display: flex; gap: 8px; justify-content: flex-end; } @@ -240,7 +312,7 @@ input.subject:not(:disabled) { border-color: var(--vscode-focusBorder, #007fd4);

reword — edit the message inline  ·  edit/squash — pauses rebase (continue in terminal)  ·  drop — remove the commit  ·  drag the handle to reorder.

-${rows}
ActionHashSubject
+${trs}
@@ -257,6 +329,9 @@ input.subject:not(:disabled) { border-color: var(--vscode-focusBorder, #007fd4);
diff --git a/src/adapter/webview/shared-styles.ts b/src/adapter/webview/shared-styles.ts index aec77dc..d855553 100644 --- a/src/adapter/webview/shared-styles.ts +++ b/src/adapter/webview/shared-styles.ts @@ -3,33 +3,52 @@ * * 4 个自绘 Webview(Commit / Log / Merge / Rebase)共享同一套: * - 设计 Token(spacing/radius,对齐 VS Code 语义); - * - 基础组件类(`.hg-btn` / `.hg-btn--secondary` / `.hg-btn--sm` / `.hg-input` / `.hg-row`) + * - 基础组件类(`.hg-btn` / `.hg-btn--secondary` / `.hg-btn--sm` / `.hg-input` / `.hg-select`) * 统一交互态(hover / active / focus-visible / disabled),消除各 Webview 各自硬编码导致的 - * 「按钮无 hover」「`:last-child` 脆弱选择器」「无 focus ring」等熵增。 + * 「按钮无 hover」「`:last-child` 脆弱选择器」「无 focus ring」等熵增; + * - 全局控件基线:滚动条(`--vscode-scrollbarSlider-*`)、checkbox(accent-color)、 + * forced-colors(高对比度/Windows 高对比模式回退系统配色)。 * * 设计原则:纯字符串、零 vscode 依赖(可单测);每个 Webview 在 ` + +${this.viewName}

The built-in Git extension is unavailable, so ${this.viewName} cannot load. Enable the Git extension and reload the window.

+`; + } +} + /** * 扩展入口。仅做装配(DI 注册),业务逻辑下沉到 engine/adapter 层。 * 返回 `{ service }` 供集成测试程序化驱动仓库切换(vscode.git 同款导出模式)。 @@ -53,6 +68,8 @@ export async function activate( context: vscode.ExtensionContext, ): Promise<{ service: GitRepositoryService } | void> { const logger = createLogger(); + // LogOutputChannel 随扩展释放(懒创建,无日志的会话零开销)。 + context.subscriptions.push(logger, new vscode.Disposable(disposeGitConsole)); logger.info('Hyper Git activated'); const llm = new NullLlmProvider(); @@ -68,7 +85,16 @@ export async function activate( if (!api) { logger.warn('vscode.git API 不可用,视图保持空状态'); const empty = new EmptyTreeProvider(); - context.subscriptions.push(vscode.window.registerTreeDataProvider('hyperGit.worktrees', empty)); + context.subscriptions.push( + // 全部声明的树视图注册空 provider:未注册的视图显示 "no tree view registered" 报错而非 viewsWelcome。 + vscode.window.registerTreeDataProvider('hyperGit.branches', empty), + vscode.window.registerTreeDataProvider('hyperGit.stash', empty), + vscode.window.registerTreeDataProvider('hyperGit.worktrees', empty), + vscode.window.createTreeView('hyperGit.shelf', { treeDataProvider: empty }), + // webview 视图渲染静态占位说明页。 + vscode.window.registerWebviewViewProvider('hyperGit.commit', new UnavailableViewProvider('Commit')), + vscode.window.registerWebviewViewProvider('hyperGit.log', new UnavailableViewProvider('Graph')), + ); return; } @@ -88,7 +114,7 @@ export async function activate( conflict: new NullConflictResolver(), }); const commitView = new CommitWebviewProvider(service, registry, commit); - const logTree = new LogWebviewProvider(service, ciService); + const logTree = new LogWebviewProvider(service, ciService, context.workspaceState); const branchesTree = new BranchesTreeProvider( service, favorites, @@ -96,10 +122,11 @@ export async function activate( service.repoRoot ?? workspaceRoot, ); // Branches 视图启用多选(canSelectMany 仅 createTreeView 支持,registerTreeDataProvider 不支持); - // 多选后批量操作(删除分支/标签、复制引用、收藏)作用于整个选区。 + // 多选后批量操作(删除分支/标签、复制引用、收藏)作用于整个选区。showCollapseAll 供前缀分组树一键折叠。 const branchesView = vscode.window.createTreeView('hyperGit.branches', { treeDataProvider: branchesTree, canSelectMany: true, + showCollapseAll: true, }); const stashTree = new StashTreeProvider(service); const worktreeTree = new WorktreeTreeProvider(service); @@ -115,6 +142,22 @@ export async function activate( const badgeView = vscode.window.createTreeView('hyperGit.changesBadge', { treeDataProvider: new EmptyTreeProvider(), }); + // Stash/Worktrees 统一 createTreeView(原 registerTreeDataProvider 拿不到 TreeView 句柄): + // canSelectMany 启用批量 Drop/Remove;provider 加载失败经 TreeView.message 以视图内联错误呈现。 + const stashView = vscode.window.createTreeView('hyperGit.stash', { + treeDataProvider: stashTree, + canSelectMany: true, + }); + const worktreesView = vscode.window.createTreeView('hyperGit.worktrees', { + treeDataProvider: worktreeTree, + canSelectMany: true, + }); + stashTree.setViewMessageSink((message) => { + stashView.message = message; + }); + worktreeTree.setViewMessageSink((message) => { + worktreesView.message = message; + }); const focusCommitView = (): void => { void vscode.commands.executeCommand('hyperGit.commit.focus'); }; @@ -159,13 +202,14 @@ export async function activate( worktreeTree, shelfTree, blame, + inlineLens, branchesView, badgeView, vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), vscode.window.registerWebviewViewProvider(LogWebviewProvider.viewType, logTree), - vscode.window.registerTreeDataProvider('hyperGit.stash', stashTree), + stashView, vscode.window.createTreeView('hyperGit.shelf', { treeDataProvider: shelfTree }), - vscode.window.registerTreeDataProvider('hyperGit.worktrees', worktreeTree), + worktreesView, ...registerChangesCommands(service, registry), ...registerHistoryCommands(service, logTree, branchesTree, favorites), ...registerGitCliCommands(service, branchesTree, logTree), @@ -173,6 +217,9 @@ export async function activate( ...registerAdvancedCommands(service, branchesTree), ...registerRemoteCommands(service, branchesTree, logTree), ...registerMergeCommands(service), + // 窗口 reload 后 rebase/merge 面板恢复(retainContextWhenHidden 仅保活不跨 reload,serializer 补齐)。 + RebaseWebview.registerSerializer(service), + MergeEditorWebview.registerSerializer(service), ...registerMiscCommands(service, branchesTree, logTree), ...registerClaudeCommands(), registerRepositorySelectionCommand(service), @@ -185,6 +232,12 @@ export async function activate( vscode.commands.registerCommand('hyperGit.commit', focusCommitView), vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), vscode.commands.registerCommand('hyperGit.ci.signIn', () => ciService.signIn()), + // Graph 标题栏视图级控件(原 webview 工具栏上移):Scope 下拉子菜单 + Changed Files List/Tree 互斥图标。 + vscode.commands.registerCommand('hyperGit.log.scopeAll', () => logTree.setScope('all')), + vscode.commands.registerCommand('hyperGit.log.scopeCurrent', () => logTree.setScope('current')), + vscode.commands.registerCommand('hyperGit.log.scopeCheckpointer', () => logTree.setScope('checkpointer')), + vscode.commands.registerCommand('hyperGit.log.detailTree', () => logTree.setDetailMode('tree')), + vscode.commands.registerCommand('hyperGit.log.detailFlat', () => logTree.setDetailMode('flat')), vscode.commands.registerCommand('hyperGit.showConsole', () => showGitConsole()), vscode.commands.registerCommand('hyperGit.startRebase', () => RebaseWebview.open(service)), vscode.languages.registerCodeLensProvider({ scheme: 'file' }, inlineLens), @@ -197,6 +250,14 @@ export async function activate( const updateBadge = (): void => { const n = service.getChangeCount(); badgeView.badge = n > 0 ? { value: n, tooltip: `${n} uncommitted change(s)` } : undefined; + // 冲突存在性 context key:acceptOurs/acceptTheirs 等命令在 commandPalette 的显隐依据(见 package.json)。 + // 事实源用 vscode.git 的 mergeChanges——7 种 unmerged 状态只进该数组,index/workingTree 变更永不承载 + // 冲突(getChanges 的 status 映射因此恒判不出冲突,曾致 context key 恒 false、palette 命令永久隐藏)。 + void vscode.commands.executeCommand( + 'setContext', + 'hyperGit.hasConflicts', + (service.repo?.state.mergeChanges.length ?? 0) > 0, + ); }; // 角标走独立快路径(~40ms 微防抖):即便视图未 resolve / Panel 未激活也近实时更新计数(Panel 展开即呈现), diff --git a/src/infra/git-console.ts b/src/infra/git-console.ts index 1020b01..021db71 100644 --- a/src/infra/git-console.ts +++ b/src/infra/git-console.ts @@ -2,13 +2,14 @@ import * as vscode from 'vscode'; /** * Hyper Git Console:记录所有经 execGit 执行的 git 命令及其输出。 - * 复用单一 OutputChannel(懒构造)。 + * 复用单一 LogOutputChannel(懒构造;{log:true} 使其纳入统一日志视图,appendLine + * 保持 `$ git …` 誊录形态不加分级别前缀,命令回显可读性优先)。 */ -let channel: vscode.OutputChannel | undefined; +let channel: vscode.LogOutputChannel | undefined; -function getChannel(): vscode.OutputChannel { +function getChannel(): vscode.LogOutputChannel { if (!channel) { - channel = vscode.window.createOutputChannel('Hyper Git Console'); + channel = vscode.window.createOutputChannel('Hyper Git Console', { log: true }); } return channel; } @@ -29,3 +30,9 @@ export function logGit(args: readonly string[], output?: string, error?: string) export function showGitConsole(): void { getChannel().show(true); } + +/** 释放 channel(随扩展 deactivate;extension.ts 订阅调用)。 */ +export function disposeGitConsole(): void { + channel?.dispose(); + channel = undefined; +} diff --git a/src/infra/logger.ts b/src/infra/logger.ts index 998db27..99ef065 100644 --- a/src/infra/logger.ts +++ b/src/infra/logger.ts @@ -1,19 +1,31 @@ import * as vscode from 'vscode'; -export interface Logger { +export interface Logger extends vscode.Disposable { info(message: string): void; warn(message: string): void; error(message: string, err?: unknown): void; show(): void; } -/** 创建基于 OutputChannel 的日志器。 */ +/** + * 创建基于 LogOutputChannel 的日志器(1.74+:自动时间戳与级别、统一日志视图可过滤, + * 取代手写 [info]/[warn] 前缀)。懒创建:首次写入才建 channel,未产生日志的会话零开销。 + */ export function createLogger(name = 'Hyper Git'): Logger { - const channel = vscode.window.createOutputChannel(name); + let channel: vscode.LogOutputChannel | undefined; + const getChannel = (): vscode.LogOutputChannel => + (channel ??= vscode.window.createOutputChannel(name, { log: true })); return { - info: (message) => channel.appendLine(`[info] ${message}`), - warn: (message) => channel.appendLine(`[warn] ${message}`), - error: (message, err) => channel.appendLine(`[error] ${message}${err !== undefined ? `: ${String(err)}` : ''}`), - show: () => channel.show(), + info: (message) => getChannel().info(message), + warn: (message) => getChannel().warn(message), + error: (message, err) => { + if (err === undefined) { + getChannel().error(message); + } else { + getChannel().error(message, err instanceof Error ? err.message : err); + } + }, + show: () => getChannel().show(true), + dispose: () => channel?.dispose(), }; } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ab892f2..35bed16 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -20,6 +20,7 @@ export interface CommitFileItem { readonly label: string; // basename readonly dir: string; // dirname readonly themeColor: string; // gitDecoration.* 主题色 id → webview 用 var(--vscode-...) + readonly letter: string; // 状态字母标记(M/A/U/R/D/C/!/I,对齐官方 SCM 字母角标;webview 渲染替代色点) } /** @@ -143,7 +144,7 @@ export interface CommitDetailStat { } /** - * Commit 详情面板视图模型(编辑器区 WebviewPanel 显示,对齐官方 Source Control Graph)。 + * Commit 详情面板视图模型(Graph webview 右侧详情面板下半区渲染,对齐官方 Source Control Graph)。 * host 侧一次备齐:基础字段(git show)+ 相对/绝对时间(预格式化)+ 变更统计 + 可选 GitHub 提交页 URL。 */ export interface CommitDetailVM { @@ -177,8 +178,10 @@ export interface LogGraphState { readonly maxLanes: number; readonly hasMore: boolean; readonly scope: LogScope; + /** 变更文件展示模式(host 为事实源,标题栏 List/Tree 图标切换;webview 据此渲染)。 */ + readonly dmode: 'flat' | 'tree'; readonly repoRoot: string; - /** 工作区含多个 git 仓库时为 true:仓库名按钮呈现可切换态(缺省视为 false,向后兼容)。 */ + /** 工作区含多个 git 仓库时为 true:标题栏「切换仓库」图标按钮显隐(缺省视为 false,向后兼容)。 */ readonly multiRepo?: boolean; } @@ -199,7 +202,7 @@ export type LogCommitOp = export interface CiMetaVM { /** 远程为 GitHub 且功能启用时为 true;否则整列隐藏、不发起任何请求。 */ readonly available: boolean; - /** 远程是 GitHub 但尚未授权:webview 显示「登录 GitHub 查看 CI」提示。 */ + /** 远程是 GitHub 但尚未授权:标题栏显示「登录 GitHub」图标按钮(授权完成自动隐藏)。 */ readonly needsSignIn: boolean; /** 软错误(限流/网络)摘要,供 webview 给出一次性提示;为空表示正常。 */ readonly error?: string; @@ -225,7 +228,12 @@ export type LogHostToWebviewMessage = | { readonly type: 'log/error'; readonly payload: { readonly message: string } } | { readonly type: 'log/ciMeta'; readonly payload: CiMetaVM } | { readonly type: 'log/ciData'; readonly payload: { readonly map: Readonly> } } - | { readonly type: 'log/commitDetail'; readonly payload: { readonly vm: CommitDetailVM | null } }; + | { + /** forHash:请求时的选中 hash。失败回包 vm=null 无 hash 可比对,webview 据此丢弃过期响应。 */ + readonly type: 'log/commitDetail'; + readonly payload: { readonly forHash: string; readonly vm: CommitDetailVM | null }; + } + | { readonly type: 'log/detailMode'; readonly payload: { readonly mode: 'flat' | 'tree' } }; /** Webview → Host(Log Graph)。 */ export type LogWebviewToHostMessage = @@ -234,21 +242,6 @@ export type LogWebviewToHostMessage = | { readonly type: 'log/loadMore'; readonly payload: { readonly cursor: number } } | { readonly type: 'log/selectCommit'; readonly payload: { readonly hash: string } } | { readonly type: 'log/commitAction'; readonly payload: { readonly op: LogCommitOp; readonly hash: string } } - | { readonly type: 'log/setScope'; readonly payload: { readonly scope: LogScope } } | { readonly type: 'log/openFile'; readonly payload: { readonly hash: string; readonly path: string; readonly status: string; readonly oldPath?: string } } | { readonly type: 'log/requestCi'; readonly payload: { readonly hashes: readonly string[] } } - | { readonly type: 'log/ciSignIn' } - | { readonly type: 'log/openExternal'; readonly payload: { readonly url: string } } - /** 悬停提交行 → 请求在右侧编辑器区打开该提交的详情面板。 */ - | { readonly type: 'log/showCommitDetail'; readonly payload: { readonly hash: string } } - /** 工具栏仓库名按钮 → 打开原生仓库选择(host 复用 hyperGit.selectRepository 命令,issue #107)。 */ - | { readonly type: 'log/selectRepo' }; - -/** Host → Commit 详情面板(编辑器区 WebviewPanel)。 */ -export type CommitDetailHostToWebviewMessage = - /** 详情数据到达(null = 取数失败/坏 hash,面板显示空态)。 */ - | { readonly type: 'commitDetail/data'; readonly payload: CommitDetailVM | null }; - -/** Commit 详情面板 → Host。 */ -export type CommitDetailWebviewToHostMessage = - | { readonly type: 'commitDetail/openExternal'; readonly payload: { readonly url: string } }; + | { readonly type: 'log/openExternal'; readonly payload: { readonly url: string } }; diff --git a/tests/unit/menus-guard.test.ts b/tests/unit/menus-guard.test.ts new file mode 100644 index 0000000..b51d1fa --- /dev/null +++ b/tests/unit/menus-guard.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * 清单守护测试:锁定 package.json 贡献点中三类易回归的规范。 + * 1. `viewItem =~` 正则必须 ^…$ 锚定——未锚定正则会误匹配包含子串的 contextValue + * (如 `hyperGit.branchFolder` 命中 `/hyperGit.branch|…/`,文件夹行出现分支菜单)。 + * 2. 全部 contributes.settings 必须声明 scope——未声明的设置在 User/Workspace 层级语义不明。 + * 3. 可见视图必须声明 contextualTitle——视图拖出容器为独立面板时标题不退化为视图名。 + */ + +interface PackageJson { + contributes: { + menus: Record>; + views: Record>; + configuration: { properties: Record }; + }; +} + +const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')) as PackageJson; + +function allWhenClauses(): string[] { + const clauses: string[] = []; + for (const entries of Object.values(pkg.contributes.menus)) { + for (const entry of entries) { + if (entry.when) { + clauses.push(entry.when); + } + } + } + return clauses; +} + +describe('menus-guard(清单守护)', () => { + it('所有 viewItem =~ 正则必须 ^…$ 锚定', () => { + const offenders: string[] = []; + for (const when of allWhenClauses()) { + const match = when.match(/viewItem =~ \/([^/]+)\//); + if (match && !match[1].startsWith('^')) { + offenders.push(when); + } + if (match && !match[1].endsWith('$')) { + offenders.push(when); + } + } + expect(offenders, `未锚定的 viewItem 正则: ${offenders.join(' | ')}`).toEqual([]); + }); + + it('全部设置项必须声明 scope', () => { + const missing = Object.keys(pkg.contributes.configuration.properties).filter( + (key) => !pkg.contributes.configuration.properties[key].scope, + ); + expect(missing, `缺 scope 的设置: ${missing.join(', ')}`).toEqual([]); + }); + + it('可见视图必须声明 contextualTitle', () => { + const missing: string[] = []; + for (const views of Object.values(pkg.contributes.views)) { + for (const view of views) { + // when:false 的隐藏承载视图(changesBadge)豁免。 + if (view.when === 'false') { + continue; + } + if (!view.contextualTitle) { + missing.push(view.id); + } + } + } + expect(missing, `缺 contextualTitle 的视图: ${missing.join(', ')}`).toEqual([]); + }); + + it('扩展声明 extensionKind=workspace(依赖本地 git,须跑在工作区侧)', () => { + const raw = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')) as { extensionKind?: string[] }; + expect(raw.extensionKind).toEqual(['workspace']); + }); + + it('非受信工作区显式声明不支持(扩展会执行 commit/push/discard)', () => { + const raw = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')) as { + capabilities: { untrustedWorkspaces?: { supported?: boolean } }; + }; + expect(raw.capabilities.untrustedWorkspaces?.supported).toBe(false); + }); +}); diff --git a/tests/unit/ref-cleanup.test.ts b/tests/unit/ref-cleanup.test.ts index a20d092..0500173 100644 --- a/tests/unit/ref-cleanup.test.ts +++ b/tests/unit/ref-cleanup.test.ts @@ -72,7 +72,7 @@ describe('truncateNames', () => { it('超过上限时截断并标注剩余数量', () => { const names = Array.from({ length: 10 }, (_, i) => `b${i}`); - expect(truncateNames(names)).toBe('b0, b1, b2, b3, b4, b5, b6, b7 …还有 2 个'); + expect(truncateNames(names)).toBe('b0, b1, b2, b3, b4, b5, b6, b7 … and 2 more'); }); }); @@ -106,31 +106,34 @@ describe('diffPrunedRefs', () => { describe('formatBranchDeleteConfirm', () => { it('单个已合并 → 安全删除文案', () => { - expect(formatBranchDeleteConfirm(['feat'], [])).toEqual({ detail: '分支「feat」已合并,可安全删除。', confirmLabel: '删除' }); + expect(formatBranchDeleteConfirm(['feat'], [])).toEqual({ + detail: 'Branch "feat" is fully merged and safe to delete.', + confirmLabel: 'Delete', + }); }); it('单个未合并 → 强制删除文案', () => { const r = formatBranchDeleteConfirm([], ['feat']); - expect(r.confirmLabel).toBe('强制删除'); - expect(r.detail).toContain('未合并'); + expect(r.confirmLabel).toBe('Force Delete'); + expect(r.detail).toContain('NOT merged'); }); it('多个全已合并 → 删除', () => { const r = formatBranchDeleteConfirm(['a', 'b'], []); - expect(r.confirmLabel).toBe('删除'); - expect(r.detail).toContain('将删除 2 个已合并'); + expect(r.confirmLabel).toBe('Delete'); + expect(r.detail).toContain('Delete 2 merged local branch'); }); it('多个全未合并 → 强制删除并警示丢失提交', () => { const r = formatBranchDeleteConfirm([], ['a', 'b']); - expect(r.confirmLabel).toBe('强制删除'); - expect(r.detail).toContain('丢失'); + expect(r.confirmLabel).toBe('Force Delete'); + expect(r.detail).toContain('lost'); }); it('混合 → 全部删除并分栏诚实呈现', () => { const r = formatBranchDeleteConfirm(['a'], ['b']); - expect(r.confirmLabel).toBe('全部删除'); - expect(r.detail).toContain('已合并'); - expect(r.detail).toContain('未合并'); + expect(r.confirmLabel).toBe('Delete All'); + expect(r.detail).toContain('Merged'); + expect(r.detail).toContain('Unmerged'); }); }); diff --git a/tests/unit/ref-name.test.ts b/tests/unit/ref-name.test.ts new file mode 100644 index 0000000..65d8a45 --- /dev/null +++ b/tests/unit/ref-name.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { validateRefName } from '../../src/engine/ref/ref-name'; + +describe('validateRefName', () => { + it('合法名称通过(含斜杠分层)', () => { + expect(validateRefName('feature/x')).toBeNull(); + expect(validateRefName('feature/login-page')).toBeNull(); + expect(validateRefName('v1.0.0', 'tag')).toBeNull(); + expect(validateRefName('release/1.2.3-beta.1', 'tag')).toBeNull(); + expect(validateRefName('a.b.c')).toBeNull(); + }); + + it('空与空白', () => { + expect(validateRefName('')).toBe('Name cannot be empty'); + expect(validateRefName(' ')).toBe('Name cannot be empty'); + expect(validateRefName(' foo')).toContain('whitespace'); + }); + + it('前导 -(会被当作选项)', () => { + expect(validateRefName('-x')).toContain('"-"'); + }); + + it('非法字符集', () => { + for (const bad of ['a b', 'a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'a\\b']) { + expect(validateRefName(bad)).toContain('cannot contain'); + } + }); + + it('.. 与 @{ 与孤立 @', () => { + expect(validateRefName('a..b')).toContain('..'); + expect(validateRefName('a@{b')).toContain('@{'); + expect(validateRefName('@')).toContain('"@"'); + }); + + it('点相关:尾点 / .lock / 组件点开头', () => { + expect(validateRefName('a.')).toContain('"."'); + expect(validateRefName('a.lock')).toContain('.lock'); + expect(validateRefName('.hidden/x')).toContain('"."'); + expect(validateRefName('a/.b')).toContain('"."'); + }); + + it('斜杠相关:首尾斜杠与 //', () => { + expect(validateRefName('/a')).toContain('"/"'); + expect(validateRefName('a/')).toContain('"/"'); + expect(validateRefName('a//b')).toContain('"//"'); + }); + + it('branch 禁 HEAD(大小写不敏感),tag 禁 #', () => { + expect(validateRefName('HEAD', 'branch')).toContain('HEAD'); + expect(validateRefName('head', 'branch')).toContain('HEAD'); + expect(validateRefName('HEAD')).toBeNull(); // any 不受限 + expect(validateRefName('rel#1', 'tag')).toContain('"#"'); + }); + + it('组件级点规则不误伤常规点号名', () => { + expect(validateRefName('v1.0')).toBeNull(); + expect(validateRefName('a.b/c.d')).toBeNull(); + }); +}); diff --git a/tests/unit/ref-remote.test.ts b/tests/unit/ref-remote.test.ts index b2b60e9..5e66859 100644 --- a/tests/unit/ref-remote.test.ts +++ b/tests/unit/ref-remote.test.ts @@ -77,10 +77,10 @@ describe('formatRemoteDeleteConfirm', () => { it('单条 → 删除 + 不可撤销 + 协作者', () => { const r = formatRemoteDeleteConfirm([t('origin/foo', 'origin', 'foo')]); - expect(r.confirmLabel).toBe('删除'); + expect(r.confirmLabel).toBe('Delete'); expect(r.detail).toContain('origin/foo'); - expect(r.detail).toContain('不可撤销'); - expect(r.detail).toContain('协作者'); + expect(r.detail).toContain('cannot be undone'); + expect(r.detail).toContain('collaborators'); }); it('多条 → 含数量与截断名', () => { @@ -89,18 +89,18 @@ describe('formatRemoteDeleteConfirm', () => { t('origin/b1', 'origin', 'b1'), t('origin/b2', 'origin', 'b2'), ]); - expect(r.confirmLabel).toBe('删除'); - expect(r.detail).toContain('3 个'); + expect(r.confirmLabel).toBe('Delete'); + expect(r.detail).toContain('3 remote'); }); - it('多条超上限 → truncateNames 截断(…还有)', () => { + it('多条超上限 → truncateNames 截断(… and N more)', () => { const ts = Array.from({ length: 12 }, (_, i) => t(`origin/b${i}`, 'origin', `b${i}`)); - expect(formatRemoteDeleteConfirm(ts).detail).toContain('…还有'); + expect(formatRemoteDeleteConfirm(ts).detail).toContain('… and 4 more'); }); it('含当前 HEAD 上游 → ⚠ 软警示', () => { const r = formatRemoteDeleteConfirm([t('origin/foo', 'origin', 'foo')], { hasUpstreamOfHead: true }); - expect(r.detail).toContain('上游'); + expect(r.detail).toContain('upstream'); expect(r.detail).toContain('⚠'); }); diff --git a/tests/unit/shared-styles.test.ts b/tests/unit/shared-styles.test.ts index 41fcdae..3f923bb 100644 --- a/tests/unit/shared-styles.test.ts +++ b/tests/unit/shared-styles.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getBaseStyles, getButtonClass } from '../../src/adapter/webview/shared-styles'; +import { getBaseStyles, getButtonClass, GRAPH_ROW_H, GRAPH_LANE_W } from '../../src/adapter/webview/shared-styles'; describe('shared-styles', () => { it('getBaseStyles 暴露 spacing/radius token 与全部基础组件类', () => { @@ -13,22 +13,47 @@ describe('shared-styles', () => { expect(css).toContain('.hg-btn--secondary'); expect(css).toContain('.hg-btn--sm'); expect(css).toContain('.hg-input'); - expect(css).toContain('.hg-row'); + expect(css).toContain('.hg-select'); }); - it('getBaseStyles 统一交互态:hover / focus-visible / disabled / reduced-motion', () => { + it('GRAPH 行高/列宽常量与 CSS 变量同源注入(消除 CSS/JS 双源漂移)', () => { + const css = getBaseStyles(); + expect(css).toContain(`--hg-row: ${GRAPH_ROW_H}px`); + expect(css).toContain(`--hg-lane: ${GRAPH_LANE_W}px`); + expect(GRAPH_ROW_H).toBeGreaterThan(0); + expect(GRAPH_LANE_W).toBeGreaterThan(0); + }); + + it('getBaseStyles 统一交互态:hover / focus-visible / disabled / reduced-motion / forced-colors', () => { const css = getBaseStyles(); expect(css).toContain(':hover'); expect(css).toContain(':focus-visible'); expect(css).toContain(':disabled'); expect(css).toContain('prefers-reduced-motion'); + expect(css).toContain('forced-colors'); + }); + + it('全局控件基线:滚动条与 checkbox 走主题令牌', () => { + const css = getBaseStyles(); + expect(css).toContain('::-webkit-scrollbar'); + expect(css).toContain('--vscode-scrollbarSlider-background'); + expect(css).toContain('accent-color'); + expect(css).toContain('--vscode-checkbox-background'); }); it('getBaseStyles 颜色一律走 --vscode-* 语义令牌(不硬编码裸 hex 作主色)', () => { const css = getBaseStyles(); expect(css).toContain('var(--vscode-button-background)'); expect(css).toContain('var(--vscode-focusBorder)'); - expect(css).toContain('var(--vscode-list-hoverBackground)'); + expect(css).toContain('var(--vscode-input-background)'); + expect(css).toContain('var(--vscode-dropdown-background)'); + }); + + it('字号以 --vscode-font-size 为基准(随用户字号设置缩放,无裸 px 主字号)', () => { + const css = getBaseStyles(); + expect(css).toContain('font-size: var(--vscode-font-size)'); + expect(css).toContain('calc(var(--vscode-font-size) - 2px)'); + expect(css).not.toMatch(/font-size: 1[0-9]px/); }); it('getButtonClass 按变体拼出正确的 class 串', () => {