Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## [Unreleased]

### Added

- **Branches 视图多选 + 批量操作**:`hyperGit.branches` 由 `registerTreeDataProvider` 改用 `createTreeView({ canSelectMany: true })`,支持 Ctrl/Cmd/Shift 框选多个分支/标签。批量命令作用于整个选区:**删除分支**(一次 `git branch --merged` 分类已合并/未合并,单条确认弹窗诚实分栏呈现强制删除风险,逐个删除并汇总成功/失败)、**删除标签**、**复制引用**(按行连接)、**收藏切换**。仅单目标语义的操作(检出/合并/变基/重命名/比较)经 `!listMultiSelection` 在多选时从右键菜单隐藏,且因仅读「右键点击项」而始终安全。新增纯逻辑 `engine/ref/selection.collectBranchRefs`(选区归一化 + 「点击在选区之外」手势优先)与 `engine/ref/cleanup` 的 `partitionByMerged`/`formatBranchDeleteConfirm`/`truncateNames`,单测全覆盖。

## [0.0.1-rc.4] - 2026-06-29 — 第四个预发布候选

> 修复用户截图反馈的两处工具窗口缺陷:活动栏图标缺失「未提交文件数」角标、Branches「Push」对无上游分支失败。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ IntelliJ IDEA 的统一 Git 工具窗口(顶部 `Commit / Shelf / Stash` 标
- **多 changelist Changes 视图**:active 列表、新建/删除/重命名/移动,`workspaceState` 持久化(重启恢复);文件状态色复用 `gitDecoration.*` 主题色。
- **Commit 提交窗口**:多行编辑器 + Conventional Commits 实时校验 + Amend / Signed-off-by / 跳过 Git hooks + Commit / Commit and Push;勾选集为提交权威范围;最近消息复用。
- **Log 提交历史**:按作者/路径过滤、清除过滤、复制 commit hash、显示文件历史。
- **Branches**:本地/远程分组、活动分支高亮;新建/检出/删除/合并/变基(rebase)。
- **Branches**:本地/远程分组、活动分支高亮;新建/检出/删除/合并/变基(rebase);**多选批量**(Ctrl/Cmd/Shift 框选后批量删除分支/标签、复制引用、收藏)
- **Stash**:create / apply / pop / drop(作用于 stash@{0} 最新)。
- **其他**:Discard 改动、Pull / Push / Fetch、Blame、Diff(HEAD ↔ Working)。

Expand Down
8 changes: 8 additions & 0 deletions docs/.agents/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,12 @@
- **后续防范**:该 job 与市场 `publish` **解耦**(不 `needs: publish`、不挂 `environment: production`),保证「Release 带 `.vsix`」不被市场审批门/密钥缺失阻塞;「仅出 Release、暂不发市场」时不审批 production 即可,无需改 publish job;最小权限仅本 job 提权 `contents: write`。
- **同类问题影响**:所有「CI 只上传 artifact + 发市场、却在 README 承诺 Release 手动下载」的 VS Code 扩展;artifact ≠ Release 资产,二者可见性/留存期差异易被忽视。

## #8 Branches 视图无法多选(批量删除等批量操作缺失)

- **表因**:用户截图反馈 Branches 视图中一组功能/工作分支无法框选多个、无法批量删除。
- **根因**:`hyperGit.branches` 经 `vscode.window.registerTreeDataProvider` 注册——该 API **不支持** `canSelectMany`,故视图天然单选;所有分支命令处理器亦只接收单个 `BranchNode`。多选能力(`canSelectMany: true`)仅 `createTreeView` 的 `TreeViewOptions` 支持。
- **处理方式**:改用 `createTreeView('hyperGit.branches', { treeDataProvider, canSelectMany: true })`(句柄入 subscriptions)。批量命令处理器签名扩展为 `(clickedNode, selectedNodes[])`——VS Code 多选树的 `view/item/context` 命令第 2 实参即完整选区数组。新增纯逻辑 `engine/ref/selection.collectBranchRefs`(谓词过滤 + shortName 去重 + 「点击在选区之外则以点击项为准」)与 `engine/ref/cleanup.partitionByMerged`/`formatBranchDeleteConfirm`,使 `branchDelete`/`tagDelete`/`copyBranchRef`/`toggleFavorite` 批量化(删除仅一次 `git branch --merged` 分类、汇总成功/失败、末尾单次刷新)。`package.json` 对仅单目标命令(检出/合并/变基/重命名/比较等)追加 `&& !listMultiSelection` 在多选时隐藏。
- **后续防范**:① 需要承载 `.badge` 或 `canSelectMany` 等 `TreeViewOptions` 能力的视图,一律用 `createTreeView` 而非 `registerTreeDataProvider`(本仓 `hyperGit.changes` 已有先例)。② 多选命令正确性**只依赖处理器读取实参**(`clickedNode` + `selectedNodes[]`),不得依赖 `listMultiSelection` 上下文键——其对**自定义贡献视图**的可靠性无法确证,仅作菜单整洁的视觉优化;单目标命令因只读 `clickedNode` 即便该键失效仍安全。③ 「右键点击选区之外」须以点击项为准(手势目标优先),由归一化助手统一兜底。
- **同类问题影响**:所有以 `registerTreeDataProvider` 注册却后续需要多选/角标的自定义 TreeView;以及误把单目标命令在多选下直接作用于「点击项」造成的隐性误操作。


16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,18 @@
{ "command": "hyperGit.showHistory", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@2" },
{ "command": "hyperGit.discardChanges", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@3" },
{ "command": "hyperGit.copyCommitHash", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" },
{ "command": "hyperGit.branchCheckout", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@1" },
{ "command": "hyperGit.branchCheckout", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", "group": "1_branch@1" },
{ "command": "hyperGit.branchDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@2" },
{ "command": "hyperGit.mergeBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@3" },
{ "command": "hyperGit.rebaseBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@4" },
{ "command": "hyperGit.branchRename", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@5" },
{ "command": "hyperGit.compareBranches", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@6" },
{ "command": "hyperGit.mergeBranch", "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", "group": "1_branch@4" },
{ "command": "hyperGit.branchRename", "when": "view == hyperGit.branches && viewItem == hyperGit.branch && !listMultiSelection", "group": "1_branch@5" },
{ "command": "hyperGit.compareBranches", "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/", "group": "1_branch@7" },
{ "command": "hyperGit.toggleFavorite", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "inline" },
{ "command": "hyperGit.toggleFavorite", "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/", "group": "1_branch@1.5" },
{ "command": "hyperGit.compareWithCurrent", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch|hyperGit.tag/", "group": "1_branch@8" },
{ "command": "hyperGit.tagCheckout", "when": "view == hyperGit.branches && viewItem == hyperGit.tag", "group": "1_tag@1" },
{ "command": "hyperGit.checkoutAsNew", "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", "group": "1_branch@8" },
{ "command": "hyperGit.tagCheckout", "when": "view == hyperGit.branches && viewItem == hyperGit.tag && !listMultiSelection", "group": "1_tag@1" },
{ "command": "hyperGit.tagDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.tag", "group": "1_tag@2" },
{ "command": "hyperGit.cherryPick", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" },
{ "command": "hyperGit.revertCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@2" },
Expand Down
16 changes: 9 additions & 7 deletions src/adapter/advanced-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree';
import type { GitRepositoryService } from './git-repository-service';
import type { LogNode } from './tree/log-tree';
import { filterMergeable } from '../engine/ref/cleanup';
import { selectedBranchRefs } from './branch-selection';

const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));

Expand Down Expand Up @@ -135,15 +136,16 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches
);

subs.push(
vscode.commands.registerCommand('hyperGit.copyBranchRef', async (node?: BranchNode) => {
if (node?.kind !== 'branch') {
vscode.commands.registerCommand('hyperGit.copyBranchRef', async (node?: BranchNode, nodes?: BranchNode[]) => {
// 支持多选:复制全部选中引用(按行连接)。
const names = selectedBranchRefs(node, nodes, () => true)
.map((r) => r.shortName)
.filter((n) => n.length > 0);
if (names.length === 0) {
return;
}
const ref = node.ref.shortName;
if (ref) {
await vscode.env.clipboard.writeText(ref);
void vscode.window.showInformationMessage(`已复制 ${ref}`);
}
await vscode.env.clipboard.writeText(names.join('\n'));
void vscode.window.showInformationMessage(names.length === 1 ? `已复制 ${names[0]}` : `已复制 ${names.length} 个引用`);
}),
);

Expand Down
29 changes: 29 additions & 0 deletions src/adapter/branch-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Branches 多选命令的适配层归一化(薄包装)。
*
* 把 VS Code `view/item/context` 命令的 `(clickedNode, selectedNodes[])` 实参抽取为 RawRef,
* 再委托纯逻辑 {@link collectBranchRefs} 完成谓词过滤 / 去重 / 「点击在选区之外」归一化。
* 维持「UI → Adapter → Engine」单向依赖:BranchNode 类型仅在本适配层出现。
*/

import type { RawRef } from '../engine/ref/for-each-ref';
import { collectBranchRefs } from '../engine/ref/selection';
import type { BranchNode } from './tree/branches-tree';

/**
* 解析多选命令的作用目标 ref 列表。
* @param clicked 右键点击节点(第 1 实参)
* @param selection VS Code 传入的选区节点(第 2 实参,仅多选时存在)
* @param predicate ref 谓词(如本地可删 / 标签)
*/
export function selectedBranchRefs(
clicked: BranchNode | undefined,
selection: BranchNode[] | undefined,
predicate: (ref: RawRef) => boolean,
): RawRef[] {
const clickedRef = clicked?.kind === 'branch' ? clicked.ref : undefined;
const selRefs = (selection ?? [])
.filter((n): n is Extract<BranchNode, { kind: 'branch' }> => n.kind === 'branch')
.map((n) => n.ref);
return collectBranchRefs(clickedRef, selRefs, predicate);
}
110 changes: 67 additions & 43 deletions src/adapter/history-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { ChangeItem, GitRepositoryService } from './git-repository-service'
import type { LogNode, LogTreeProvider } from './tree/log-tree';
import { handleGitConflict } from './conflict-ui';
import type { MergeMode } from '../engine/log/log-filter';
import { selectedBranchRefs } from './branch-selection';
import { formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../engine/ref/cleanup';

/** 注册 Log/Branches/Blame/History/Tags 相关命令。 */
export function registerHistoryCommands(
Expand Down Expand Up @@ -108,27 +110,51 @@ export function registerHistoryCommands(
);

subs.push(
vscode.commands.registerCommand('hyperGit.branchDelete', async (node: BranchNode) => {
vscode.commands.registerCommand('hyperGit.branchDelete', async (node: BranchNode, nodes?: BranchNode[]) => {
const repo = service.repo;
if (!repo || node?.kind !== 'branch' || node.remote) {
if (!repo) {
return;
}
const name = node.ref.shortName;
// 查询是否已合并:已合并用安全删除(-d / force=false),未合并需二次确认强制删除(-D / force=true)
const merged = await isBranchMerged(service, name);
const detail = merged
? `分支「${name}」已合并,可安全删除。`
: `分支「${name}」未合并,强制删除将丢失其独有提交!`;
const confirmText = merged ? '删除' : '强制删除';
const choice = await vscode.window.showWarningMessage(detail, { modal: true }, confirmText);
if (choice === confirmText) {
// 仅本地、非当前 HEAD 可删(ref.head 直接排除当前分支,无需解析名字);支持多选批量。
const refs = selectedBranchRefs(node, nodes, (r) => !r.isRemote && !r.isTag && !r.head);
const names = refs.map((r) => r.shortName);
if (names.length === 0) {
if (node?.kind === 'branch' && node.ref.head) {
void vscode.window.showWarningMessage('当前分支无法删除');
}
return;
}
// 一次查询已合并集合(避免逐个 git branch --merged):已合并安全删除(-d),未合并强制删除(-D)。
const base = repo.state.HEAD?.name ?? 'main';
let mergedOut = '';
try {
mergedOut = await service.execGit(['branch', '--merged', base]);
} catch {
/* 查询失败则视为全部未合并,确认弹窗会以强制删除提示 */
}
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);
if (choice !== confirmLabel) {
return;
}
const failures: string[] = [];
let deleted = 0;
for (const name of names) {
try {
await repo.deleteBranch(name, !merged);
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`删除失败:${errMsg(e)}`);
await repo.deleteBranch(name, !mergedSet.has(name));
deleted++;
} catch {
failures.push(name);
}
}
branchesTree.refresh();
if (failures.length === 0) {
void vscode.window.showInformationMessage(deleted === 1 ? `已删除分支 ${names[0]}` : `已删除 ${deleted} 个分支`);
} else {
void vscode.window.showWarningMessage(`已删除 ${deleted} 个分支,${failures.length} 个失败:${truncateNames(failures)}`);
}
}),
);

Expand Down Expand Up @@ -285,11 +311,12 @@ export function registerHistoryCommands(
// —— IDEA 风格 Branches 高级操作(Phase 1)——

subs.push(
vscode.commands.registerCommand('hyperGit.toggleFavorite', async (node: BranchNode) => {
if (node?.kind !== 'branch' || node.ref.isTag) {
return;
vscode.commands.registerCommand('hyperGit.toggleFavorite', async (node: BranchNode, nodes?: BranchNode[]) => {
// 支持多选批量切换收藏(标签无收藏语义,谓词排除)。
const refs = selectedBranchRefs(node, nodes, (r) => !r.isTag);
for (const r of refs) {
favorites.toggle(r.shortName);
}
favorites.toggle(node.ref.shortName);
// favorites.onDidChange 会触发 branchesTree.refresh()
}),
);
Expand Down Expand Up @@ -365,20 +392,32 @@ export function registerHistoryCommands(
);

subs.push(
vscode.commands.registerCommand('hyperGit.tagDelete', async (node: BranchNode) => {
if (node?.kind !== 'branch' || !node.ref.isTag) {
vscode.commands.registerCommand('hyperGit.tagDelete', async (node: BranchNode, nodes?: BranchNode[]) => {
// 支持多选批量删除标签。
const names = selectedBranchRefs(node, nodes, (r) => r.isTag).map((r) => r.shortName);
if (names.length === 0) {
return;
}
const name = node.ref.shortName;
const ok = await vscode.window.showWarningMessage(`删除标签「${name}」?`, { modal: true }, '删除');
const detail = names.length === 1 ? `删除标签「${names[0]}」?` : `将删除 ${names.length} 个标签:${truncateNames(names)}`;
const ok = await vscode.window.showWarningMessage(detail, { modal: true }, '删除');
if (ok !== '删除') {
return;
}
try {
await service.execGit(['tag', '-d', name]);
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`删除标签失败:${errMsg(e)}`);
const failures: string[] = [];
let deleted = 0;
for (const name of names) {
try {
await service.execGit(['tag', '-d', name]);
deleted++;
} catch {
failures.push(name);
}
}
branchesTree.refresh();
if (failures.length === 0) {
void vscode.window.showInformationMessage(deleted === 1 ? `已删除标签 ${names[0]}` : `已删除 ${deleted} 个标签`);
} else {
void vscode.window.showWarningMessage(`已删除 ${deleted} 个标签,${failures.length} 个失败:${truncateNames(failures)}`);
}
}),
);
Expand Down Expand Up @@ -574,18 +613,3 @@ export function registerHistoryCommands(

return subs;
}

/** 查询本地分支是否已合并到当前 HEAD(或 main):经 `git branch --merged <base>`。 */
async function isBranchMerged(service: GitRepositoryService, branch: string): Promise<boolean> {
const repo = service.repo;
if (!repo) {
return false;
}
const base = repo.state.HEAD?.name ?? 'main';
try {
const out = await service.execGit(['branch', '--merged', base]);
return out.split('\n').some((line) => line.replace(/^\*\s*/, '').trim() === branch);
} catch {
return false;
}
}
Loading
Loading