diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bc1f7..a27bf85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,40 @@ ## [Unreleased] +### 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 编辑器无注解、已关闭文档条目泄漏(订阅可见编辑器变化统一挂载/清理)。 + +### 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 记忆化。 + +### Docs + + + - **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 边界防抖不误触)。 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/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/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 334a1a7..8c8bf14 100644 --- a/package.json +++ b/package.json @@ -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", @@ -305,7 +368,7 @@ "command": "hyperGit.rebaseBranch", "title": "Rebase Current Branch onto…", "category": "Hyper Git", - "icon": "$(call-out)" + "icon": "$(arrow-right)" }, { "command": "hyperGit.showBlame", @@ -399,7 +462,7 @@ "command": "hyperGit.ignorePath", "title": "Add to .gitignore", "category": "Hyper Git", - "icon": "$(eye-closed)" + "icon": "$(diff-ignored)" }, { "command": "hyperGit.compareBranches", @@ -625,7 +688,7 @@ "command": "hyperGit.startRebase", "title": "Interactive Rebase…", "category": "Hyper Git", - "icon": "$(call-out)" + "icon": "$(arrow-right)" }, { "command": "hyperGit.moveHunkToChangelist", @@ -942,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" }, { @@ -957,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" }, { @@ -972,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" }, { @@ -1047,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" }, { @@ -1067,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" }, { @@ -1291,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" } ] }, @@ -1300,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", @@ -1363,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; } 破出。 // 不可用 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; } @@ -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), @@ -203,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 882c7e4..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 渲染替代色点) } /** 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 串', () => {