From caa0959eaf528baeebefb138dd374013698efce9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sun, 28 Jun 2026 00:24:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(Inline-Commit):=20editor=20=E8=A1=8C?= =?UTF-8?q?=E5=86=85=E6=8F=90=E4=BA=A4=20CodeLens(#13)+=20hunk=E2=86=92?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E8=A1=8C=E6=98=A0=E5=B0=84=E5=BC=95?= =?UTF-8?q?=E6=93=8E;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 8 ++ package.json | 8 +- src/adapter/editor/inline-commit-codelens.ts | 117 +++++++++++++++++++ src/engine/diff/editor-mapping.ts | 45 +++++++ src/extension.ts | 5 + tests/suite/extension.test.js | 1 + tests/unit/editor-mapping.test.ts | 55 +++++++++ 7 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 src/adapter/editor/inline-commit-codelens.ts create mode 100644 src/engine/diff/editor-mapping.ts create mode 100644 tests/unit/editor-mapping.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eaae4f..17deece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### Added — Editor Inline Commit(#13,0.0.5) + +> IDEA editor inline commit 的 VS Code 等价(补齐最后一块主要拼图)。 + +- **行内提交 CodeLens**:编辑器中每个未暂存 hunk 上方渲染可点击 CodeLens「✓ 提交此 Hunk (+N -M)」→ 仅暂存该 hunk(patch 重建 + `git apply --cached`)→ 输入 message → `git commit`。 +- 新增 `engine/diff/editor-mapping`(hunk → 编辑器行区域映射,纯逻辑 + 5 单测);gutter 行标记(绿/红/蓝)由原生 git quickDiff 提供。 +- 其他已暂存内容会一并提交时给出二次确认提示。 + ### Added — Parity Batch 3(partial commit + 高级操作,0.0.4) - **partial / 行级提交**(IDEA PartialChangesUtil 等价):`engine/diff/hunk-parser`(unified diff 解析,7 单测)+ hunk 选择暂存/取消暂存(QuickPick 勾选)+ 光标处 hunk 暂存——经 patch 重建 + `git apply --cached`。 diff --git a/package.json b/package.json index 2c6110e..bdec148 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Hyper Git", "icon": "media/icon.png", "description": "在 VS Code 上完整复刻 IntelliJ IDEA 的 Git 工具窗口与 Commit 提交窗口,并为未来 AI Agent 自主代理预留架构接缝。", - "version": "0.0.4", + "version": "0.0.5", "publisher": "threefish-ai", "license": "MIT", "preview": true, @@ -141,7 +141,8 @@ { "command": "hyperGit.fixupCommit", "title": "Fixup 到提交(autosquash)", "category": "Hyper Git" }, { "command": "hyperGit.cleanupBranches", "title": "清理已合并分支", "category": "Hyper Git" }, { "command": "hyperGit.copyBranchRef", "title": "复制分支引用", "category": "Hyper Git" }, - { "command": "hyperGit.threeWayDiff", "title": "3-way Diff 概览", "category": "Hyper Git" } + { "command": "hyperGit.threeWayDiff", "title": "3-way Diff 概览", "category": "Hyper Git" }, + { "command": "hyperGit.inlineCommitHunk", "title": "行内提交 Hunk", "category": "Hyper Git" } ], "menus": { "view/title": [ @@ -206,7 +207,8 @@ { "command": "hyperGit.branchRename", "when": "false" }, { "command": "hyperGit.compareBranches", "when": "false" }, { "command": "hyperGit.ignorePath", "when": "false" }, - { "command": "hyperGit.copyBranchRef", "when": "false" } + { "command": "hyperGit.copyBranchRef", "when": "false" }, + { "command": "hyperGit.inlineCommitHunk", "when": "false" } ] }, "configuration": { diff --git a/src/adapter/editor/inline-commit-codelens.ts b/src/adapter/editor/inline-commit-codelens.ts new file mode 100644 index 0000000..dd96b86 --- /dev/null +++ b/src/adapter/editor/inline-commit-codelens.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { mapFileToEditorRegions } from '../../engine/diff/editor-mapping'; +import { buildPatch, parseUnifiedDiff } from '../../engine/diff/hunk-parser'; +import type { GitRepositoryService } from '../git-repository-service'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function repoRelative(root: string, fsPath: string): string | null { + const rel = path.relative(root, fsPath).split(path.sep).join('/'); + return rel.startsWith('..') || path.isAbsolute(rel) ? null : rel; +} + +/** + * 行内提交 CodeLensProvider(IDEA editor inline commit 的 VS Code 等价)。 + * + * 对当前文件每个未暂存 hunk,在其起始行上方渲染可点击 CodeLens「✓ 提交此 Hunk (+N -M)」。 + * 点击 → 仅暂存该 hunk(patch 重建 + `git apply --cached`)→ 输入 message → `git commit`。 + * gutter 视觉标记(绿/红/蓝)由原生 git quickDiff 提供,不重复造。 + */ +export class InlineCommitCodeLensProvider implements vscode.CodeLensProvider { + private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; + + constructor(private readonly service: GitRepositoryService) {} + + refresh(): void { + this._onDidChangeCodeLenses.fire(); + } + + async provideCodeLenses(doc: vscode.TextDocument): Promise { + const repo = this.service.repo; + if (!repo) { + return []; + } + const rel = repoRelative(repo.rootUri.fsPath, doc.uri.fsPath); + if (!rel) { + return []; + } + try { + const diff = await this.service.execGit(['diff', '-U3', '--', rel]); + if (!diff.trim()) { + return []; + } + const files = parseUnifiedDiff(diff); + if (files.length === 0) { + return []; + } + const regions = mapFileToEditorRegions(files[0]); + return regions.map((r) => { + const line = Math.max(0, r.startLine - 1); + return new vscode.CodeLens(new vscode.Range(line, 0, line, 0), { + command: 'hyperGit.inlineCommitHunk', + title: `✓ 提交此 Hunk (+${r.addedCount} -${r.removedCount})`, + arguments: [rel, r.hunkIndex], + }); + }); + } catch { + return []; + } + } +} + +/** 注册行内提交命令。 */ +export function registerInlineCommitCommand(service: GitRepositoryService, provider: InlineCommitCodeLensProvider): vscode.Disposable { + return vscode.commands.registerCommand('hyperGit.inlineCommitHunk', async (rel: string, hunkIndex: number) => { + const repo = service.repo; + if (!repo || !rel || hunkIndex === undefined) { + return; + } + // 其他已暂存内容会一并提交——给出提示(IDEA changelist 隔离在本工具由 stage 语义承载) + const otherStaged = service.getChanges().filter((c) => c.staged && c.relativePath !== rel); + if (otherStaged.length > 0) { + const ok = await vscode.window.showWarningMessage( + `当前已有其他 ${otherStaged.length} 个已暂存文件,将一并提交。继续?`, + { modal: true }, + '继续提交', + ); + if (ok !== '继续提交') { + return; + } + } + const message = await vscode.window.showInputBox({ + prompt: `提交 Hunk(${rel} #${hunkIndex + 1})的 commit message`, + placeHolder: 'feat(scope): description', + }); + if (!message || !message.trim()) { + return; + } + try { + const diff = await service.execGit(['diff', '-U3', '--', rel]); + const files = parseUnifiedDiff(diff); + if (files.length === 0) { + return; + } + const patch = buildPatch(files[0], [hunkIndex]); + const tmp = path.join(os.tmpdir(), `hg-inline-${Date.now()}.diff`); + fs.writeFileSync(tmp, patch); + try { + await service.execGit(['apply', '--cached', '--whitespace=nowarn', tmp]); + } finally { + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + } + await service.execGit(['commit', '-m', message.trim()]); + provider.refresh(); + void vscode.window.showInformationMessage('已提交该 Hunk'); + } catch (e) { + void vscode.window.showErrorMessage(`Inline commit 失败:${errMsg(e)}`); + } + }); +} diff --git a/src/engine/diff/editor-mapping.ts b/src/engine/diff/editor-mapping.ts new file mode 100644 index 0000000..f8cdfcb --- /dev/null +++ b/src/engine/diff/editor-mapping.ts @@ -0,0 +1,45 @@ +import type { DiffFile, DiffHunk } from './hunk-parser'; + +/** + * 一个 hunk 在编辑器(当前工作区文件)中的可见区域。 + * + * - startLine/endLine:该 hunk 的 new 范围(1-based 编辑器行),用于 CodeLens 定位与背景装饰。 + * - addedLines:hunk 体中「新增行('+')」对应的编辑器行号,用于 gutter 新增标记。 + */ +export interface EditorRegion { + readonly hunkIndex: number; + readonly startLine: number; + readonly endLine: number; + readonly addedLines: readonly number[]; + readonly addedCount: number; + readonly removedCount: number; +} + +/** + * 把 DiffFile 的每个 hunk 映射为编辑器区域(IDEA LineStatusTracker 的纯逻辑等价)。 + */ +export function mapFileToEditorRegions(file: DiffFile): EditorRegion[] { + return file.hunks.map((h, i) => mapHunkToRegion(h, i)); +} + +function mapHunkToRegion(hunk: DiffHunk, hunkIndex: number): EditorRegion { + const startLine = hunk.newStart; + const endLine = hunk.newStart + Math.max(hunk.newCount, 1) - 1; + const addedLines: number[] = []; + let added = 0; + let removed = 0; + let newLine = hunk.newStart; + for (const body of hunk.body) { + const prefix = body[0]; + if (prefix === '+') { + addedLines.push(newLine); + added++; + newLine++; + } else if (prefix === ' ') { + newLine++; + } else if (prefix === '-') { + removed++; + } + } + return { hunkIndex, startLine, endLine, addedLines, addedCount: added, removedCount: removed }; +} diff --git a/src/extension.ts b/src/extension.ts index 537a1bb..ab09514 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { StashTreeProvider } from './adapter/tree/stash-tree'; import { CommitWebviewProvider } from './adapter/webview/commit-webview'; import { GraphWebview } from './adapter/webview/graph-webview'; import { showGitConsole } from './infra/git-console'; +import { InlineCommitCodeLensProvider, registerInlineCommitCommand } from './adapter/editor/inline-commit-codelens'; import { getGitApi } from './adapter/git-api'; import { GitRepositoryService } from './adapter/git-repository-service'; import { createLogger } from './infra/logger'; @@ -63,6 +64,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const logTree = new LogTreeProvider(service); const branchesTree = new BranchesTreeProvider(service); const stashTree = new StashTreeProvider(service); + const inlineLens = new InlineCommitCodeLensProvider(service); const focusCommitView = (): void => { void vscode.commands.executeCommand('hyperGit.commit.focus'); }; @@ -89,6 +91,8 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), vscode.commands.registerCommand('hyperGit.showGraph', () => GraphWebview.open(service)), vscode.commands.registerCommand('hyperGit.showConsole', () => showGitConsole()), + vscode.languages.registerCodeLensProvider({ scheme: 'file' }, inlineLens), + registerInlineCommitCommand(service, inlineLens), ); // git 状态变化频繁(add/checkout/diff 缓存失效均触发),防抖合并避免 log/stash 高频重拉。 @@ -101,6 +105,7 @@ export async function activate(context: vscode.ExtensionContext): Promise logTree.refresh(); branchesTree.refresh(); stashTree.refresh(); + inlineLens.refresh(); }, 150); }; context.subscriptions.push( diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index 798f86f..99e18ff 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -63,6 +63,7 @@ suite('扩展冒烟测试', function () { 'hyperGit.cleanupBranches', 'hyperGit.copyBranchRef', 'hyperGit.threeWayDiff', + 'hyperGit.inlineCommitHunk', ]) { assert.ok(commands.includes(cmd), `命令 ${cmd} 未注册`); } diff --git a/tests/unit/editor-mapping.test.ts b/tests/unit/editor-mapping.test.ts new file mode 100644 index 0000000..b024b8a --- /dev/null +++ b/tests/unit/editor-mapping.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { mapFileToEditorRegions } from '../../src/engine/diff/editor-mapping'; +import { parseUnifiedDiff } from '../../src/engine/diff/hunk-parser'; + +const DIFF = `diff --git a/x.txt b/x.txt +index 1..2 100644 +--- a/x.txt ++++ b/x.txt +@@ -1,3 +1,5 @@ + ctx1 +-old1 ++new1 ++new2 + ctx2 +@@ -10,2 +12,3 @@ + ctx10 +-old10 ++new10a ++new10b ++new10c +`; + +describe('mapFileToEditorRegions', () => { + it('每个 hunk 映射为一个 region', () => { + const file = parseUnifiedDiff(DIFF)[0]; + const regions = mapFileToEditorRegions(file); + expect(regions).toHaveLength(2); + }); + + it('正确计算 new 范围行号', () => { + const regions = mapFileToEditorRegions(parseUnifiedDiff(DIFF)[0]); + expect(regions[0]).toMatchObject({ startLine: 1, endLine: 5 }); + expect(regions[1]).toMatchObject({ startLine: 12, endLine: 14 }); + }); + + it('addedLines 仅含 + 行的编辑器行号', () => { + const regions = mapFileToEditorRegions(parseUnifiedDiff(DIFF)[0]); + // hunk0: ctx1(1) old1-> +new1(2) +new2(3) ctx2(4) ... newStart=1 + // +new1 at line 2, +new2 at line 3 + expect(regions[0].addedLines).toEqual([2, 3]); + // hunk1: newStart=12: ctx10(12) +new10a(13) +new10b(14) +new10c(15) + expect(regions[1].addedLines).toEqual([13, 14, 15]); + }); + + it('addedCount / removedCount 统计', () => { + const regions = mapFileToEditorRegions(parseUnifiedDiff(DIFF)[0]); + expect(regions[0]).toMatchObject({ addedCount: 2, removedCount: 1 }); + expect(regions[1]).toMatchObject({ addedCount: 3, removedCount: 1 }); + }); + + it('hunkIndex 与文件 hunks 下标一致', () => { + const regions = mapFileToEditorRegions(parseUnifiedDiff(DIFF)[0]); + expect(regions.map((r) => r.hunkIndex)).toEqual([0, 1]); + }); +});