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 — 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`。
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": {
Expand Down
117 changes: 117 additions & 0 deletions src/adapter/editor/inline-commit-codelens.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;

constructor(private readonly service: GitRepositoryService) {}

refresh(): void {
this._onDidChangeCodeLenses.fire();
}

async provideCodeLenses(doc: vscode.TextDocument): Promise<vscode.CodeLens[]> {
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)}`);
}
});
}
45 changes: 45 additions & 0 deletions src/engine/diff/editor-mapping.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
5 changes: 5 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,6 +64,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
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');
};
Expand All @@ -89,6 +91,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
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 高频重拉。
Expand All @@ -101,6 +105,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
logTree.refresh();
branchesTree.refresh();
stashTree.refresh();
inlineLens.refresh();
}, 150);
};
context.subscriptions.push(
Expand Down
1 change: 1 addition & 0 deletions tests/suite/extension.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ suite('扩展冒烟测试', function () {
'hyperGit.cleanupBranches',
'hyperGit.copyBranchRef',
'hyperGit.threeWayDiff',
'hyperGit.inlineCommitHunk',
]) {
assert.ok(commands.includes(cmd), `命令 ${cmd} 未注册`);
}
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/editor-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Loading