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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

## [Unreleased]

### Added — M3 Log/Branches/Diff·Blame(0.4.0)

- **Log TreeView**:消费 `Repository.log()`,按 author/path 过滤(清除过滤)、复制 commit hash、显示文件历史。完整提交图(SVG 拓扑连线)作为后续增强(M3.x)。
- **Branches TreeView**:消费 `Repository.state.refs`(Local/Remote 分组),活动分支高亮;支持新建/检出/删除/合并/变基(rebase)。
- **Blame**:`Show Blame` 命令对当前文件执行 `repo.blame` 并以只读文档展示。
- **Show History**:从 Changes 文件右键跳转 Log 并按该文件路径过滤。
- **API 限制(文档化延后)**:vscode.git 稳定 API 不含 cherry-pick / revert / reset / 分支重命名,这些 IDEA 功能暂不可用(未来可 CLI 兜底)。

### Fixed — M0/M1/M2 审查修复(0.3.1)

经 3 路独立 code review(正确性 / 架构 / 完整性)交叉复核后修复:
Expand Down
51 changes: 46 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "hyper-git",
"displayName": "Hyper Git",
"description": "在 VS Code 上完整复刻 IntelliJ IDEA 的 Git 工具窗口与 Commit 提交窗口,并为未来 AI Agent 自主代理预留架构接缝。",
"version": "0.3.1",
"version": "0.4.0",
"publisher": "threefish-ai",
"license": "MIT",
"preview": true,
Expand Down Expand Up @@ -63,6 +63,16 @@
"name": "Commit",
"type": "webview",
"visibility": "visible"
},
{
"id": "hyperGit.log",
"name": "Log",
"visibility": "visible"
},
{
"id": "hyperGit.branches",
"name": "Branches",
"visibility": "visible"
}
]
},
Expand All @@ -82,26 +92,57 @@
{ "command": "hyperGit.moveChangelist", "title": "移至 Changelist…", "category": "Hyper Git" },
{ "command": "hyperGit.openDiff", "title": "打开 Diff", "category": "Hyper Git" },
{ "command": "hyperGit.commit", "title": "提交", "category": "Hyper Git", "icon": "$(check)" },
{ "command": "hyperGit.commitAndPush", "title": "提交并推送", "category": "Hyper Git", "icon": "$(cloud-upload)" }
{ "command": "hyperGit.commitAndPush", "title": "提交并推送", "category": "Hyper Git", "icon": "$(cloud-upload)" },
{ "command": "hyperGit.refreshLog", "title": "刷新 Log", "category": "Hyper Git", "icon": "$(refresh)" },
{ "command": "hyperGit.refreshBranches", "title": "刷新 Branches", "category": "Hyper Git", "icon": "$(refresh)" },
{ "command": "hyperGit.logFilterAuthor", "title": "按作者过滤", "category": "Hyper Git" },
{ "command": "hyperGit.logFilterPath", "title": "按路径过滤", "category": "Hyper Git" },
{ "command": "hyperGit.logClearFilter", "title": "清除 Log 过滤", "category": "Hyper Git" },
{ "command": "hyperGit.copyCommitHash", "title": "复制 Commit Hash", "category": "Hyper Git" },
{ "command": "hyperGit.showHistory", "title": "显示文件历史", "category": "Hyper Git" },
{ "command": "hyperGit.branchCreate", "title": "新建分支", "category": "Hyper Git", "icon": "$(add)" },
{ "command": "hyperGit.branchCheckout", "title": "检出分支", "category": "Hyper Git" },
{ "command": "hyperGit.branchDelete", "title": "删除分支", "category": "Hyper Git" },
{ "command": "hyperGit.mergeBranch", "title": "合并到当前分支", "category": "Hyper Git" },
{ "command": "hyperGit.rebaseBranch", "title": "变基当前分支到…", "category": "Hyper Git" },
{ "command": "hyperGit.showBlame", "title": "显示 Blame", "category": "Hyper Git" }
],
"menus": {
"view/title": [
{ "command": "hyperGit.refresh", "when": "view == hyperGit.changes", "group": "navigation" },
{ "command": "hyperGit.newChangelist", "when": "view == hyperGit.changes", "group": "navigation" }
{ "command": "hyperGit.newChangelist", "when": "view == hyperGit.changes", "group": "navigation" },
{ "command": "hyperGit.refreshLog", "when": "view == hyperGit.log", "group": "navigation" },
{ "command": "hyperGit.logFilterAuthor", "when": "view == hyperGit.log" },
{ "command": "hyperGit.logFilterPath", "when": "view == hyperGit.log" },
{ "command": "hyperGit.logClearFilter", "when": "view == hyperGit.log" },
{ "command": "hyperGit.refreshBranches", "when": "view == hyperGit.branches", "group": "navigation" },
{ "command": "hyperGit.branchCreate", "when": "view == hyperGit.branches", "group": "navigation" }
],
"view/item/context": [
{ "command": "hyperGit.setActiveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.changelist", "group": "1_changelist@1" },
{ "command": "hyperGit.renameChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.changelist", "group": "1_changelist@2" },
{ "command": "hyperGit.deleteChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.changelist", "group": "1_changelist@3" },
{ "command": "hyperGit.moveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "inline" },
{ "command": "hyperGit.moveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@1" }
{ "command": "hyperGit.moveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@1" },
{ "command": "hyperGit.showHistory", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@2" },
{ "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.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" }
],
"commandPalette": [
{ "command": "hyperGit.openDiff", "when": "false" },
{ "command": "hyperGit.setActiveChangelist", "when": "false" },
{ "command": "hyperGit.renameChangelist", "when": "false" },
{ "command": "hyperGit.deleteChangelist", "when": "false" },
{ "command": "hyperGit.moveChangelist", "when": "false" }
{ "command": "hyperGit.moveChangelist", "when": "false" },
{ "command": "hyperGit.copyCommitHash", "when": "false" },
{ "command": "hyperGit.showHistory", "when": "false" },
{ "command": "hyperGit.branchCheckout", "when": "false" },
{ "command": "hyperGit.branchDelete", "when": "false" },
{ "command": "hyperGit.mergeBranch", "when": "false" },
{ "command": "hyperGit.rebaseBranch", "when": "false" }
]
},
"configuration": {
Expand Down
165 changes: 165 additions & 0 deletions src/adapter/history-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as path from 'path';
import * as vscode from 'vscode';
import type { BranchNode } from './tree/branches-tree';
import type { BranchesTreeProvider } from './tree/branches-tree';
import type { ChangeItem, GitRepositoryService } from './git-repository-service';
import type { LogNode, LogTreeProvider } from './tree/log-tree';

/** 注册 Log/Branches/Blame/History 相关命令(M3)。 */
export function registerHistoryCommands(
service: GitRepositoryService,
logTree: LogTreeProvider,
branchesTree: BranchesTreeProvider,
): vscode.Disposable[] {
const subs: vscode.Disposable[] = [];
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));

subs.push(vscode.commands.registerCommand('hyperGit.refreshLog', () => logTree.refresh()));
subs.push(vscode.commands.registerCommand('hyperGit.refreshBranches', () => branchesTree.refresh()));

subs.push(
vscode.commands.registerCommand('hyperGit.logFilterAuthor', async () => {
const author = await vscode.window.showInputBox({ prompt: '按作者过滤(Author)', placeHolder: '例如 John' });
const f = logTree.getFilter();
logTree.setFilter({ ...f, author: author && author.trim() ? author.trim() : undefined });
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.logFilterPath', async () => {
const uri = await vscode.window.showOpenDialog({ canSelectMany: false });
const repo = service.repo;
if (uri?.[0] && repo) {
const rel = path.relative(repo.rootUri.fsPath, uri[0].fsPath).split(path.sep).join('/');
const f = logTree.getFilter();
logTree.setFilter({ ...f, path: rel });
}
}),
);

subs.push(vscode.commands.registerCommand('hyperGit.logClearFilter', () => logTree.clearFilter()));

subs.push(
vscode.commands.registerCommand('hyperGit.copyCommitHash', (node: LogNode) => {
if (node?.kind === 'commit') {
void vscode.env.clipboard.writeText(node.commit.hash);
void vscode.window.showInformationMessage(`已复制 ${node.commit.hash.slice(0, 7)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.showHistory', async (change?: ChangeItem) => {
if (change?.relativePath) {
logTree.setFilter({ path: change.relativePath });
}
await vscode.commands.executeCommand('hyperGit.log.focus');
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.branchCreate', async () => {
const repo = service.repo;
if (!repo) {
return;
}
const name = await vscode.window.showInputBox({ prompt: '新分支名称' });
if (name && name.trim()) {
try {
await repo.createBranch(name.trim(), true);
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`创建分支失败:${errMsg(e)}`);
}
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.branchCheckout', async (node: BranchNode) => {
const repo = service.repo;
if (!repo || node?.kind !== 'branch') {
return;
}
try {
await repo.checkout(node.ref.name ?? '');
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`检出失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.branchDelete', async (node: BranchNode) => {
const repo = service.repo;
if (!repo || node?.kind !== 'branch' || node.remote) {
return;
}
const name = node.ref.name ?? '';
const choice = await vscode.window.showWarningMessage(`删除分支「${name}」?`, { modal: true }, '删除');
if (choice === '删除') {
try {
await repo.deleteBranch(name, true);
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`删除失败:${errMsg(e)}`);
}
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.mergeBranch', async (node: BranchNode) => {
const repo = service.repo;
if (!repo || node?.kind !== 'branch') {
return;
}
try {
await repo.merge(node.ref.name ?? '');
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`合并失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.rebaseBranch', async (node: BranchNode) => {
const repo = service.repo;
if (!repo || node?.kind !== 'branch') {
return;
}
try {
await repo.rebase(node.ref.name ?? '');
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`变基失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.showBlame', async () => {
const repo = service.repo;
if (!repo) {
return;
}
const editor = vscode.window.activeTextEditor;
if (!editor) {
void vscode.window.showWarningMessage('请先打开一个文件');
return;
}
const rel = path.relative(repo.rootUri.fsPath, editor.document.uri.fsPath).split(path.sep).join('/');
try {
const blame = await repo.blame(rel);
const doc = await vscode.workspace.openTextDocument({ content: blame, language: 'plaintext' });
await vscode.window.showTextDocument(doc, { preview: true });
} catch (e) {
void vscode.window.showErrorMessage(`Blame 失败:${errMsg(e)}`);
}
}),
);

return subs;
}
67 changes: 67 additions & 0 deletions src/adapter/tree/branches-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as vscode from 'vscode';
import type { Ref } from '../../types/git';
import type { GitRepositoryService } from '../git-repository-service';

export interface BranchGroupNode {
readonly kind: 'group';
readonly id: 'local' | 'remote';
readonly label: string;
}

export interface BranchRefNode {
readonly kind: 'branch';
readonly ref: Ref;
readonly remote: boolean;
}

export type BranchNode = BranchGroupNode | BranchRefNode;

/** Branches 视图 TreeDataProvider:消费 `Repository.state.refs`(本地 + 远程分支)。 */
export class BranchesTreeProvider implements vscode.TreeDataProvider<BranchNode> {
private readonly _onDidChange = new vscode.EventEmitter<BranchNode | undefined>();
readonly onDidChangeTreeData = this._onDidChange.event;

constructor(private readonly service: GitRepositoryService) {}

refresh(): void {
this._onDidChange.fire(undefined);
}

getChildren(element?: BranchNode): BranchNode[] {
const repo = this.service.repo;
if (!repo) {
return [];
}
if (!element) {
return [
{ kind: 'group', id: 'local', label: 'Local Branches' },
{ kind: 'group', id: 'remote', label: 'Remote Branches' },
];
}
if (element.kind === 'group') {
const type = element.id === 'remote' ? 1 : 0; // RefType: 0=Head, 1=RemoteHead
return repo.state.refs
.filter((r) => r.type === type && r.name)
.map((r): BranchRefNode => ({ kind: 'branch', ref: r, remote: element.id === 'remote' }));
}
return [];
}

getTreeItem(element: BranchNode): vscode.TreeItem {
if (element.kind === 'group') {
const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded);
item.contextValue = 'hyperGit.branchGroup';
item.iconPath = new vscode.ThemeIcon(element.id === 'remote' ? 'repo' : 'git-branch');
return item;
}
const ref = element.ref;
const head = this.service.repo?.state.HEAD;
const active = !!head?.name && head.name === ref.name;
const item = new vscode.TreeItem(ref.name ?? '', vscode.TreeItemCollapsibleState.None);
item.description = active ? 'active' : (ref.commit?.slice(0, 7) ?? '');
item.contextValue = element.remote ? 'hyperGit.remoteBranch' : 'hyperGit.branch';
item.tooltip = `${ref.name ?? ''}${active ? ' (active)' : ''}`;
item.iconPath = new vscode.ThemeIcon(element.remote ? 'cloud' : 'git-branch', active ? new vscode.ThemeColor('gitDecoration.modifiedResourceForeground') : undefined);
return item;
}
}
Loading
Loading