diff --git a/.agents/issue.md b/.agents/issue.md index fb02135..90fc2a2 100644 --- a/.agents/issue.md +++ b/.agents/issue.md @@ -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/其他静态分析工具。 + diff --git a/.vscodeignore b/.vscodeignore index 6a3ca91..397d146 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -6,6 +6,7 @@ node_modules/** .github/** .agents/** .context/** +docs/** .temp/** media-src/** AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7622221..c72edde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 0264ea2..1c9c6a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ export default tseslint.config( 'media/**', 'media-src/**', 'node_modules/**', + '.vscode-test/**', '*.vsix', 'esbuild.js', 'tests/run-integration.js', diff --git a/package.json b/package.json index 6c5ad6e..91ab1b0 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.1.0", + "version": "0.2.0", "publisher": "threefish-ai", "license": "MIT", "preview": true, @@ -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": { diff --git a/src/adapter/changelist-registry.ts b/src/adapter/changelist-registry.ts new file mode 100644 index 0000000..6da180c --- /dev/null +++ b/src/adapter/changelist-registry.ts @@ -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(); + readonly onDidChange: vscode.Event = 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(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(items: readonly T[], keyOf: (item: T) => string): GroupedChangelist[] { + 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(); + } +} diff --git a/src/adapter/commands.ts b/src/adapter/commands.ts new file mode 100644 index 0000000..3d9238d --- /dev/null +++ b/src/adapter/commands.ts @@ -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; +} diff --git a/src/adapter/git-api.ts b/src/adapter/git-api.ts new file mode 100644 index 0000000..7182ade --- /dev/null +++ b/src/adapter/git-api.ts @@ -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 { + if (cached !== undefined) { + return cached; + } + const ext = vscode.extensions.getExtension('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; +} diff --git a/src/adapter/git-repository-service.ts b/src/adapter/git-repository-service.ts new file mode 100644 index 0000000..21fe824 --- /dev/null +++ b/src/adapter/git-repository-service.ts @@ -0,0 +1,93 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { API, Repository } from '../types/git'; +import { FileStatus } from '../engine/model'; +import { mapGitStatus } from './git-status-map'; + +/** 适配层视图模型:一个文件的变更(携带 vscode.Uri 供 diff/操作)。 */ +export interface ChangeItem { + /** 仓库相对路径(posix 分隔),用作 changelist 分组稳定 key。 */ + readonly relativePath: string; + readonly uri: vscode.Uri; + readonly originalUri: vscode.Uri; + readonly renameUri?: vscode.Uri; + readonly status: FileStatus; + readonly staged: boolean; +} + +/** + * GitRepositoryService:封装 vscode.git 的活跃 Repository。 + * 职责:选取活跃仓库、读取变更(→ ChangeItem)、暴露状态变更事件、提供 diff/写操作委托。 + */ +export class GitRepositoryService implements vscode.Disposable { + private _repo: Repository | null = null; + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly api: API) { + this.disposables.push(api.onDidOpenRepository(() => this.pickRepository())); + this.disposables.push(api.onDidCloseRepository(() => this.pickRepository())); + this.pickRepository(); + } + + get repo(): Repository | null { + return this._repo; + } + + get repoRoot(): string | null { + return this._repo?.rootUri.fsPath ?? null; + } + + /** 选取活跃仓库:优先匹配工作区根,否则首个。 */ + private pickRepository(): void { + const folders = vscode.workspace.workspaceFolders; + let repo: Repository | null = null; + if (folders && folders.length > 0) { + const wsRoot = folders[0].uri.fsPath; + repo = this.api.repositories.find((r) => wsRoot.startsWith(r.rootUri.fsPath)) ?? null; + } + if (!repo) { + repo = this.api.repositories[0] ?? null; + } + const changed = repo !== this._repo; + if (changed) { + this._repo = repo; + if (repo) { + this.disposables.push(repo.state.onDidChange(() => this._onDidChange.fire())); + } + } + this._onDidChange.fire(); + } + + /** 读取本地变更(工作区 + 未跟踪),映射为 ChangeItem。 */ + getChanges(): ChangeItem[] { + const repo = this._repo; + if (!repo) { + return []; + } + const root = repo.rootUri.fsPath; + const items: ChangeItem[] = []; + const fromChange = (uri: vscode.Uri, originalUri: vscode.Uri, renameUri: vscode.Uri | undefined, status: number, staged: boolean): ChangeItem => { + const rel = path.relative(root, uri.fsPath).split(path.sep).join('/'); + return { relativePath: rel, uri, originalUri, renameUri, status: mapGitStatus(status), staged }; + }; + for (const c of repo.state.workingTreeChanges) { + items.push(fromChange(c.uri, c.originalUri, c.renameUri ?? undefined, c.status, false)); + } + for (const c of repo.state.untrackedChanges) { + items.push(fromChange(c.uri, c.originalUri, c.renameUri ?? undefined, c.status, false)); + } + return items; + } + + /** 构造任意 ref 版本的资源 Uri(diff 原始端,复用 vscode.git 的 git scheme)。 */ + toGitUri(uri: vscode.Uri, ref: string): vscode.Uri { + return this.api.toGitUri(uri, ref); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + this._onDidChange.dispose(); + } +} diff --git a/src/adapter/git-status-map.ts b/src/adapter/git-status-map.ts new file mode 100644 index 0000000..44c9c2c --- /dev/null +++ b/src/adapter/git-status-map.ts @@ -0,0 +1,67 @@ +import { FileStatus } from '../engine/model'; + +/** + * vscode.git `Status` 枚举数值镜像(单一事实源)。 + * + * 原枚举为 git.d.ts 中的 `const enum`,esbuild 打包 `.d.ts` 时无法在运行时取值, + * 故在此以普通 const 对象镜像其数值(与 extensions/git/src/api/git.d.ts 严格一致)。 + */ +export const GitStatus = { + INDEX_MODIFIED: 0, + INDEX_ADDED: 1, + INDEX_DELETED: 2, + INDEX_RENAMED: 3, + INDEX_COPIED: 4, + + MODIFIED: 5, + DELETED: 6, + UNTRACKED: 7, + IGNORED: 8, + INTENT_TO_ADD: 9, + INTENT_TO_RENAME: 10, + TYPE_CHANGED: 11, + + ADDED_BY_US: 12, + ADDED_BY_THEM: 13, + DELETED_BY_US: 14, + DELETED_BY_THEM: 15, + BOTH_ADDED: 16, + BOTH_DELETED: 17, + BOTH_MODIFIED: 18, +} as const; +export type GitStatus = (typeof GitStatus)[keyof typeof GitStatus]; + +/** vscode.git Status 数值 → 本扩展 FileStatus 领域模型(纯函数)。 */ +export function mapGitStatus(status: number): FileStatus { + switch (status) { + case GitStatus.INDEX_MODIFIED: + case GitStatus.MODIFIED: + case GitStatus.TYPE_CHANGED: + case GitStatus.INTENT_TO_ADD: + return FileStatus.Modified; + case GitStatus.INDEX_ADDED: + case GitStatus.ADDED_BY_US: + return FileStatus.Added; + case GitStatus.INDEX_DELETED: + case GitStatus.DELETED: + case GitStatus.DELETED_BY_US: + case GitStatus.DELETED_BY_THEM: + return FileStatus.Deleted; + case GitStatus.UNTRACKED: + return FileStatus.Untracked; + case GitStatus.INDEX_RENAMED: + case GitStatus.INTENT_TO_RENAME: + return FileStatus.Renamed; + case GitStatus.INDEX_COPIED: + return FileStatus.Copied; + case GitStatus.IGNORED: + return FileStatus.Ignored; + case GitStatus.BOTH_ADDED: + case GitStatus.BOTH_DELETED: + case GitStatus.BOTH_MODIFIED: + case GitStatus.ADDED_BY_THEM: + return FileStatus.Conflict; + default: + return FileStatus.Modified; + } +} diff --git a/src/adapter/tree/changes-tree.ts b/src/adapter/tree/changes-tree.ts new file mode 100644 index 0000000..4f1f5a9 --- /dev/null +++ b/src/adapter/tree/changes-tree.ts @@ -0,0 +1,105 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { getDecoration } from '../../engine/scm-mapping/status-decoration'; +import type { ChangelistRegistry } from '../changelist-registry'; +import type { ChangeItem, GitRepositoryService } from '../git-repository-service'; + +export interface ChangesChangelistNode { + readonly kind: 'changelist'; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly active: boolean; + readonly items: readonly ChangeItem[]; +} + +export interface ChangesFileNode { + readonly kind: 'file'; + readonly item: ChangeItem; + readonly changelistId: string; +} + +export type ChangesNode = ChangesChangelistNode | ChangesFileNode; + +/** + * Changes 视图 TreeDataProvider:changelist 一级节点 + 文件叶子。 + * 状态色用 ThemeIcon(circle-filled) + gitDecoration 主题色;文件单击打开原生 diff。 + */ +export class ChangesTreeProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private readonly service: GitRepositoryService, + private readonly registry: ChangelistRegistry, + ) {} + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getChildren(element?: ChangesNode): ChangesNode[] { + if (!element) { + const items = this.service.getChanges(); + const groups = this.registry.getGroups(items, (i) => i.relativePath); + return groups + .filter((g) => g.active || g.items.length > 0) + .map((g): ChangesChangelistNode => ({ + kind: 'changelist', + id: g.id, + name: g.name, + description: g.description, + active: g.active, + items: g.items, + })); + } + if (element.kind === 'changelist') { + return element.items.map((item): ChangesFileNode => ({ kind: 'file', item, changelistId: element.id })); + } + return []; + } + + getTreeItem(element: ChangesNode): vscode.TreeItem { + if (element.kind === 'changelist') { + return this.createChangelistItem(element); + } + return this.createFileItem(element); + } + + private createChangelistItem(node: ChangesChangelistNode): vscode.TreeItem { + const count = node.items.length; + const state = count > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None; + const item = new vscode.TreeItem(node.name, state); + item.contextValue = 'hyperGit.changelist'; + item.id = `cl:${node.id}`; + item.iconPath = new vscode.ThemeIcon('folder'); + item.description = `${count} ${count === 1 ? 'file' : 'files'}${node.active ? ' · active' : ''}`; + item.tooltip = `${node.name}${node.active ? ' (active)' : ''}\n${count} changes`; + return item; + } + + private createFileItem(node: ChangesFileNode): vscode.TreeItem { + const change = node.item; + const decoration = getDecoration(change.status); + const dir = path.dirname(change.relativePath); + const treeItem = new vscode.TreeItem(path.basename(change.relativePath), vscode.TreeItemCollapsibleState.None); + treeItem.contextValue = 'hyperGit.fileChange'; + treeItem.id = `file:${change.relativePath}`; + treeItem.resourceUri = change.uri; + treeItem.description = `${decoration.letter}${dir && dir !== '.' ? ' · ' + dir : ''}`; + treeItem.tooltip = `${change.relativePath}\n状态:${change.status}${change.staged ? '(已暂存)' : ''}`; + treeItem.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor(decoration.themeColor)); + treeItem.command = { command: 'hyperGit.openDiff', title: '打开 Diff', arguments: [change] }; + return treeItem; + } +} + +/** 无 git 时的占位 provider(空树,触发 viewsWelcome)。 */ +export class EmptyChangesProvider implements vscode.TreeDataProvider { + getTreeItem(): vscode.TreeItem { + return new vscode.TreeItem(''); + } + getChildren(): never[] { + return []; + } +} diff --git a/src/engine/changelist/grouper.ts b/src/engine/changelist/grouper.ts new file mode 100644 index 0000000..26bc503 --- /dev/null +++ b/src/engine/changelist/grouper.ts @@ -0,0 +1,69 @@ +/** + * Changelist 分组纯逻辑(零 vscode 依赖,可单测)。 + * + * 仿 IntelliJ IDEA `ChangeListManager`:变更项(item)按 key(文件相对路径) + * 分配到命名 changelist;未显式分配者落入 active changelist(IDEA 默认行为)。 + */ + +export interface ChangelistDef { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +/** fileKey → changelistId 的分配表(持久化于 workspaceState)。 */ +export type ChangelistAssignment = Record; + +export interface GroupedChangelist extends ChangelistDef { + readonly active: boolean; + readonly items: readonly T[]; +} + +/** + * 将 items 按 changelist 分组。 + * + * @param items 变更项 + * @param keyOf 提取 item 的稳定 key(文件相对路径) + * @param defs changelist 定义(决定输出顺序) + * @param assignments fileKey→changelistId 分配表 + * @param activeId 当前 active changelist id(未分配项的兜底归属) + */ +export function groupByChangelist( + items: readonly T[], + keyOf: (item: T) => string, + defs: readonly ChangelistDef[], + assignments: ChangelistAssignment, + activeId: string, +): GroupedChangelist[] { + // 未分配项始终落入 active changelist(IDEA 语义:新改动默认归活动列表)。 + const fallbackId = activeId; + + const buckets = new Map(); + for (const def of defs) { + buckets.set(def.id, []); + } + if (!buckets.has(activeId)) { + buckets.set(activeId, []); + } + + for (const item of items) { + const key = keyOf(item); + let id = assignments[key] ?? fallbackId; + if (!buckets.has(id)) { + id = fallbackId; + } + buckets.get(id)!.push(item); + } + + const result: GroupedChangelist[] = []; + const seen = new Set(); + for (const def of defs) { + seen.add(def.id); + result.push({ ...def, active: def.id === activeId, items: buckets.get(def.id) ?? [] }); + } + // 兜底:active changelist 不在 defs 中时补一项 + if (!seen.has(activeId)) { + result.push({ id: activeId, name: activeId, active: true, items: buckets.get(activeId) ?? [] }); + } + return result; +} diff --git a/src/extension.ts b/src/extension.ts index 0f6cfab..eb92fb2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,41 +1,52 @@ import * as vscode from 'vscode'; -import { createLogger } from './infra/logger'; import { NullLlmProvider } from './agent/llm-provider'; +import { registerChangesCommands } from './adapter/commands'; +import { ChangelistRegistry } from './adapter/changelist-registry'; +import { ChangesTreeProvider, EmptyChangesProvider } from './adapter/tree/changes-tree'; +import { getGitApi } from './adapter/git-api'; +import { GitRepositoryService } from './adapter/git-repository-service'; +import { createLogger } from './infra/logger'; /** * 扩展入口。仅做装配(DI 注册),业务逻辑下沉到 engine/adapter 层。 */ -export function activate(context: vscode.ExtensionContext): void { +export async function activate(context: vscode.ExtensionContext): Promise { const logger = createLogger(); logger.info('Hyper Git activated'); - // AI 接缝注入(Null 实现,M5 替换为真实 ILlmProvider)。 const llm = new NullLlmProvider(); - const showVersion = vscode.commands.registerCommand('hyperGit.showVersion', () => { - const version: string = context.extension.packageJSON.version; - vscode.window.showInformationMessage(`Hyper Git v${version}`); - logger.info(`showVersion: version=${version}, llmSource=${llm.sourceId}`); - }); - - // Changes 视图占位(M1 接入真实 changelist registry + vscode.git workingTreeChanges)。 - const changesProvider = new PlaceholderChangesProvider(); - const changesView = vscode.window.registerTreeDataProvider('hyperGit.changes', changesProvider); + context.subscriptions.push( + vscode.commands.registerCommand('hyperGit.showVersion', () => { + const version: string = context.extension.packageJSON.version; + vscode.window.showInformationMessage(`Hyper Git v${version}`); + logger.info(`version=${version}, llmSource=${llm.sourceId}`); + }), + ); + + // M1:Git Adapter + Changes TreeView(多 changelist) + const api = await getGitApi(); + if (!api) { + logger.warn('vscode.git API 不可用,Changes 视图保持空状态'); + context.subscriptions.push(vscode.window.registerTreeDataProvider('hyperGit.changes', new EmptyChangesProvider())); + return; + } - context.subscriptions.push(showVersion, changesView); + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? 'default'; + const service = new GitRepositoryService(api); + const registry = new ChangelistRegistry(context.workspaceState, service.repoRoot ?? workspaceRoot); + const tree = new ChangesTreeProvider(service, registry); + + context.subscriptions.push( + service, + registry, + vscode.window.registerTreeDataProvider('hyperGit.changes', tree), + ...registerChangesCommands(service, registry, tree), + ); + service.onDidChange(() => tree.refresh()); + registry.onDidChange(() => tree.refresh()); } export function deactivate(): void { - // 预留:M1+ 在此释放长生命周期资源。 -} - -/** M0 占位:空树 → 触发 viewsWelcome 内容显示。M1 替换为真实 changelist TreeDataProvider。 */ -class PlaceholderChangesProvider implements vscode.TreeDataProvider { - getTreeItem(element: vscode.TreeItem): vscode.TreeItem { - return element; - } - - getChildren(): vscode.TreeItem[] { - return []; - } + // 预留:M2+ 在此释放长生命周期资源。 } diff --git a/src/types/git.d.ts b/src/types/git.d.ts new file mode 100644 index 0000000..e1fa868 --- /dev/null +++ b/src/types/git.d.ts @@ -0,0 +1,237 @@ +/*---------------------------------------------------------------------------------------------* + * VS Code 内置 git 扩展导出 API 的类型契约(消费侧)。 + * 改编自 microsoft/vscode extensions/git/src/api/git.d.ts(MIT License)。 + * + * 调整说明(相对官方原文件): + * - 将 `const enum`(Status / RefType / ForcePushMode / GitErrorCodes)改为 `number`, + * 以规避 esbuild 打包时 `.d.ts` 中 const enum 无法在运行时取值的问题; + * Status 数值语义见 src/adapter/git-status-map.ts 的 GitStatus 镜像(单一事实源)。 + * - 裁剪 M1-M5 不直接消费的 provider 注册接口,保留 Repository 全量方法以利后续里程碑。 + *--------------------------------------------------------------------------------------------*/ + +import { Uri, Event, CancellationToken } from 'vscode'; + +export interface Git { + readonly path: string; +} +export interface InputBox { + value: string; +} + +export type RefType = number; // 0=Head, 1=RemoteHead, 2=Tag +export type ForcePushMode = number; // 0=Force, 1=ForceWithLease, 2=ForceWithLeaseIfIncludes + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} +export interface UpstreamRef { + readonly remote: string; + readonly name: string; + readonly commit?: string; +} +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} +export interface CommitShortStat { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; + readonly shortStat?: CommitShortStat; +} +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} +export interface Worktree { + readonly name: string; + readonly path: string; + readonly ref: string; + readonly main: boolean; + readonly detached: boolean; +} + +export interface Change { + /** 优先使用:rename 时为 renameUri,否则 originalUri。 */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + /** 变更状态(数值),语义见 src/adapter/git-status-map.ts 的 GitStatus 镜像。 */ + readonly status: number; +} +export interface DiffChange extends Change { + readonly insertions: number; + readonly deletions: number; +} + +export type RepositoryKind = 'repository' | 'submodule' | 'worktree'; + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly worktrees: Worktree[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; + + readonly onDidChange: Event; +} +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +export interface LogOptions { + readonly maxEntries?: number; + readonly path?: string; + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; + readonly author?: string; + readonly grep?: string; + readonly refNames?: string[]; + readonly maxParents?: number; + readonly skip?: number; +} +export interface CommitOptions { + all?: boolean | 'tracked'; + amend?: boolean; + signoff?: boolean; + signCommit?: boolean; + empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + postCommitCommand?: string | null; +} +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} +export interface InitOptions { + defaultBranch?: string; +} +export interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string | string[]; + readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; +} +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; +} + +export interface Repository { + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + readonly kind: RepositoryKind; + readonly isUsingVirtualFileSystem: boolean; + + readonly onDidCommit: Event; + readonly onDidCheckout: Event; + + getConfigs(): Promise<{ key: string; value: string; }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + unsetConfig(key: string): Promise; + getGlobalConfig(key: string): Promise; + + add(paths: string[]): Promise; + revert(paths: string[]): Promise; + clean(paths: string[]): Promise; + restore(paths: string[], options?: { staged?: boolean; ref?: string }): Promise; + + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; + pull(unshallow?: boolean): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; + merge(ref: string): Promise; + mergeAbort(): Promise; + rebase(branch: string): Promise; + + createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise; + applyStash(index?: number): Promise; + popStash(index?: number): Promise; + dropStash(index?: number): Promise; +} + +export type APIState = 'uninitialized' | 'initialized'; +export interface PublishEvent { + repository: Repository; + branch?: string; +} + +export interface API { + readonly state: APIState; + readonly onDidChangeState: Event; + readonly onDidPublish: Event; + readonly git: Git; + readonly repositories: Repository[]; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; + getRepository(uri: Uri): Repository | null; + getRepositoryRoot(uri: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + openRepository(root: Uri): Promise; +} + +export interface GitExtension { + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + getAPI(version: 1): API; +} diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index 4e2af0f..486e3f0 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -1,16 +1,29 @@ const assert = require('assert'); const vscode = require('vscode'); +const EXT_ID = 'threefish-ai.hyper-git'; + suite('扩展冒烟测试', function () { this.timeout(30000); - test('扩展可激活并注册 hyperGit.showVersion 命令', async () => { - const ext = vscode.extensions.getExtension('threefish-ai.hyper-git'); - assert.ok(ext, '扩展 threefish-ai.hyper-git 未找到'); + test('扩展可激活并注册全部 M1 命令', async () => { + const ext = vscode.extensions.getExtension(EXT_ID); + assert.ok(ext, `扩展 ${EXT_ID} 未找到`); if (!ext.isActive) { await ext.activate(); } const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes('hyperGit.showVersion'), '命令 hyperGit.showVersion 未注册'); + for (const cmd of [ + 'hyperGit.showVersion', + 'hyperGit.refresh', + 'hyperGit.newChangelist', + 'hyperGit.setActiveChangelist', + 'hyperGit.renameChangelist', + 'hyperGit.deleteChangelist', + 'hyperGit.moveChangelist', + 'hyperGit.openDiff', + ]) { + assert.ok(commands.includes(cmd), `命令 ${cmd} 未注册`); + } }); }); diff --git a/tests/unit/changelist-grouper.test.ts b/tests/unit/changelist-grouper.test.ts new file mode 100644 index 0000000..08ad8a4 --- /dev/null +++ b/tests/unit/changelist-grouper.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { groupByChangelist } from '../../src/engine/changelist/grouper'; +import type { ChangelistDef } from '../../src/engine/changelist/grouper'; + +const DEFS: ChangelistDef[] = [ + { id: 'default', name: 'Default' }, + { id: 'fx', name: 'Feature X' }, +]; + +describe('groupByChangelist', () => { + it('未分配项落入 active changelist', () => { + const items = [{ k: 'a.ts' }, { k: 'b.ts' }]; + const groups = groupByChangelist(items, (i) => i.k, DEFS, {}, 'default'); + const def = groups.find((g) => g.id === 'default')!; + expect(def.items).toHaveLength(2); + expect(def.active).toBe(true); + }); + + it('按 assignment 分配到指定 changelist', () => { + const items = [{ k: 'a.ts' }, { k: 'b.ts' }]; + const groups = groupByChangelist(items, (i) => i.k, DEFS, { 'a.ts': 'fx' }, 'default'); + expect(groups.find((g) => g.id === 'fx')!.items.map((i) => i.k)).toEqual(['a.ts']); + expect(groups.find((g) => g.id === 'default')!.items.map((i) => i.k)).toEqual(['b.ts']); + }); + + it('保留 defs 顺序并标记 active', () => { + const groups = groupByChangelist([], (i: { k: string }) => i.k, DEFS, {}, 'fx'); + expect(groups.map((g) => g.id)).toEqual(['default', 'fx']); + expect(groups[0].active).toBe(false); + expect(groups[1].active).toBe(true); + }); + + it('assignment 指向不存在的 changelist 时回退到 active', () => { + const items = [{ k: 'a.ts' }]; + const groups = groupByChangelist(items, (i) => i.k, DEFS, { 'a.ts': 'ghost' }, 'default'); + expect(groups.find((g) => g.id === 'default')!.items).toHaveLength(1); + }); + + it('active 不在 defs 中时补一项兜底', () => { + const items = [{ k: 'a.ts' }]; + const groups = groupByChangelist(items, (i) => i.k, DEFS, {}, 'orphan'); + const orphan = groups.find((g) => g.id === 'orphan'); + expect(orphan).toBeTruthy(); + expect(orphan!.active).toBe(true); + expect(orphan!.items).toHaveLength(1); + }); +}); diff --git a/tests/unit/git-status-map.test.ts b/tests/unit/git-status-map.test.ts new file mode 100644 index 0000000..31766e0 --- /dev/null +++ b/tests/unit/git-status-map.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { GitStatus, mapGitStatus } from '../../src/adapter/git-status-map'; +import { FileStatus } from '../../src/engine/model'; + +describe('mapGitStatus', () => { + it('MODIFIED / INDEX_MODIFIED → Modified', () => { + expect(mapGitStatus(GitStatus.MODIFIED)).toBe(FileStatus.Modified); + expect(mapGitStatus(GitStatus.INDEX_MODIFIED)).toBe(FileStatus.Modified); + }); + + it('INDEX_ADDED → Added', () => { + expect(mapGitStatus(GitStatus.INDEX_ADDED)).toBe(FileStatus.Added); + }); + + it('UNTRACKED → Untracked', () => { + expect(mapGitStatus(GitStatus.UNTRACKED)).toBe(FileStatus.Untracked); + }); + + it('DELETED / INDEX_DELETED → Deleted', () => { + expect(mapGitStatus(GitStatus.DELETED)).toBe(FileStatus.Deleted); + expect(mapGitStatus(GitStatus.INDEX_DELETED)).toBe(FileStatus.Deleted); + }); + + it('INDEX_RENAMED → Renamed', () => { + expect(mapGitStatus(GitStatus.INDEX_RENAMED)).toBe(FileStatus.Renamed); + }); + + it('INDEX_COPIED → Copied', () => { + expect(mapGitStatus(GitStatus.INDEX_COPIED)).toBe(FileStatus.Copied); + }); + + it('BOTH_MODIFIED → Conflict', () => { + expect(mapGitStatus(GitStatus.BOTH_MODIFIED)).toBe(FileStatus.Conflict); + }); + + it('IGNORED → Ignored', () => { + expect(mapGitStatus(GitStatus.IGNORED)).toBe(FileStatus.Ignored); + }); + + it('未知数值 → Modified(兜底)', () => { + expect(mapGitStatus(999)).toBe(FileStatus.Modified); + }); +});