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 .agents/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@
- **后续防范**:凡含 `@vscode/test-electron` 集成测试的 CI job,必须在测试前显式构建扩展产物;本地验证集成测试后清理 dist/ 以暴露该依赖;`.gitignore` 排除 dist/ 时注意 CI 需重建。
- **同类问题影响**:所有 VS Code 扩展的 test-electron CI job;本地"能跑"但 CI 失败的构建产物缺失类问题。

## #5 ESLint flat config 遍历 .vscode-test 导致 OOM

- **表因**:本地 `pnpm run lint` 在 ~70s 后 `FATAL ERROR: ... JavaScript heap out of memory`(4GB 耗尽);M0 时却正常。
- **根因**:`@vscode/test-electron` 首次运行将完整 VS Code(约 260MB、海量 JS)下载到 `.vscode-test/`;ESLint 9 flat config 默认仅忽略 `node_modules`,**不忽略 `.vscode-test/`**,于是 eslint 遍历其下成千上万 JS 文件导致 OOM。M0 lint 通过是因为当时 `.vscode-test/` 尚未生成。
- **处理方式**:在 `eslint.config.mjs` 的 `ignores` 增加 `.vscode-test/**`。
- **后续防范**:含 test-electron 的扩展,eslint ignores 必须含 `.vscode-test/**`(及 `out/**`、`dist/**`、`*.vsix`);CI 因不缓存该目录可能不暴露,但本地必现——本地与 CI 环境差异需警惕。
- **同类问题影响**:所有跑过 test-electron 的本地环境的 eslint/其他静态分析工具。

1 change: 1 addition & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ node_modules/**
.github/**
.agents/**
.context/**
docs/**
.temp/**
media-src/**
AGENTS.md
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## [Unreleased]

### Added — M1 Git Adapter + 多 changelist Changes(0.2.0)

- Git Adapter:`GitRepositoryService` 封装内置 vscode.git 稳定 `Repository` API(读取 workingTreeChanges/untrackedChanges、状态变更事件、diff/toGitUri 委托)。
- 多 changelist:`ChangelistRegistry`(active 列表、新建/重命名/删除/移动 + `workspaceState` 持久化,重启恢复)+ 引擎纯分组逻辑 `groupByChangelist`。
- Changes TreeView:changelist 一级节点 + 文件叶子,状态色复用 `gitDecoration.*` 主题色(ThemeIcon + ThemeColor),文件单击打开原生 `vscode.diff`。
- 命令:`refresh` / `newChangelist` / `setActiveChangelist` / `renameChangelist` / `deleteChangelist` / `moveChangelist` / `openDiff`,配视图标题与右键菜单(`viewItem` 上下文键)。
- 测试:新增 `changelist-grouper`(5)+ `git-status-map`(9)单元测试;集成测试覆盖全部 M1 命令注册。
- 工程修复:eslint flat config 忽略 `.vscode-test/**`(规避本地 test-electron 下载的 VS Code 导致 lint OOM)。

### Added — M0 脚手架 + CI

- 初始化 pnpm + esbuild + TypeScript(strict)工程骨架,对齐官方 `esbuild-sample`。
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default tseslint.config(
'media/**',
'media-src/**',
'node_modules/**',
'.vscode-test/**',
'*.vsix',
'esbuild.js',
'tests/run-integration.js',
Expand Down
37 changes: 30 additions & 7 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.1.0",
"version": "0.2.0",
"publisher": "threefish-ai",
"license": "MIT",
"preview": true,
Expand Down Expand Up @@ -63,16 +63,39 @@
"viewsWelcome": [
{
"view": "hyperGit.changes",
"contents": "Hyper Git 脚手架已就绪(M0)。\nLocal Changes 变更列表(多 changelist)将在 M1 接入 vscode.git API。\n[显示版本](command:hyperGit.showVersion)"
"contents": "未检测到 Git 变更或仓库。\n在 Git 仓库中打开工作区,变更将按 Changelist 分组显示。\n[新建 Changelist](command:hyperGit.newChangelist)"
}
],
"commands": [
{
"command": "hyperGit.showVersion",
"title": "显示版本",
"category": "Hyper Git"
}
{ "command": "hyperGit.showVersion", "title": "显示版本", "category": "Hyper Git" },
{ "command": "hyperGit.refresh", "title": "刷新", "category": "Hyper Git", "icon": "$(refresh)" },
{ "command": "hyperGit.newChangelist", "title": "新建 Changelist", "category": "Hyper Git", "icon": "$(add)" },
{ "command": "hyperGit.setActiveChangelist", "title": "设为活动 Changelist", "category": "Hyper Git" },
{ "command": "hyperGit.renameChangelist", "title": "重命名 Changelist", "category": "Hyper Git" },
{ "command": "hyperGit.deleteChangelist", "title": "删除 Changelist", "category": "Hyper Git" },
{ "command": "hyperGit.moveChangelist", "title": "移至 Changelist…", "category": "Hyper Git" },
{ "command": "hyperGit.openDiff", "title": "打开 Diff", "category": "Hyper Git" }
],
"menus": {
"view/title": [
{ "command": "hyperGit.refresh", "when": "view == hyperGit.changes", "group": "navigation" },
{ "command": "hyperGit.newChangelist", "when": "view == hyperGit.changes", "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" }
],
"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" }
]
},
"configuration": {
"title": "Hyper Git",
"properties": {
Expand Down
129 changes: 129 additions & 0 deletions src/adapter/changelist-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as vscode from 'vscode';
import { groupByChangelist } from '../engine/changelist/grouper';
import type { ChangelistAssignment, ChangelistDef, GroupedChangelist } from '../engine/changelist/grouper';

const DEFAULT_ID = 'default';
const DEFAULT_NAME = 'Default';

interface PersistedState {
defs: ChangelistDef[];
activeId: string;
assignments: ChangelistAssignment;
}

/**
* ChangelistRegistry:仿 IDEA ChangeListManager 的多 changelist 管理。
* 维护命名 changelist 列表 + fileKey→changelist 分配表 + active changelist;
* 持久化于 workspaceState(按仓库根隔离),重启后恢复。
*/
export class ChangelistRegistry implements vscode.Disposable {
private defs: ChangelistDef[];
private activeId: string;
private assignments: ChangelistAssignment;
private readonly storageKey: string;
private readonly _onDidChange = new vscode.EventEmitter<void>();
readonly onDidChange: vscode.Event<void> = this._onDidChange.event;

constructor(private readonly workspaceState: vscode.Memento, repoRoot: string) {
this.storageKey = `hyperGit.changelists:${repoRoot}`;
const loaded = this.load();
this.defs = loaded.defs;
this.activeId = loaded.activeId;
this.assignments = loaded.assignments;
}

private load(): PersistedState {
const fallback: PersistedState = { defs: [{ id: DEFAULT_ID, name: DEFAULT_NAME }], activeId: DEFAULT_ID, assignments: {} };
const raw = this.workspaceState.get<string>(this.storageKey);
if (!raw) {
return fallback;
}
try {
const s = JSON.parse(raw) as PersistedState;
if (!s.defs?.length) {
s.defs = fallback.defs;
}
if (!s.activeId || !s.defs.some((d) => d.id === s.activeId)) {
s.activeId = s.defs[0].id;
}
s.assignments = s.assignments ?? {};
return s;
} catch {
return fallback;
}
}

private persist(): void {
const state: PersistedState = { defs: this.defs, activeId: this.activeId, assignments: this.assignments };
void this.workspaceState.update(this.storageKey, JSON.stringify(state));
this._onDidChange.fire();
}

getGroups<T>(items: readonly T[], keyOf: (item: T) => string): GroupedChangelist<T>[] {
return groupByChangelist(items, keyOf, this.defs, this.assignments, this.activeId);
}

listDefs(): readonly ChangelistDef[] {
return this.defs;
}

get activeChangelistId(): string {
return this.activeId;
}

getDef(id: string): ChangelistDef | undefined {
return this.defs.find((d) => d.id === id);
}

create(name: string): string {
const id = `cl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
this.defs = [...this.defs, { id, name }];
this.persist();
return id;
}

rename(id: string, name: string): void {
if (id === DEFAULT_ID) {
return; // 默认列表名固定
}
this.defs = this.defs.map((d) => (d.id === id ? { ...d, name } : d));
this.persist();
}

setActive(id: string): void {
if (!this.defs.some((d) => d.id === id)) {
return;
}
this.activeId = id;
this.persist();
}

/** 删除 changelist;其下文件重新归属到默认列表。 */
remove(id: string): void {
if (id === DEFAULT_ID || this.defs.length <= 1) {
return;
}
this.defs = this.defs.filter((d) => d.id !== id);
const reassigned: ChangelistAssignment = {};
for (const [key, cid] of Object.entries(this.assignments)) {
reassigned[key] = cid === id ? DEFAULT_ID : cid;
}
this.assignments = reassigned;
if (this.activeId === id) {
this.activeId = DEFAULT_ID;
}
this.persist();
}

move(fileKey: string, targetId: string): void {
if (!this.defs.some((d) => d.id === targetId)) {
return;
}
this.assignments = { ...this.assignments, [fileKey]: targetId };
this.persist();
}

dispose(): void {
this._onDidChange.dispose();
}
}
98 changes: 98 additions & 0 deletions src/adapter/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as path from 'path';
import * as vscode from 'vscode';
import type { ChangelistRegistry } from './changelist-registry';
import type { ChangeItem, GitRepositoryService } from './git-repository-service';
import type { ChangesNode, ChangesTreeProvider } from './tree/changes-tree';

/** 注册 Changes 视图相关命令(M1)。 */
export function registerChangesCommands(
service: GitRepositoryService,
registry: ChangelistRegistry,
tree: ChangesTreeProvider,
): vscode.Disposable[] {
const subs: vscode.Disposable[] = [];

subs.push(
vscode.commands.registerCommand('hyperGit.refresh', () => {
void service.repo?.status();
tree.refresh();
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.newChangelist', async () => {
const name = await vscode.window.showInputBox({ prompt: '新建 Changelist 名称', placeHolder: '例如 feature-x' });
if (name && name.trim()) {
registry.create(name.trim());
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.setActiveChangelist', (node: ChangesNode) => {
if (node?.kind === 'changelist') {
registry.setActive(node.id);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.renameChangelist', async (node: ChangesNode) => {
if (node?.kind !== 'changelist') {
return;
}
const def = registry.getDef(node.id);
const name = await vscode.window.showInputBox({ prompt: '重命名 Changelist', value: def?.name });
if (name && name.trim()) {
registry.rename(node.id, name.trim());
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.deleteChangelist', async (node: ChangesNode) => {
if (node?.kind !== 'changelist') {
return;
}
const choice = await vscode.window.showWarningMessage(
`删除 Changelist「${node.name}」?其下文件将归入默认列表。`,
{ modal: true },
'删除',
);
if (choice === '删除') {
registry.remove(node.id);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.moveChangelist', async (node: ChangesNode) => {
if (node?.kind !== 'file') {
return;
}
const active = registry.activeChangelistId;
const picks = registry
.listDefs()
.map((d) => ({ label: d.name, id: d.id, description: d.id === active ? 'active' : undefined, picked: d.id === node.changelistId }));
const pick = await vscode.window.showQuickPick(picks, { placeHolder: '将文件移至 Changelist' });
if (pick) {
registry.move(node.item.relativePath, pick.id);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.openDiff', async (change: ChangeItem) => {
const repo = service.repo;
if (!repo) {
return;
}
const left = service.toGitUri(change.uri, 'HEAD');
const right = change.uri;
const title = `${path.basename(change.relativePath)} (HEAD ↔ Working)`;
await vscode.commands.executeCommand('vscode.diff', left, right, title);
}),
);

return subs;
}
33 changes: 33 additions & 0 deletions src/adapter/git-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as vscode from 'vscode';
import type { API, GitExtension } from '../types/git';

let cached: API | null | undefined;

/**
* 获取内置 vscode.git 扩展导出的稳定 API(getAPI(1));不可用时返回 null。
* 依赖声明:package.json `extensionDependencies: ["vscode.git"]`。
*/
export async function getGitApi(): Promise<API | null> {
if (cached !== undefined) {
return cached;
}
const ext = vscode.extensions.getExtension<GitExtension>('vscode.git');
if (!ext) {
cached = null;
return null;
}
if (!ext.isActive) {
await ext.activate();
}
if (!ext.exports?.enabled) {
cached = null;
return null;
}
cached = ext.exports.getAPI(1);
return cached;
}

/** 重置缓存(测试用)。 */
export function resetGitApiCache(): void {
cached = undefined;
}
Loading
Loading