diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac3639..31ef204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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(正确性 / 架构 / 完整性)交叉复核后修复: diff --git a/package.json b/package.json index 0a6bf33..76ac52b 100644 --- a/package.json +++ b/package.json @@ -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, @@ -63,6 +63,16 @@ "name": "Commit", "type": "webview", "visibility": "visible" + }, + { + "id": "hyperGit.log", + "name": "Log", + "visibility": "visible" + }, + { + "id": "hyperGit.branches", + "name": "Branches", + "visibility": "visible" } ] }, @@ -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": { diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts new file mode 100644 index 0000000..a9545e7 --- /dev/null +++ b/src/adapter/history-commands.ts @@ -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; +} diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts new file mode 100644 index 0000000..251d4b3 --- /dev/null +++ b/src/adapter/tree/branches-tree.ts @@ -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 { + private readonly _onDidChange = new vscode.EventEmitter(); + 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; + } +} diff --git a/src/adapter/tree/log-tree.ts b/src/adapter/tree/log-tree.ts new file mode 100644 index 0000000..023d39a --- /dev/null +++ b/src/adapter/tree/log-tree.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; +import type { Commit } from '../../types/git'; +import type { GitRepositoryService } from '../git-repository-service'; + +export interface LogCommitNode { + readonly kind: 'commit'; + readonly commit: Commit; +} + +export type LogNode = LogCommitNode; + +export interface LogFilter { + readonly author?: string; + readonly path?: string; +} + +/** + * Log 视图 TreeDataProvider:消费 `Repository.log()`,按 author/path 过滤。 + * 完整提交图(SVG 拓扑连线)作为后续增强(M3.x);当前以提交列表 + 过滤 + copy hash 提供核心浏览能力。 + */ +export class LogTreeProvider implements vscode.TreeDataProvider { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChange.event; + private filter: LogFilter = {}; + + constructor(private readonly service: GitRepositoryService) {} + + setFilter(filter: LogFilter): void { + this.filter = filter; + this._onDidChange.fire(undefined); + } + + clearFilter(): void { + this.filter = {}; + this._onDidChange.fire(undefined); + } + + getFilter(): LogFilter { + return this.filter; + } + + refresh(): void { + this._onDidChange.fire(undefined); + } + + async getChildren(element?: LogNode): Promise { + if (element) { + return []; + } + const repo = this.service.repo; + if (!repo) { + return []; + } + try { + const commits = await repo.log({ maxEntries: 200, author: this.filter.author, path: this.filter.path }); + return commits.map((c): LogCommitNode => ({ kind: 'commit', commit: c })); + } catch { + return []; + } + } + + getTreeItem(node: LogNode): vscode.TreeItem { + const c = node.commit; + const subject = (c.message.split('\n', 1)[0] ?? c.message).slice(0, 80); + const date = c.authorDate ? formatDate(c.authorDate) : ''; + const item = new vscode.TreeItem(subject, vscode.TreeItemCollapsibleState.None); + item.id = c.hash; + item.description = `${c.authorName ?? '?'} · ${date} · ${c.hash.slice(0, 7)}`; + item.tooltip = `${c.hash}\n${c.authorName ?? ''} <${c.authorEmail ?? ''}> · ${date}\n\n${c.message}`; + item.contextValue = 'hyperGit.commit'; + item.iconPath = new vscode.ThemeIcon('git-commit'); + return item; + } +} + +function formatDate(d: Date): string { + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${m}-${day}`; +} diff --git a/src/extension.ts b/src/extension.ts index 459d84a..0b7ca5b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,10 @@ import { NullPreCommitInspector } from './agent/pre-commit'; import { registerChangesCommands } from './adapter/commands'; import { ChangelistRegistry } from './adapter/changelist-registry'; import { CommitService } from './adapter/commit/commit-service'; +import { BranchesTreeProvider } from './adapter/tree/branches-tree'; import { ChangesTreeProvider, EmptyChangesProvider } from './adapter/tree/changes-tree'; +import { LogTreeProvider } from './adapter/tree/log-tree'; +import { registerHistoryCommands } from './adapter/history-commands'; import { CommitWebviewProvider } from './adapter/webview/commit-webview'; import { getGitApi } from './adapter/git-api'; import { GitRepositoryService } from './adapter/git-repository-service'; @@ -50,6 +53,8 @@ export async function activate(context: vscode.ExtensionContext): Promise conflict: new NullConflictResolver(), }); const commitView = new CommitWebviewProvider(service, registry, commit); + const logTree = new LogTreeProvider(service); + const branchesTree = new BranchesTreeProvider(service); const focusCommitView = (): void => { void vscode.commands.executeCommand('hyperGit.commit.focus'); }; @@ -60,7 +65,10 @@ export async function activate(context: vscode.ExtensionContext): Promise commit, vscode.window.registerTreeDataProvider('hyperGit.changes', tree), vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), + vscode.window.registerTreeDataProvider('hyperGit.log', logTree), + vscode.window.registerTreeDataProvider('hyperGit.branches', branchesTree), ...registerChangesCommands(service, registry, tree), + ...registerHistoryCommands(service, logTree, branchesTree), vscode.commands.registerCommand('hyperGit.commit', focusCommitView), vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), ); @@ -68,6 +76,8 @@ export async function activate(context: vscode.ExtensionContext): Promise const refreshAll = (): void => { tree.refresh(); commitView.refresh(); + logTree.refresh(); + branchesTree.refresh(); }; context.subscriptions.push( service.onDidChange(refreshAll), diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index 453ca27..270ad5e 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -6,7 +6,7 @@ const EXT_ID = 'threefish-ai.hyper-git'; suite('扩展冒烟测试', function () { this.timeout(30000); - test('扩展可激活并注册全部 M1+M2 命令', async () => { + test('扩展可激活并注册全部 M1+M2+M3 命令', async () => { const ext = vscode.extensions.getExtension(EXT_ID); assert.ok(ext, `扩展 ${EXT_ID} 未找到`); if (!ext.isActive) { @@ -24,6 +24,19 @@ suite('扩展冒烟测试', function () { 'hyperGit.openDiff', 'hyperGit.commit', 'hyperGit.commitAndPush', + 'hyperGit.refreshLog', + 'hyperGit.refreshBranches', + 'hyperGit.logFilterAuthor', + 'hyperGit.logFilterPath', + 'hyperGit.logClearFilter', + 'hyperGit.copyCommitHash', + 'hyperGit.showHistory', + 'hyperGit.branchCreate', + 'hyperGit.branchCheckout', + 'hyperGit.branchDelete', + 'hyperGit.mergeBranch', + 'hyperGit.rebaseBranch', + 'hyperGit.showBlame', ]) { assert.ok(commands.includes(cmd), `命令 ${cmd} 未注册`); }