diff --git a/PROJECT_INIT.md b/PROJECT_INIT.md index 114b67c..43a2ab9 100644 --- a/PROJECT_INIT.md +++ b/PROJECT_INIT.md @@ -1,22 +1,24 @@ -# AI Code Diff Preview - VSCode 插件立项文档 +# AI Code Diff Preview - VSCode 插件架构文档 ## 一、项目概述 ### 1.1 项目名称 -**AI Code Diff Preview** - 类 Cursor AI 代码差异预览插件 +**AI Code Diff Preview** — 类 Cursor AI 代码差异预览 VSCode 插件 ### 1.2 项目目标 -实现类似 Cursor 的 Accept/Reject 差异预览功能,通过 Claude Hooks 触发,在 VSCode 中提供: -- AI 生成代码的临时隔离层 -- 实时 Diff 差分预览 -- 逐行/整块 Accept/Reject 操作 +在 VSCode 中实现类似 Cursor 的 Accept/Reject 差异预览功能: +- AI 代码编辑自动收集到变更集 +- 调用 **VSCode 内置 Diff 编辑器** 展示差异(不自定义 Webview) +- 红绿对应的 **浮动 Accept/Reject 标签**,固定在每次变更和每个文件变更上方 +- 逐文件 / 逐次编辑的精细化 Accept/Reject 操作 - 多文件修改统一管理 ### 1.3 核心价值 -- **安全隔离**: AI 生成的代码不直接覆盖本地文件 -- **可视化对比**: 红绿高亮显示删除/新增内容 -- **灵活控制**: 支持单行接受、整块拒绝等精细化操作 -- **无缝集成**: 通过 Claude Hooks 自动触发,无需手动操作 +- **原生体验**:直接使用 VSCode 内置 `vscode.diff` 命令,风格统一、交互一致 +- **安全隔离**:AI 编辑期间可选择不弹窗打断,对话结束后统一审查 +- **浮动操作**:Accept/Reject 标签固定在编辑器顶部/变更行上方,不干扰阅读 +- **精细控制**:支持单次编辑粒度(每次 Edit/Write 独立审核)、文件粒度 Accept/Reject +- **灵活触发**:可配置「每次编辑自动弹 Diff」「对话结束后统一弹完整多文件预览」 --- @@ -27,24 +29,26 @@ | 技术领域 | 选型方案 | 理由 | |---------|---------|------| | **插件框架** | VSCode Extension API | 官方原生支持,API 完善 | -| **开发语言** | TypeScript | 类型安全,VSCode 插件官方推荐 | -| **Diff 算法** | `diff` 库 (Myers 算法) | 成熟稳定,支持行级/字符级差异 | +| **开发语言** | TypeScript (ES2022) | 类型安全,VSCode 插件官方推荐 | +| **Diff 渲染** | `vscode.commands.executeCommand('vscode.diff', ...)` | **VSCode 内置 Diff 编辑器**,原生交互,无需自建 Webview | +| **浮动 UI** | `vscode.window.createTextEditorDecorationType` + `after` | 编辑器内 Accept/Reject 标签(非 Webview) | +| **Diff 算法** | `diff` 库 (Myers 算法) | 行级差异计算,用于数据层变更追踪 | | **构建工具** | esbuild | 快速打包,VSCode 插件社区标准 | -| **测试框架** | Vitest + @vscode/test-electron | 单元测试 + 集成测试 | +| **测试框架** | Vitest | 现代化单元测试 | | **代码规范** | ESLint + Prettier | 代码质量保证 | -### 2.2 关键依赖库 +### 2.2 关键依赖 ```json { "dependencies": { - "diff": "^5.0.0", // Diff 算法核心 - "vscode-languageclient": "^9.0.0", // LSP 客户端(可选) - "uuid": "^9.0.0" // 变更块唯一标识 + "diff": "^5.0.0", // Diff 算法核心(ChangeSetManager 中行级对比) + "uuid": "^9.0.0" // 变更块/变更集唯一标识 }, "devDependencies": { "@types/vscode": "^1.85.0", "@types/diff": "^5.0.0", + "@types/uuid": "^9.0.0", "esbuild": "^0.19.0", "typescript": "^5.3.0", "vitest": "^1.0.0" @@ -56,52 +60,86 @@ | API 模块 | 用途 | |---------|------| -| `vscode.workspace.applyEdit` | 原子化文件编辑 | -| `vscode.window.createTextEditorDecorationType` | Diff 高亮装饰器 | -| `vscode.languages.registerCodeLensProvider` | Accept/Reject 按钮 | -| `vscode.workspace.onDidChangeTextDocument` | 文档变更监听 | -| `vscode.commands.registerCommand` | 命令注册 | -| `WebviewViewProvider` | 侧边栏 Diff 面板(可选) | +| `vscode.commands.executeCommand('vscode.diff', uri1, uri2, title)` | **VSCode 内置 Diff 编辑器**,核心展示方案 | +| `vscode.workspace.createFileSystemWatcher` | 监听 `.claude/hooks/pending.json` 触发文件 | +| `vscode.window.createTextEditorDecorationType` | 红绿色高亮 + 浮动 Accept/Reject 标签 | +| `vscode.window.showInformationMessage` | 对话结束后弹出通知 | +| `vscode.commands.registerCommand` | 命令注册(快捷键绑定的操作) | +| `vscode.workspace.applyEdit` | 原子化文件写入 | +| `vscode.Uri.parse('untitled:...')` | 生成临时虚拟文档用于 Diff 对比 | --- ## 三、架构设计 -### 3.1 分层架构 +### 3.1 工作流程 ``` -┌─────────────────────────────────────────────────────────────┐ -│ 触发层 (Trigger Layer) │ -│ Claude Hooks: after_code_edit / after_write_file │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ 临时变更隔离层 (Suggestion Layer) │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ Base 快照 │ │ Suggest 临时 │ │ -│ │ (只读原始) │ │ (AI 生成) │ │ -│ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ Diff 差分引擎 (Diff Engine) │ -│ Myers 算法 → 变更块切割 → 冲突检测 → Chunk 管理 │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ 渲染 UI 层 (Render Layer) │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ 内联装饰器 │ │ CodeLens │ │ Diff 面板 │ │ -│ │ (行高亮) │ │ (Accept/ │ │ (多文件) │ │ -│ │ │ │ Reject) │ │ │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ 提交/回写事务层 (Transaction Layer) │ -│ Accept: 校验 → 原子写入 → LSP 刷新 → 清理临时层 │ -│ Reject: 丢弃快照 → 清理 UI → 恢复原始视图 │ -└─────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────┐ +│ Claude 对话中 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Edit 工具 │ │ Write 工具 │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 写入 .claude/hooks/pending.json │ ← PostToolUse 触发 │ +│ │ { type, filePath, oldString, │ │ +│ │ newString, toolName, timestamp } │ │ +│ └────────────────┬─────────────────────┘ │ +│ │ │ +├───────────────────┼──────────────────────────────────────────────┤ +│ VSCode 插件 │ │ +│ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ FileSystemWatcher 监听到变更 │ ← extension.ts │ +│ │ 读取 pending.json → 分发事件 │ │ +│ └────────────────┬─────────────────────┘ │ +│ │ │ +│ ┌─────────┴─────────┐ │ +│ ▼ ▼ │ +│ toolName="Edit/Write" toolName="Stop" │ +│ ┌─────────────────┐ ┌──────────────────────┐ │ +│ │ HookHandler │ │ HookHandler │ │ +│ │ .handleEdit() │ │ .handleStop() │ │ +│ │ │ │ │ │ +│ │ → recordEdit() │ │ → markReady() │ │ +│ │ 收集变更到 │ │ 标记变更集就绪 │ │ +│ │ ChangeSet │ │ │ │ +│ │ │ │ → 根据配置决定行为: │ │ +│ │ → 根据配置: │ │ showAllDiffsOnStop │ │ +│ │ autoShowDiff- │ │ ? 弹出完整Diff预览 │ │ +│ │ PerEdit │ │ : 仅显示通知 │ │ +│ │ ? 立即弹出 │ └───────────┬──────────┘ │ +│ │ 内置Diff │ │ │ +│ │ : 静默收集 │ ▼ │ +│ └─────────────────┘ ┌──────────────────────────────────────┐ │ +│ │ 打开完整 Diff 文件列表 (QuickPick │ │ +│ │ 或 TreeView) │ │ +│ │ ┌────────────────────────────────┐ │ │ +│ │ │ 📄 src/file1.ts [+3 -2] [✓][✗]│ │ │ +│ │ │ 📄 src/file2.ts [+10 -0] [✓][✗]│ │ │ +│ │ │ 📄 src/file3.ts [+1 -5] [✓][✗]│ │ │ +│ │ └────────────────────────────────┘ │ │ +│ │ 点击某文件 → 打开 VSCode 内置 Diff │ │ +│ └───────────────────┬──────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ VSCode 原生 Diff 编辑器 │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ [浮动标签] ✓ Accept本次更改 ✗ Reject本次更改 │ │ │ +│ │ │ ┌──────────────────┬──────────────────────────┐ │ │ +│ │ │ │ 原始代码 (左侧) │ AI 修改后 (右侧) │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ - 红色删除行 │ + 绿色新增行 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ └──────────────────┴──────────────────────────┘ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ Accept: oldContent → newContent 写入磁盘 │ +│ Reject: newContent → oldContent 恢复 / 新建文件删除 │ +└──────────────────────────────────────────────────────────────────┘ ``` ### 3.2 核心模块划分 @@ -109,418 +147,307 @@ ``` src/ ├── extension.ts # 插件入口 +│ ├── 初始化 ChangeSetManager、HookHandler +│ ├── 注册所有命令(acceptChunk/rejectChunk/acceptAll/rejectAll/showDiff) +│ ├── 注册 FileSystemWatcher 监听 pending.json +│ └── 检查启动时已有 pending 文件 +│ ├── trigger/ -│ ├── hookHandler.ts # Claude Hooks 处理器 -│ └── commandHandler.ts # 手动命令处理器 +│ └── hookHandler.ts # Hook 事件处理器 +│ ├── handleEdit() # PostToolUse → 收集变更 + 可选自动弹 Diff +│ └── handleStop() # 对话结束 → 可选弹出完整文件列表 +│ ├── snapshot/ -│ ├── snapshotManager.ts # 快照管理器 -│ └── snapshotStore.ts # 快照存储 +│ └── snapshotManager.ts # 变更集管理器 (ChangeSetManager) +│ ├── recordEdit() # 记录每次编辑(独立 EditRecord,不合并) +│ ├── markReady() # 标记变更集就绪 +│ ├── applyEditRecord() # 接受:还原 oldString→newString 到磁盘 +│ ├── rejectEditRecord() # 拒绝:还原 newString→oldString 到磁盘 +│ ├── applyFileChange() # 接受整个文件变更 +│ ├── rejectFileChange() # 拒绝整个文件变更 +│ └── computeDiffLines() # 行级 Diff 计算 +│ ├── diff/ -│ ├── diffEngine.ts # Diff 计算引擎 -│ ├── chunkManager.ts # 变更块管理 -│ └── conflictDetector.ts # 冲突检测 +│ └── diffEngine.ts # Diff 计算引擎 +│ ├── computeChunks() # 计算 DiffChunk 列表 +│ ├── detectConflicts() # 冲突检测 +│ └── applyChunks() # 批量应用(倒序避免行号偏移) +│ ├── render/ -│ ├── inlineDecorator.ts # 内联装饰器 -│ ├── codeLensProvider.ts # CodeLens 提供器 -│ ├── diffPanel.ts # Diff 预览面板 -│ └── statusBar.ts # 状态栏 +│ ├── inlineDecorator.ts # 内联装饰器 + 浮动标签 ★ 核心 UI +│ │ ├── 新增行:绿色背景 + ✓ Accept 浮动标签 +│ │ ├── 删除行:红色背景 + 删除线 +│ │ ├── 修改区域:黄色背景高亮 +│ │ ├── 浮动 Accept/Reject 标签(固定在编辑器视口顶部或变更行上方) +│ │ └── 冲突行:橙色边框 +│ │ +│ ├── diffViewer.ts # VSCode 内置 Diff 查看器 ★ 新增 +│ │ ├── openDiff() # 调用 vscode.diff 命令展示单文件差异 +│ │ ├── openDiffForEdit() # 展示单次 Edit 的差异 +│ │ └── openAllDiffs() # 依次/并排展示所有文件差异 +│ │ +│ ├── fileListProvider.ts # 变更文件列表提供器 ★ 新增 +│ │ └── QuickPick / TreeView 展示文件列表(含 Accept/Reject 按钮) +│ │ +│ └── statusBar.ts # 状态栏管理器 +│ └── 显示待处理变更数量 + 快速入口 +│ ├── transaction/ │ ├── acceptHandler.ts # Accept 处理器 -│ ├── rejectHandler.ts # Reject 处理器 -│ └── transactionManager.ts # 事务管理器 +│ │ ├── acceptEdit() # 接受单次编辑 +│ │ ├── acceptFile() # 接受整个文件所有编辑 +│ │ ├── acceptAll() # 接受全部 +│ │ └── writeFileAtomic() # 原子化写入 +│ │ +│ └── rejectHandler.ts # Reject 处理器 +│ ├── rejectEdit() # 拒绝单次编辑 +│ ├── rejectFile() # 拒绝整个文件 +│ ├── rejectAll() # 拒绝全部 +│ └── cleanupUI() # 清理装饰器和状态 +│ ├── models/ -│ ├── types.ts # 类型定义 +│ ├── types.ts # 核心类型定义 │ └── constants.ts # 常量定义 -└── utils/ - ├── diffUtils.ts # Diff 工具函数 - ├── fileUtils.ts # 文件操作工具 - └── logger.ts # 日志工具 +│ +└── __tests__/ + └── diffEngine.test.ts # Diff 引擎单元测试 ``` --- -## 四、功能需求 +## 四、核心数据模型 -### 4.1 核心功能 - -#### 4.1.1 临时变更隔离层 -- [ ] 捕获编辑前文件快照(Base 版本) -- [ ] 接收 AI 生成的 Patch 并存储为临时层 -- [ ] 维护 Base 与 Suggest 双版本并行状态 -- [ ] 支持会话级别的快照持久化 - -#### 4.1.2 Diff 差分引擎 -- [ ] 实现 Myers 差分算法封装 -- [ ] 将 Patch 切分为独立变更块(Chunk) -- [ ] 为每个 Chunk 生成唯一 ID、起止行号 -- [ ] 支持行级和字符级差异对比 -- [ ] 检测用户手动修改导致的冲突 - -#### 4.1.3 渲染 UI 层 -- [ ] **内联装饰器** - - 新增行:绿色背景 + 左侧 ✓ 按钮 - - 删除行:红色背景 + 删除线 - - 修改行:黄色背景高亮 -- [ ] **CodeLens** - - 单块 Accept 按钮 - - 单块 Reject 按钮 - - 整块 Accept All / Reject All -- [ ] **Diff 面板**(多文件场景) - - 左右分栏对比视图 - - 文件树导航 - - 全局操作按钮 - -#### 4.1.4 提交/回写事务层 -- [ ] **Accept 流程** - - 校验无冲突 - - 原子事务写入文件 - - 触发 LSP 重新解析 - - 清理临时层和 UI - - 记录变更日志 -- [ ] **Reject 流程** - - 丢弃临时快照 - - 清理装饰 UI - - 恢复原始视图 - - 记录拒绝历史 - -### 4.2 辅助功能 - -#### 4.2.1 Claude Hooks 集成 -- [ ] 监听 `after_code_edit` 事件 -- [ ] 监听 `after_write_file` 事件 -- [ ] 解析 Hook 传入的 Patch 数据 -- [ ] 自动触发 Diff 预览流程 - -#### 4.2.2 快捷键支持 -- [ ] `Tab` / `Enter`: 接受当前块 -- [ ] `Esc`: 拒绝本次所有修改 -- [ ] `Ctrl+Shift+A`: Accept All -- [ ] `Ctrl+Shift+R`: Reject All - -#### 4.2.3 状态管理 -- [ ] 状态栏显示当前 Diff 状态 -- [ ] 显示待处理变更数量 -- [ ] 提供快速操作入口 - ---- - -## 五、实现流程 - -### 5.1 阶段一:基础框架搭建 (Week 1) - -``` -Day 1-2: 项目初始化 -├── 使用 yo code 脚手架创建项目 -├── 配置 TypeScript、ESLint、Prettier -├── 配置 esbuild 构建流程 -└── 设置测试环境 - -Day 3-5: 核心模型定义 -├── 定义 TypeScript 接口和类型 -├── 实现快照管理器基础结构 -├── 实现 Diff 引擎基础封装 -└── 编写单元测试 -``` - -### 5.2 阶段二:Diff 引擎实现 (Week 2) - -``` -Day 1-3: Myers 算法集成 -├── 集成 diff 库 -├── 实现行级差异计算 -├── 实现变更块切割逻辑 -└── 测试各种代码变更场景 - -Day 4-5: 冲突检测 -├── 实现 Base 与当前文件对比 -├── 检测用户手动修改 -├── 标记冲突变更块 -└── 测试冲突场景 -``` - -### 5.3 阶段三:UI 渲染层 (Week 3) - -``` -Day 1-3: 内联装饰器 -├── 实现 DecorationType 定义 -├── 实现行高亮渲染 -├── 实现 Accept/Reject 按钮 -└── 测试不同 Diff 场景 - -Day 4-5: CodeLens 集成 -├── 实现 CodeLens Provider -├── 绑定 Accept/Reject 命令 -├── 处理多块场景 -└── 测试交互流程 -``` - -### 5.4 阶段四:事务处理层 (Week 4) - -``` -Day 1-3: Accept 流程 -├── 实现冲突校验 -├── 实现原子化文件写入 -├── 触发 LSP 刷新 -├── 清理临时层和 UI -└── 测试 Accept 全流程 - -Day 4-5: Reject 流程 -├── 实现快照丢弃 -├── 实现 UI 清理 -├── 实现视图恢复 -└── 测试 Reject 全流程 -``` - -### 5.5 阶段五:Hooks 集成与优化 (Week 5) - -``` -Day 1-3: Claude Hooks 集成 -├── 实现 Hook 监听器 -├── 解析 Patch 数据格式 -├── 自动触发 Diff 预览 -└── 测试端到端流程 - -Day 4-5: 优化与完善 -├── 性能优化(大文件处理) -├── 快捷键绑定 -├── 状态栏集成 -├── 文档编写 -└── 发布准备 -``` - ---- - -## 六、关键技术实现 - -### 6.1 快照管理器 +### 4.1 变更集模型(含编辑粒度) ```typescript -// snapshotManager.ts -interface Snapshot { +// 一轮对话的变更集 +interface ChangeSet { + id: string; + edits: EditRecord[]; // ★ 每次 Edit/Write 独立记录 + createdAt: number; + status: 'collecting' | 'ready' | 'reviewed'; +} + +// ★ 单次编辑记录(不合并!每次 Edit/Write 都是一个独立 EditRecord) +interface EditRecord { id: string; filePath: string; - baseContent: string; - suggestContent: string; + toolName: 'Edit' | 'Write'; timestamp: number; - chunks: DiffChunk[]; + /** 编辑前的文件快照 */ + beforeContent: string; + /** 编辑后的文件快照 */ + afterContent: string; + /** oldString → newString 的具体替换内容 */ + oldString: string; + newString: string; + /** 本次编辑的 Diff 行 */ + diffLines: DiffLine[]; + /** 状态 */ + status: 'pending' | 'accepted' | 'rejected'; } -class SnapshotManager { - private snapshots: Map = new Map(); - - // 创建 Base 快照 - async createBaseSnapshot(filePath: string): Promise { - const content = await fs.readFile(filePath, 'utf-8'); - const id = uuid(); - this.snapshots.set(id, { - id, - filePath, - baseContent: content, - suggestContent: '', - timestamp: Date.now(), - chunks: [] - }); - return id; - } - - // 存储 AI 生成的 Patch - async applyPatch(snapshotId: string, patch: string): Promise { - const snapshot = this.snapshots.get(snapshotId); - if (!snapshot) throw new Error('Snapshot not found'); - snapshot.suggestContent = patch; - snapshot.chunks = this.diffEngine.computeChunks( - snapshot.baseContent, - patch - ); - } -} -``` - -### 6.2 Diff 引擎 - -```typescript -// diffEngine.ts -import { diffLines, Change } from 'diff'; - -interface DiffChunk { +// 文件维度的聚合变更(由多个 EditRecord 聚合而来) +interface FileChange { id: string; - startLine: number; - endLine: number; - type: 'add' | 'delete' | 'modify'; - content: string; - accepted?: boolean; + filePath: string; + type: 'create' | 'modify' | 'delete'; + edits: EditRecord[]; // ★ 包含的所有编辑记录 + /** 原始内容 = 所有编辑前的文件状态 */ + originalContent: string; + /** 最新内容 = 所有编辑后的文件状态 */ + latestContent: string; + /** 聚合 Diff */ + diffLines: DiffLine[]; + status: 'pending' | 'accepted' | 'rejected'; } -class DiffEngine { - computeChunks(base: string, suggest: string): DiffChunk[] { - const changes = diffLines(base, suggest); - const chunks: DiffChunk[] = []; - let lineOffset = 0; - - changes.forEach((change, index) => { - if (change.added || change.removed) { - chunks.push({ - id: `chunk-${index}`, - startLine: lineOffset, - endLine: lineOffset + (change.count || 0), - type: change.added ? 'add' : 'delete', - content: change.value - }); - } - if (!change.removed) { - lineOffset += change.count || 0; - } - }); - - return chunks; - } - - detectConflicts(base: string, current: string, suggest: string): DiffChunk[] { - // 检测用户手动修改与 AI 建议的冲突 - const baseToCurrent = this.computeChunks(base, current); - const baseToSuggest = this.computeChunks(base, suggest); - - return baseToSuggest.filter(suggestChunk => { - return baseToCurrent.some(currentChunk => - this.isOverlapping(suggestChunk, currentChunk) - ); - }); - } +// 单行 Diff +interface DiffLine { + type: 'context' | 'add' | 'delete'; + oldLineNum: number; + newLineNum: number; + content: string; } ``` -### 6.3 内联装饰器 +### 4.2 关键设计:编辑不合并 + +> **重要**:同一文件的多次 Edit/Write **不合并**为一个 FileChange。每次 Edit 都保留独立的 `EditRecord`,用户可以: +> - 按单次编辑粒度 Accept/Reject(精确控制每次 AI 修改) +> - 按文件粒度 Accept/Reject(接受/拒绝该文件的所有编辑) +> - 全局 Accept All / Reject All + +### 4.3 Hook 触发事件 ```typescript -// inlineDecorator.ts +// pending.json 文件格式 +interface ClaudeHookEvent { + type: string; + filePath: string; + content: string; + oldString: string; // Edit: 替换前的文本片段 + newString: string; // Edit: 替换后的文本片段 / Write: 完整内容 + timestamp: number; + toolName: 'Edit' | 'Write' | 'Stop'; +} +``` + +### 4.4 原始内容捕获策略 + +`ChangeSetManager.recordEdit()` 中的内容反推机制: + +- **Edit**(首次编辑该文件):从当前文件中找到 `newString`,替换回 `oldString`,得到编辑前内容 +- **Edit**(非首次):基于上一次 `EditRecord.afterContent` 作为本次的 `beforeContent` +- **Write**:无法获取原始内容,标记 `beforeContent = ''`,类型为 `create` +- **Write 后 Edit**:`beforeContent` 为 Write 写入后的文件内容 + +--- + +## 五、触发机制 + +### 5.1 文件监听模式 + +插件通过 `FileSystemWatcher` 监听 `.claude/hooks/pending.json` 文件的变更: + +``` +.claude/hooks/pending.json + │ + ▼ (create / change 事件) +extension.ts: + 1. 读取文件内容 + 2. 删除 pending.json(防止重复处理) + 3. 解析为 ClaudeHookEvent + 4. 检查时间戳(10秒内有效) + 5. 根据 toolName 分发: + - Edit/Write → hookHandler.handleEdit() → 收集 + 可选弹 Diff + - Stop → hookHandler.handleStop() → 可选弹完整文件列表 +``` + +### 5.2 启动恢复 + +`extension.activate()` 时检查是否已有 pending 文件(上次对话遗留),时间窗口 15 秒内有效。 + +--- + +## 六、Diff 展示方案 + +### 6.1 使用 VSCode 内置 Diff 编辑器 + +不再自定义 Webview,直接调用 VSCode 原生命令: + +```typescript +// diffViewer.ts import * as vscode from 'vscode'; -class InlineDecorator { - private addDecorationType: vscode.TextEditorDecorationType; - private deleteDecorationType: vscode.TextEditorDecorationType; - private modifyDecorationType: vscode.TextEditorDecorationType; +class DiffViewer { + /** + * 打开 VSCode 内置 Diff 编辑器展示单次编辑的差异 + */ + async openDiffForEdit(editRecord: EditRecord): Promise { + // 创建临时虚拟文档(不写入磁盘) + const beforeUri = vscode.Uri.parse(`untitled:before-${editRecord.id}.tmp`); + const afterUri = vscode.Uri.parse(`untitled:after-${editRecord.id}.tmp`); - constructor() { - this.addDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(46, 160, 67, 0.15)', - isWholeLine: true, - gutterIconPath: new vscode.ThemeIcon('check').id, - gutterIconSize: 'contain' - }); + // 写入临时内容 + await this.writeTempContent(beforeUri, editRecord.beforeContent); + await this.writeTempContent(afterUri, editRecord.afterContent); - this.deleteDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(248, 81, 73, 0.15)', - isWholeLine: true, - textDecoration: 'line-through' - }); - - this.modifyDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(210, 153, 34, 0.15)', - isWholeLine: true - }); + // 打开 VSCode 原生 Diff + await vscode.commands.executeCommand( + 'vscode.diff', + beforeUri, + afterUri, + `${editRecord.filePath} (AI修改) — 原始 ↔ 变更后` + ); } - renderChunks(editor: vscode.TextEditor, chunks: DiffChunk[]) { - const addRanges: vscode.Range[] = []; - const deleteRanges: vscode.Range[] = []; - const modifyRanges: vscode.Range[] = []; + /** + * 展示整个文件的聚合变更 + */ + async openDiffForFile(fileChange: FileChange): Promise { + const beforeUri = vscode.Uri.parse(`untitled:before-file-${fileChange.id}.tmp`); + const afterUri = vscode.Uri.parse(`untitled:after-file-${fileChange.id}.tmp`); - chunks.forEach(chunk => { - const range = new vscode.Range( - new vscode.Position(chunk.startLine, 0), - new vscode.Position(chunk.endLine, 0) - ); + await this.writeTempContent(beforeUri, fileChange.originalContent); + await this.writeTempContent(afterUri, fileChange.latestContent); - switch (chunk.type) { - case 'add': - addRanges.push(range); - break; - case 'delete': - deleteRanges.push(range); - break; - case 'modify': - modifyRanges.push(range); - break; - } - }); - - editor.setDecorations(this.addDecorationType, addRanges); - editor.setDecorations(this.deleteDecorationType, deleteRanges); - editor.setDecorations(this.modifyDecorationType, modifyRanges); - } - - clearDecorations(editor: vscode.TextEditor) { - editor.setDecorations(this.addDecorationType, []); - editor.setDecorations(this.deleteDecorationType, []); - editor.setDecorations(this.modifyDecorationType, []); + await vscode.commands.executeCommand( + 'vscode.diff', + beforeUri, + afterUri, + `${fileChange.filePath} — 原始 ↔ 变更后` + ); } } ``` -### 6.4 Claude Hooks 处理器 +### 6.2 浮动 Accept/Reject 标签 + +在 Diff 编辑器或当前编辑器中,使用 `TextEditorDecorationType` 的 `after` 属性在变更行上方渲染固定操作标签: ```typescript -// hookHandler.ts -interface ClaudeHookEvent { - type: 'after_code_edit' | 'after_write_file'; - filePath: string; - patch: string; - timestamp: number; -} - -class HookHandler { - private snapshotManager: SnapshotManager; - private diffEngine: DiffEngine; - private decorator: InlineDecorator; - - async handleHook(event: ClaudeHookEvent): Promise { - // 1. 创建 Base 快照 - const snapshotId = await this.snapshotManager.createBaseSnapshot( - event.filePath - ); - - // 2. 应用 AI Patch - await this.snapshotManager.applyPatch(snapshotId, event.patch); - - // 3. 计算 Diff - const snapshot = this.snapshotManager.getSnapshot(snapshotId); - const chunks = this.diffEngine.computeChunks( - snapshot.baseContent, - snapshot.suggestContent - ); - - // 4. 渲染 Diff UI - const editor = vscode.window.activeTextEditor; - if (editor && editor.document.uri.fsPath === event.filePath) { - this.decorator.renderChunks(editor, chunks); - this.showDiffPanel(snapshot); - } - } -} +// 浮动标签结构 +// ┌─────────────────────────────────────────┐ +// │ ✓ Accept本次更改 ✗ Reject本次更改 │ ← 固定在视口顶部 +// │ [3行新增, 2行删除] │ ← 变更摘要 +// │─────────────────────────────────────────│ +// │ (Diff 编辑器内容区域) │ +// │ + 绿色高亮新增行 │ +// │ - 红色高亮删除行 │ +// └─────────────────────────────────────────┘ ``` +标签实现方式: +- **编辑器中**:`createTextEditorDecorationType({ after: { contentText: ' ✓ Accept' } })` 在变更区域末尾添加 +- **Diff 编辑器中**:利用 VSCode 原生 Diff 视图,在标题栏区域叠加操作按钮(通过 `vscode.window.onDidChangeActiveTextEditor` 跟踪) +- **替代方案**:使用 `vscode.window.createStatusBarItem` 在状态栏固定 Accept/Reject 按钮(更简单可靠) + +### 6.3 文件列表 UI + +对话结束后,通过 **QuickPick** 或 **TreeView** 展示所有变更文件: + +``` +┌──────────────────────────────────────────┐ +│ AI Diff Review — 3 个文件有变更 │ +│ │ +│ 📄 src/utils.ts [+5 -2] [✓] [✗] │ +│ 📄 src/index.ts [+10 -0] [✓] [✗] │ +│ 📄 src/types.ts [+0 -3] [✓] [✗] │ +│ │ +│ ─────────────────────────────────────── │ +│ [Accept All (3)] [Reject All (3)] │ +└──────────────────────────────────────────┘ +``` + +点击某文件 → 打开内置 Diff 编辑器展示该文件的差异。 + --- -## 七、配置与扩展点 - -### 7.1 插件配置项 +## 七、配置项 ```json { "aiDiffPreview.enableAutoTrigger": { "type": "boolean", "default": true, - "description": "启用 Claude Hooks 自动触发" + "description": "启用自动触发(监听 pending.json)" }, - "aiDiffPreview.showInlineButtons": { + "aiDiffPreview.autoShowDiffPerEdit": { + "type": "boolean", + "default": false, + "description": "每次 Edit/Write 后自动弹出 VSCode 内置 Diff 编辑器。关闭时静默收集,等待 Stop 后统一展示" + }, + "aiDiffPreview.showAllDiffsOnStop": { "type": "boolean", "default": true, - "description": "显示内联 Accept/Reject 按钮" + "description": "对话结束后弹出完整文件列表/QuickPick,展示所有变更文件。关闭时仅显示状态栏通知" }, - "aiDiffPreview.autoLint": { - "type": "boolean", - "default": true, - "description": "Diff 预览时自动运行 Lint 检查" + "aiDiffPreview.floatingLabelMode": { + "type": "string", + "default": "statusBar", + "enum": ["inline", "statusBar", "both"], + "description": "浮动 Accept/Reject 标签显示位置:inline=编辑器内文本后, statusBar=状态栏, both=两者都显示" }, "aiDiffPreview.maxFileSize": { "type": "number", @@ -530,95 +457,171 @@ class HookHandler { } ``` -### 7.2 命令注册 +### 配置组合行为矩阵 -```json -{ - "contributes": { - "commands": [ - { - "command": "aiDiffPreview.acceptChunk", - "title": "AI Diff: 接受当前变更块" - }, - { - "command": "aiDiffPreview.rejectChunk", - "title": "AI Diff: 拒绝当前变更块" - }, - { - "command": "aiDiffPreview.acceptAll", - "title": "AI Diff: 接受所有变更" - }, - { - "command": "aiDiffPreview.rejectAll", - "title": "AI Diff: 拒绝所有变更" - }, - { - "command": "aiDiffPreview.showDiffPanel", - "title": "AI Diff: 显示 Diff 面板" - } - ], - "keybindings": [ - { - "command": "aiDiffPreview.acceptChunk", - "key": "tab", - "when": "aiDiffPreview.isActive" - }, - { - "command": "aiDiffPreview.rejectAll", - "key": "escape", - "when": "aiDiffPreview.isActive" - } - ] - } +| autoShowDiffPerEdit | showAllDiffsOnStop | 行为 | +|:---:|:---:|------| +| false | true | **默认推荐**:编辑时静默收集,Stop 后弹出文件列表统一审查 | +| true | false | 每次编辑立即弹 Diff,Stop 后无额外操作 | +| true | true | 每次编辑弹 Diff + Stop 后再弹完整列表 | +| false | false | 仅状态栏显示待处理数量,用户手动触发 | + +--- + +## 八、命令与快捷键 + +### 8.1 注册命令 + +| 命令 ID | 标题 | 说明 | +|---------|------|------| +| `aiDiffPreview.showDiffPanel` | AI Diff: 显示所有变更文件列表 | QuickPick 文件列表 | +| `aiDiffPreview.showCurrentFileDiff` | AI Diff: 查看当前文件 Diff | 打开当前活动文件的内置 Diff | +| `aiDiffPreview.acceptCurrentEdit` | AI Diff: 接受当前编辑 | 接受光标所在位置的编辑记录 | +| `aiDiffPreview.rejectCurrentEdit` | AI Diff: 拒绝当前编辑 | 拒绝光标所在位置的编辑记录 | +| `aiDiffPreview.acceptCurrentFile` | AI Diff: 接受当前文件所有编辑 | 接受当前文件全部编辑 | +| `aiDiffPreview.rejectCurrentFile` | AI Diff: 拒绝当前文件所有编辑 | 拒绝当前文件全部编辑 | +| `aiDiffPreview.acceptAll` | AI Diff: 接受所有变更 | 批量接受 | +| `aiDiffPreview.rejectAll` | AI Diff: 拒绝所有变更 | 批量拒绝 | +| `aiDiffPreview.nextDiff` | AI Diff: 下一个变更 | 跳转到下一个待处理的编辑 | +| `aiDiffPreview.prevDiff` | AI Diff: 上一个变更 | 跳转到上一个待处理的编辑 | + +### 8.2 快捷键 + +| 快捷键 | 条件 | 功能 | +|--------|------|------| +| `Tab` | `aiDiffPreview.isActive` | 接受当前编辑块 | +| `Esc` | `aiDiffPreview.isActive` | 拒绝当前编辑块 | +| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All | +| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All | +| `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更 | +| `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更 | +| `Ctrl+Shift+D` | — | 显示所有变更文件列表 | + +--- + +## 九、已知 Bug & 修复计划 + +### 9.1 Bug: Accept 后同文件再编辑不触发选项且 Diff 合并 + +**现象**: +1. 对文件 `A.ts` 进行修改 → 弹出 Accept/Reject 选项 +2. 用户点击 Accept,修改写入磁盘 +3. 对同一文件 `A.ts` 再次修改 → **不再弹出 Accept/Reject 选项** +4. 第二次修改的 Diff 内容被**合并到了第一次的 Diff 中**,无法独立审核 + +**根因分析**: +当前 `ChangeSetManager.recordEdit()` 的逻辑中,首次编辑该文件时捕获 `originalContent` 并创建 `FileChange`,但后续对同一文件的编辑会**更新同一个 `FileChange` 对象**(更新 `newContent` 和 `diffLines`),而不是创建独立的编辑记录。当用户 Accept 后,`FileChange.status` 变为 `accepted`,后续编辑找不到正确的状态机入口。 + +```typescript +// 当前代码 snapshotManager.ts 中的问题逻辑: +const existing = this.currentSet.changes.find(c => norm(c.filePath) === filePath); +if (existing) { + // ★ 问题:更新同一个 FileChange,导致多次编辑合并 + existing.newContent = currentContent; + existing.diffLines = this.computeDiffLines(originalContent, currentContent); } ``` +**修复方案**: +1. **引入 `EditRecord` 粒度**:每次 Edit/Write 创建独立的 `EditRecord`(见 §4.1 数据模型) +2. **FileChange 作为聚合视图**:`FileChange.edits[]` 持有所有 `EditRecord`,支持按编辑粒度或文件粒度操作 +3. **Accept 后状态重置**:Accept 某次编辑后,后续编辑创建新的 `EditRecord` 追加到 `FileChange.edits[]` +4. **独立 Diff 计算**:每个 `EditRecord` 计算自己的 `diffLines`,不再与之前的编辑合并 + +--- + +## 十、构建与开发 + +### 10.1 构建配置 + +```bash +# 构建:esbuild 打包 src/extension.ts → dist/extension.js +npm run build # esbuild --bundle --external:vscode --format=cjs --platform=node --minify + +# 监听模式 +npm run watch # 同上 + --sourcemap --watch + +# 测试 +npm run test # vitest run +npm run test:watch # vitest (watch 模式) + +# 代码检查 +npm run lint # eslint src --ext ts + +# 打包 +npm run package # vsce package +``` + +### 10.2 TypeScript 配置 + +- **Target**: ES2022 +- **Module**: CommonJS +- **Strict**: true +- **输出**: `dist/` (含 declaration + sourceMap) + +--- + +## 十一、测试策略 + +### 11.1 当前测试覆盖 + +| 测试 | 文件 | 覆盖内容 | +|------|------|---------| +| Diff 引擎 | `src/__tests__/diffEngine.test.ts` | `computeChunks`(新增/删除/修改/空文本)、`applyChunk`(新增块/删除块) | + +### 11.2 待扩展测试 + +- `ChangeSetManager`:EditRecord 独立记录、多次编辑不合并、Accept 后再次编辑 +- `HookHandler`:Edit/Stop 事件处理、autoShowDiffPerEdit 配置分支 +- `DiffViewer`:vscode.diff 调用、虚拟文档生成 +- **Bug 回归测试**:Accept → 再编辑 → 验证独立 EditRecord 生成 +- 集成测试:完整 Edit → Stop → Diff → Accept/Reject 流程 + +--- + +## 十二、版本历史 + +| 版本 | 日期 | 说明 | +|------|------|------| +| **0.1.0** | 2026-06-17 | 项目初始化,Webview Review 面板架构 | +| **0.2.0** | 计划中 | ★ 重构为 VSCode 内置 Diff + 浮动标签 + EditRecord 粒度 + Bug 修复 | + +### Git 提交记录 + +``` +a7c992a refactor: 重构为 Webview Review 面板架构 +3168e81 feat: 初始化 AI Code Diff Preview 插件项目 +``` + --- -## 八、测试策略 +## 十三、待完成事项 -### 8.1 单元测试 +### 高优先级(v0.2.0) +- [ ] **Bug 修复**:Accept 后同文件再编辑不触发选项、Diff 合并的问题(§9.1) +- [ ] **EditRecord 粒度重构**:ChangeSetManager 改为 `EditRecord[]` 独立记录模式 +- [ ] **VSCode 内置 Diff 集成**:实现 `DiffViewer` 调用 `vscode.diff` +- [ ] **浮动 Accept/Reject 标签**:状态栏或编辑器内固定操作按钮 +- [ ] **配置项实现**:`autoShowDiffPerEdit`、`showAllDiffsOnStop`、`floatingLabelMode` -- 快照管理器:创建、更新、删除快照 -- Diff 引擎:各种代码变更场景 -- 冲突检测:用户手动修改检测 +### 中优先级 +- [ ] **文件列表 UI**:QuickPick 文件列表 + Accept/Reject 按钮 +- [ ] **冲突检测**:用户手动修改与 AI 建议冲突检测 +- [ ] **状态栏集成**:显示待处理编辑数量 +- [ ] 完善单元测试 + Bug 回归测试 -### 8.2 集成测试 - -- 完整 Accept/Reject 流程 -- 多文件 Diff 预览 -- Claude Hooks 触发测试 - -### 8.3 E2E 测试 - -- 使用 @vscode/test-electron -- 模拟用户交互 -- 验证 UI 渲染效果 - ---- - -## 九、发布与维护 - -### 9.1 发布清单 - -- [ ] 完善 README 文档 -- [ ] 添加 CHANGELOG -- [ ] 配置 GitHub Actions CI/CD +### 低优先级 +- [ ] Git 集成(生成 Commit 建议) - [ ] 发布到 VSCode Marketplace -- [ ] 收集用户反馈 - -### 9.2 后续迭代 - -- 支持更多 AI 模型(GPT-4、Gemini 等) -- 支持 Git 集成(生成 Commit 建议) -- 支持团队协作(共享 Diff 配置) -- 性能优化(大文件分块处理) +- [ ] CI/CD 配置(GitHub Actions) --- -## 十、参考资料 +## 十四、参考资料 - [VSCode Extension API](https://code.visualstudio.com/api) +- [VSCode 内置 Diff 命令](https://code.visualstudio.com/api/references/commands) (`vscode.diff`) +- [VSCode TextEditorDecorationType](https://code.visualstudio.com/api/references/vscode-api#TextEditorDecorationType) - [Myers Diff Algorithm](https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/) - [diff 库文档](https://github.com/kpdecker/jsdiff) - [Cursor 官方文档](https://cursor.sh/docs) diff --git a/src/__tests__/changeSetManager.test.ts b/src/__tests__/changeSetManager.test.ts new file mode 100644 index 0000000..9baf1f4 --- /dev/null +++ b/src/__tests__/changeSetManager.test.ts @@ -0,0 +1,171 @@ +/** + * ChangeSetManager 测试 — Bug 回归 + EditRecord 粒度验证 + * + * 重要:模拟真实 Claude 流程 + * 1. Claude 修改文件 → 文件在磁盘上已变更 + * 2. pending.json 写入 → recordEdit() 被调用 + * 3. recordEdit() 读取已修改的文件 → 反推原始内容 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ChangeSetManager } from '../snapshot/snapshotManager'; + +describe('ChangeSetManager', () => { + let manager: ChangeSetManager; + let tmpDir: string; + let testFile: string; + + beforeEach(() => { + manager = new ChangeSetManager(); + manager.startNewSet(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-diff-test-')); + testFile = path.join(tmpDir, 'test.ts'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(content: string): void { + fs.writeFileSync(testFile, content, 'utf-8'); + } + + function readFile(): string { + return fs.readFileSync(testFile, 'utf-8'); + } + + describe('EditRecord 独立记录', () => { + it('每次 Edit 应创建独立 EditRecord', () => { + // 模拟真实流程:文件已在磁盘上被 Claude 修改 + writeFile('line1\nline2-modified\nline3\n'); + manager.recordEdit(testFile, 'Edit', 'line2', 'line2-modified', ''); + + // 第二次修改 + writeFile('line1\nline2-modified\nline3-modified\n'); + manager.recordEdit(testFile, 'Edit', 'line3', 'line3-modified', ''); + + const set = manager.getCurrentSet()!; + expect(set.changes).toHaveLength(1); + + const fileChange = set.changes[0]; + expect(fileChange.edits).toHaveLength(2); + expect(fileChange.edits[0].status).toBe('pending'); + expect(fileChange.edits[1].status).toBe('pending'); + + // 第一次编辑的 Diff 应该包含 line2 的变更 + const edit1Diffs = fileChange.edits[0].diffLines; + expect(edit1Diffs.some(d => d.type === 'add' && d.content.includes('line2-modified'))).toBe(true); + + // 第二次编辑的 Diff 应该包含 line3 的变更 + const edit2Diffs = fileChange.edits[1].diffLines; + expect(edit2Diffs.some(d => d.type === 'add' && d.content.includes('line3-modified'))).toBe(true); + + // FileChange 聚合 Diff 应包含所有变更 + expect(fileChange.diffLines.some(d => d.content.includes('line2-modified'))).toBe(true); + expect(fileChange.diffLines.some(d => d.content.includes('line3-modified'))).toBe(true); + }); + }); + + describe('Bug 修复: Accept 后再编辑', () => { + it('Accept 后再次编辑应创建新的 FileChange', () => { + // 第一次编辑(文件已在磁盘上被 Claude 修改) + writeFile('line1\nline2-changed\nline3\n'); + manager.recordEdit(testFile, 'Edit', 'line2', 'line2-changed', ''); + + const set1 = manager.getCurrentSet()!; + const fileId1 = set1.changes[0].id; + manager.acceptFile(fileId1); + expect(set1.changes[0].status).toBe('accepted'); + + // ★ 第二次编辑(同一文件,再次被 Claude 修改) + writeFile('line1\nline2-changed\nline3-changed\n'); + manager.recordEdit(testFile, 'Edit', 'line3', 'line3-changed', ''); + + const set2 = manager.getCurrentSet()!; + // ★ 应该有 2 个 FileChange(旧的 accepted + 新的 pending) + expect(set2.changes).toHaveLength(2); + expect(set2.changes[0].status).toBe('accepted'); + expect(set2.changes[1].status).toBe('pending'); + expect(set2.changes[1].edits).toHaveLength(1); + expect(set2.changes[1].edits[0].newString).toBe('line3-changed'); + }); + + it('Accept All 后再次编辑应独立追踪', () => { + writeFile('line1-new\nline2\nline3\n'); + manager.recordEdit(testFile, 'Edit', 'line1', 'line1-new', ''); + + manager.acceptAll(); + expect(manager.getCurrentSet()!.changes[0].status).toBe('accepted'); + + writeFile('line1-new\nline2-new\nline3\n'); + manager.recordEdit(testFile, 'Edit', 'line2', 'line2-new', ''); + + const set = manager.getCurrentSet()!; + expect(set.changes).toHaveLength(2); + expect(set.changes[1].status).toBe('pending'); + expect(set.changes[1].edits[0].newString).toBe('line2-new'); + }); + + it('Reject 后再次编辑应创建新 FileChange', () => { + writeFile('line1\nline2-bad\nline3\n'); + manager.recordEdit(testFile, 'Edit', 'line2', 'line2-bad', ''); + + const fileId = manager.getCurrentSet()!.changes[0].id; + manager.rejectFile(fileId); + + // Reject 后文件应恢复到原始内容 + expect(readFile()).toBe('line1\nline2\nline3\n'); + + // 第二次编辑 + writeFile('line1\nline2\nline3-good\n'); + manager.recordEdit(testFile, 'Edit', 'line3', 'line3-good', ''); + + const set = manager.getCurrentSet()!; + expect(set.changes).toHaveLength(2); + expect(set.changes[0].status).toBe('rejected'); + expect(set.changes[1].status).toBe('pending'); + expect(set.changes[1].edits[0].newString).toBe('line3-good'); + }); + }); + + describe('Per-edit Accept/Reject', () => { + it('acceptEditRecord 应只接受单个编辑', () => { + writeFile('aaa\nBBB\nCCC\n'); + manager.recordEdit(testFile, 'Edit', 'AAA', 'aaa', ''); + + writeFile('aaa\nBBB\nccc\n'); + manager.recordEdit(testFile, 'Edit', 'CCC', 'ccc', ''); + + const fileChange = manager.getCurrentSet()!.changes[0]; + const edit1Id = fileChange.edits[0].id; + const edit2Id = fileChange.edits[1].id; + + // 只接受第一个编辑 + manager.acceptEditRecord(fileChange.id, edit1Id); + + const updated = manager.getCurrentSet()!.changes[0]; + expect(updated.edits[0].status).toBe('accepted'); + expect(updated.edits[1].status).toBe('pending'); + expect(updated.status).toBe('pending'); + }); + + it('全部编辑接受后文件状态应变为 accepted', () => { + writeFile('new-line1\nline2\n'); + manager.recordEdit(testFile, 'Edit', 'line1', 'new-line1', ''); + + writeFile('new-line1\nnew-line2\n'); + manager.recordEdit(testFile, 'Edit', 'line2', 'new-line2', ''); + + const fc = manager.getCurrentSet()!.changes[0]; + manager.acceptEditRecord(fc.id, fc.edits[0].id); + manager.acceptEditRecord(fc.id, fc.edits[1].id); + + const updated = manager.getCurrentSet()!.changes[0]; + expect(updated.status).toBe('accepted'); + expect(updated.edits.every(e => e.status === 'accepted')).toBe(true); + }); + }); +}); diff --git a/src/models/types.ts b/src/models/types.ts index 041b44e..8b0787e 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -15,6 +15,11 @@ export interface ClaudeHookEvent { toolName: string; // Edit | Write | Stop } +/** + * 变更状态(编辑记录和文件变更共用) + */ +export type ChangeStatus = 'pending' | 'accepted' | 'rejected'; + /** * 文件变更类型 */ @@ -23,23 +28,45 @@ export type FileChangeType = 'create' | 'modify' | 'delete'; /** * 文件变更状态 */ -export type FileChangeStatus = 'pending' | 'accepted' | 'rejected'; +export type FileChangeStatus = ChangeStatus; /** - * 单个文件的变更 + * ★ 单次编辑记录(每次 Edit/Write 独立记录,不合并) + */ +export interface EditRecord { + id: string; + filePath: string; + toolName: 'Edit' | 'Write'; + /** 编辑前的文件完整快照 */ + beforeContent: string; + /** 编辑后的文件完整快照 */ + afterContent: string; + /** Edit: 被替换的文本片段; Write: 空字符串 */ + oldString: string; + /** Edit: 替换后的文本片段; Write: 写入的完整内容 */ + newString: string; + /** 本次编辑的独立 Diff */ + diffLines: DiffLine[]; + status: ChangeStatus; + timestamp: number; +} + +/** + * 单个文件的变更(聚合一个或多个 EditRecord) */ export interface FileChange { id: string; filePath: string; type: FileChangeType; - /** 修改前的内容(modify/delete 时有值) */ - oldContent: string; - /** 修改后的内容(modify/create 时有值) */ - newContent: string; - /** Diff 行 */ + /** ★ 该文件的所有编辑记录(不合并) */ + edits: EditRecord[]; + /** 第一次编辑前的原始内容 */ + originalContent: string; + /** 最后一次编辑后的最新内容 */ + latestContent: string; + /** 聚合 Diff(originalContent → latestContent) */ diffLines: DiffLine[]; status: FileChangeStatus; - timestamp: number; } /** @@ -73,6 +100,7 @@ export interface ChangeSet { export type ReviewPanelMessageToWebview = | { type: 'init'; changeSet: ChangeSet } | { type: 'updateFileStatus'; fileId: string; status: FileChangeStatus } + | { type: 'updateEditStatus'; fileId: string; editId: string; status: ChangeStatus } | { type: 'updateAllStatus'; status: FileChangeStatus }; /** @@ -81,6 +109,44 @@ export type ReviewPanelMessageToWebview = export type ReviewPanelMessageFromWebview = | { type: 'acceptFile'; fileId: string } | { type: 'rejectFile'; fileId: string } + | { type: 'acceptEdit'; fileId: string; editId: string } + | { type: 'rejectEdit'; fileId: string; editId: string } | { type: 'acceptAll' } | { type: 'rejectAll' } | { type: 'ready' }; + +// ---- 以下为遗留类型,供未迁移的模块使用 ---- + +/** @legacy Diff 变更块类型 */ +export type ChunkType = 'add' | 'delete' | 'modify'; + +/** @legacy Diff 变更块 */ +export interface DiffChunk { + id: string; + startLine: number; + endLine: number; + type: ChunkType; + content: string; + status: 'pending' | 'accepted' | 'rejected' | 'conflict'; + conflictWith?: string; +} + +/** @legacy 快照类型 */ +export interface Snapshot { + id: string; + filePath: string; + baseContent: string; + suggestContent: string; + timestamp: number; + chunks: DiffChunk[]; + status: 'active' | 'completed' | 'cancelled'; +} + +/** @legacy 事务结果 */ +export interface TransactionResult { + success: boolean; + snapshotId: string; + chunkId?: string; + action: 'accept' | 'reject'; + error?: string; +} diff --git a/src/render/reviewPanel.ts b/src/render/reviewPanel.ts index e6409c9..50b2fa3 100644 --- a/src/render/reviewPanel.ts +++ b/src/render/reviewPanel.ts @@ -1,12 +1,15 @@ /** * Review 面板 - 内嵌 Webview 悬浮窗 * - * 双栏 Diff + Accept/Reject 按钮,不弹系统窗口 + * ★ v0.2.0: 支持 per-edit 和 per-file 两级 Accept/Reject + * - 每个文件卡片展开后显示各次编辑记录 + * - 每次编辑有独立的 ✓/✗ 按钮 + * - 文件级 Accept/Reject 一键处理所有编辑 */ import * as vscode from 'vscode'; import { ChangeSetManager } from '../snapshot/snapshotManager'; -import { ChangeSet, FileChange, FileChangeStatus } from '../models/types'; +import { ChangeSet, FileChange, EditRecord, ChangeStatus } from '../models/types'; export class ReviewPanel { private panel: vscode.WebviewPanel | undefined; @@ -41,23 +44,33 @@ export class ReviewPanel { case 'ready': this.sendData(changeSet); break; + + // 单次编辑 Accept/Reject + case 'acceptEdit': + this.changeSetManager.acceptEditRecord(msg.fileId, msg.editId); + this.refresh(); + break; + case 'rejectEdit': + this.changeSetManager.rejectEditRecord(msg.fileId, msg.editId); + this.refresh(); + break; + + // 整个文件 Accept/Reject case 'acceptFile': - this.changeSetManager.updateFileStatus(msg.fileId, 'accepted'); - this.changeSetManager.applyFileChange(msg.fileId); + this.changeSetManager.acceptFile(msg.fileId); this.refresh(); break; case 'rejectFile': - this.changeSetManager.updateFileStatus(msg.fileId, 'rejected'); - this.changeSetManager.rejectFileChange(msg.fileId); + this.changeSetManager.rejectFile(msg.fileId); this.refresh(); break; + + // 全局操作 case 'acceptAll': - this.changeSetManager.updateAllStatus('accepted'); - this.changeSetManager.applyAllAccepted(); + this.changeSetManager.acceptAll(); this.refresh(); break; case 'rejectAll': - this.changeSetManager.updateAllStatus('rejected'); this.changeSetManager.rejectAll(); this.refresh(); break; @@ -124,12 +137,12 @@ export class ReviewPanel { } .badge-modify { background:rgba(210,153,34,.2); color:#d29922; } .badge-create { background:rgba(46,160,67,.2); color:#3fb950; } - .badge-delete { background:rgba(248,81,73,.2); color:#f85149; } .badge-accepted { background:rgba(46,160,67,.3); color:#3fb950; } .badge-rejected { background:rgba(248,81,73,.3); color:#f85149; text-decoration:line-through; } .file-actions { display:flex; gap:6px; } .btn-sm { padding:3px 10px; border:none; border-radius:3px; cursor:pointer; font-size:11px; } + /* Diff 双栏 */ .diff-wrap { display:grid; grid-template-columns:1fr 1fr; font-family:'Cascadia Code','Fira Code',Consolas,monospace; font-size:12px; line-height:20px; } .diff-side { overflow-x:auto; } .diff-side.left { border-right:1px solid var(--vscode-panel-border); } @@ -148,6 +161,28 @@ export class ReviewPanel { .diff-side.left .diff-line.add { background:transparent; } .diff-side.right .diff-line.del { background:transparent; } .empty-line { min-height:20px; } + + /* ★ 编辑记录列表 */ + .edits-list { padding:0; } + .edit-item { + border-bottom:1px solid var(--vscode-panel-border); + } + .edit-item:last-child { border-bottom:none; } + .edit-header { + display:flex; align-items:center; justify-content:space-between; + padding:6px 12px; background:var(--vscode-editor-background); cursor:pointer; + font-size:12px; + } + .edit-header:hover { background:var(--vscode-list-hoverBackground); } + .edit-label { display:flex; align-items:center; gap:6px; color:var(--vscode-descriptionForeground); } + .edit-label .edit-num { font-weight:600; color:var(--vscode-foreground); } + .edit-status { font-size:10px; } + .edit-actions { display:flex; gap:4px; } + .btn-xs { padding:2px 8px; border:none; border-radius:3px; cursor:pointer; font-size:10px; } + + .summary-stats { font-size:11px; color:var(--vscode-descriptionForeground); padding:4px 12px; } + .stat-add { color:#3fb950; } + .stat-del { color:#f85149; } @@ -175,6 +210,8 @@ function acceptAll(){ vscode.postMessage({type:'acceptAll'}); } function rejectAll(){ vscode.postMessage({type:'rejectAll'}); } function acceptFile(id){ vscode.postMessage({type:'acceptFile',fileId:id}); } function rejectFile(id){ vscode.postMessage({type:'rejectFile',fileId:id}); } +function acceptEdit(fid, eid){ vscode.postMessage({type:'acceptEdit',fileId:fid,editId:eid}); } +function rejectEdit(fid, eid){ vscode.postMessage({type:'rejectEdit',fileId:fid,editId:eid}); } function render(){ if(!cs) return; @@ -189,34 +226,99 @@ function card(c){ const name = c.filePath.split(/[/\\\\]/).pop(); const typeMap = {modify:'修改',create:'新增',delete:'删除'}; const badgeCls = 'badge badge-' + c.type; + let statusBadge = ''; if(c.status==='accepted') statusBadge = '已接受'; if(c.status==='rejected') statusBadge = '已拒绝'; + // 统计增减行数 + let adds = 0, dels = 0; + c.diffLines.forEach(l => { if(l.type==='add') adds++; if(l.type==='delete') dels++; }); + d.innerHTML = '
' + '📄 ' + esc(name) + ' ' + typeMap[c.type] + '' + statusBadge + '' + + '+' + adds + ' -' + dels + '' + (c.status==='pending' ? '' + - '' + - '' + + '' + + '' + '' : '' ) + '
' + - '
' + buildDiff(c) + '
'; + '
' + + buildEdits(c) + + '
📊 聚合 Diff(原始 → 最新)
' + + buildDiff(c) + + '
'; return d; } +/** ★ 构建编辑记录列表 */ +function buildEdits(c){ + if(!c.edits || c.edits.length === 0) return ''; + const toolMap = {Edit:'✏️ 编辑', Write:'📝 写入'}; + let h = '
'; + c.edits.forEach((edit, i) => { + let statusCls = '', statusText = ''; + if(edit.status==='accepted'){ statusCls='badge-accepted'; statusText='已接受'; } + if(edit.status==='rejected'){ statusCls='badge-rejected'; statusText='已拒绝'; } + + let editAdds = 0, editDels = 0; + (edit.diffLines||[]).forEach(l => { if(l.type==='add') editAdds++; if(l.type==='delete') editDels++; }); + + h += '
' + + '
' + + '' + + '#' + (i+1) + ' ' + (toolMap[edit.toolName]||edit.toolName) + + ' +' + editAdds + ' -' + editDels + '' + + (statusText ? ' ' + statusText + '' : '') + + '' + + (edit.status==='pending' ? + '' + + '' + + '' + + '' : '' + ) + + '
' + + '
' + buildEditDiff(edit) + '
' + + '
'; + }); + h += '
'; + return h; +} + +/** 单次编辑的 Diff */ +function buildEditDiff(edit){ + let lh='', rh=''; + (edit.diffLines||[]).forEach(l => { + if(l.type==='context'){ + lh += '
'+l.oldLineNum+''+esc(l.content)+'
'; + rh += '
'+l.newLineNum+''+esc(l.content)+'
'; + } else if(l.type==='delete'){ + lh += '
'+l.oldLineNum+''+esc(l.content)+'
'; + rh += '
'; + } else if(l.type==='add'){ + lh += '
'; + rh += '
'+l.newLineNum+''+esc(l.content)+'
'; + } + }); + return '
' + + '
编辑前
'+lh+'
' + + '
编辑后
'+rh+'
' + + '
'; +} + function buildDiff(c){ - if(c.type==='create') return buildSingle(c.newLines||[],'right','新文件'); - if(c.type==='delete') return buildSingle(c.oldLines||[],'left','已删除'); + if(c.type==='create') return buildSingle(c.latestContent.split('\\n'),'right','新文件'); + if(c.type==='delete') return buildSingle(c.originalContent.split('\\n'),'left','已删除'); return buildDual(c.diffLines||[]); } function buildSingle(lines,side,title){ let h = '
' + title + '
'; - lines.forEach(l => { - h += '
' + (l.num||'') + '' + esc(l.content||l) + '
'; + lines.forEach((l,i) => { + h += '
' + (i+1) + '' + esc(l) + '
'; }); return h + '
'; } @@ -243,7 +345,7 @@ function buildDual(dlines){ function esc(s){ return String(s).replace(/&/g,'&').replace(//g,'>'); } - + `; } diff --git a/src/snapshot/snapshotManager.ts b/src/snapshot/snapshotManager.ts index 81e7b4e..aa19fb2 100644 --- a/src/snapshot/snapshotManager.ts +++ b/src/snapshot/snapshotManager.ts @@ -1,11 +1,16 @@ /** * 变更集管理器 - 收集一轮对话中的所有文件变更 + * + * ★ v0.2.0: 引入 EditRecord 粒度 + * - 每次 Edit/Write 创建独立 EditRecord(不合并) + * - FileChange 作为聚合视图,持有 edits: EditRecord[] + * - Accept 后再编辑 → 自动创建新 FileChange(修复 Bug) */ import * as path from 'path'; import * as fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; -import { ChangeSet, FileChange, FileChangeType, FileChangeStatus, DiffLine } from '../models/types'; +import { ChangeSet, FileChange, EditRecord, FileChangeType, ChangeStatus, DiffLine } from '../models/types'; import { diffLines, Change } from 'diff'; function norm(p: string): string { @@ -14,7 +19,7 @@ function norm(p: string): string { export class ChangeSetManager { private currentSet: ChangeSet | null = null; - /** 原始文件内容快照(首次编辑前捕获) */ + /** 原始文件内容快照(每个文件第一次编辑前捕获) */ private originalContents: Map = new Map(); startNewSet(): string { @@ -44,14 +49,13 @@ export class ChangeSetManager { } /** - * 记录一次文件编辑 + * ★ 记录一次文件编辑(重构后) * - * Edit: oldString=修改前的代码片段, newString=修改后的代码片段 - * → 文件当前内容 = 原始内容中 oldString 被替换为 newString 后的结果 - * → 原始内容 = 当前内容中首次出现的 newString 替换回 oldString - * - * Write: content=写入的完整内容 - * → 原始内容无法获取(已覆写),标记为 create + * 核心逻辑: + * 1. 每次 Edit/Write 创建独立 EditRecord + * 2. 如果该文件已有 pending 的 FileChange → 追加 EditRecord + * 3. 如果该文件的 FileChange 已 accepted/rejected → 创建新 FileChange(修复 Bug) + * 4. 如果该文件没有 FileChange → 创建新 FileChange */ recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void { if (!this.currentSet) { @@ -59,46 +63,68 @@ export class ChangeSetManager { } filePath = norm(filePath); + const currentContent = this.readFile(filePath) || ''; + const now = Date.now(); + console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath}`); - // 首次编辑该文件时,捕获原始内容 - if (!this.originalContents.has(filePath)) { - if (toolName === 'Write') { - // Write: 无法获取旧内容 - this.originalContents.set(filePath, ''); - } else if (toolName === 'Edit' && oldString) { - // Edit: 当前文件 = oldString 被替换为 newString 后的结果 - // 反推: 当前文件中找 newString 替换回 oldString = 原始内容 - const currentContent = this.readFile(filePath); - if (currentContent !== null) { - const idx = currentContent.indexOf(newString); - if (idx !== -1) { - const original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); - this.originalContents.set(filePath, original); - console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`); - } else { - // newString 不在文件中,可能已经被之前的编辑影响 - // 用 oldString 作为最小化的原始内容 - this.originalContents.set(filePath, oldString); - console.log(`[AI Diff] newString 未找到,用 oldString 作为原始内容`); - } - } - } + // Step 1: 查找该文件是否已有变更记录 + const existingChange = this.currentSet!.changes.find(c => norm(c.filePath) === filePath); + + // ★ 如果已有变更记录且已 accepted/rejected,需要重置缓存以重新捕获 + if (existingChange && existingChange.status !== 'pending') { + this.originalContents.delete(filePath); } - // 更新文件变更记录 - const existing = this.currentSet.changes.find(c => norm(c.filePath) === filePath); - const currentContent = this.readFile(filePath) || ''; - const originalContent = this.originalContents.get(filePath) || ''; + // Step 2: 确保已捕获原始内容 + const capturedOriginal = this.captureOriginalContent(filePath, toolName, oldString, newString, currentContent); - if (existing) { - // 多次编辑同一文件:更新 newContent 和 diff - existing.newContent = currentContent; - existing.diffLines = this.computeDiffLines(originalContent, currentContent); - console.log(`[AI Diff] 更新变更记录: ${filePath}, diff=${existing.diffLines.length} 行`); + // Step 3: 确定本次编辑的 beforeContent + let beforeContent: string; + + if (existingChange) { + const lastEdit = existingChange.edits[existingChange.edits.length - 1]; + if (existingChange.status === 'pending') { + // 还在收集中,before 使用上一次编辑的 afterContent + beforeContent = lastEdit.afterContent; + } else { + // ★ Bug 修复:已 accepted/rejected,需要新的 beforeContent + // 重新捕获:当前文件内容 = 本次编辑后的结果 + beforeContent = this.reverseEdit(currentContent, oldString, newString); + } } else { - // 新建变更记录 - const changeType: FileChangeType = (toolName === 'Write' && !originalContent) + // 首次编辑该文件 + beforeContent = capturedOriginal; + } + + // Step 3: 计算本次编辑的独立 Diff + const afterContent = currentContent; + const editDiffLines = this.computeDiffLines(beforeContent, afterContent); + + // Step 4: 创建 EditRecord + const editRecord: EditRecord = { + id: uuidv4(), + filePath, + toolName: toolName as 'Edit' | 'Write', + beforeContent, + afterContent, + oldString: oldString || '', + newString: newString || '', + diffLines: editDiffLines, + status: 'pending', + timestamp: now, + }; + + // Step 5: 决定 FileChange 策略 + if (existingChange && existingChange.status === 'pending') { + // 追加到已有 FileChange + existingChange.edits.push(editRecord); + existingChange.latestContent = afterContent; + existingChange.diffLines = this.computeDiffLines(existingChange.originalContent, afterContent); + console.log(`[AI Diff] 追加 EditRecord 到已有 FileChange: ${filePath}, edits=${existingChange.edits.length}`); + } else { + // 创建新 FileChange(首次编辑 或 之前的已 accepted/rejected) + const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal) ? 'create' : 'modify'; @@ -106,53 +132,186 @@ export class ChangeSetManager { id: uuidv4(), filePath, type: changeType, - oldContent: originalContent, - newContent: currentContent, - diffLines: this.computeDiffLines(originalContent, currentContent), + edits: [editRecord], + originalContent: capturedOriginal, + latestContent: afterContent, + diffLines: editDiffLines, status: 'pending', - timestamp: Date.now(), }; - this.currentSet.changes.push(fileChange); - console.log(`[AI Diff] 新增变更记录: ${filePath}, type=${changeType}, diff=${fileChange.diffLines.length} 行`); + this.currentSet!.changes.push(fileChange); + if (existingChange) { + console.log(`[AI Diff] ★ 新建 FileChange(旧 FileChange 已 ${existingChange.status}): ${filePath}`); + } else { + console.log(`[AI Diff] 新建 FileChange: ${filePath}, type=${changeType}`); + } } } - updateFileStatus(fileId: string, status: FileChangeStatus): void { - if (!this.currentSet) return; - const change = this.currentSet.changes.find(c => c.id === fileId); - if (change) change.status = status; - } + /** + * 捕获文件的原始内容 + */ + private captureOriginalContent( + filePath: string, + toolName: string, + oldString: string, + newString: string, + currentContent: string + ): string { + if (this.originalContents.has(filePath)) { + return this.originalContents.get(filePath)!; + } - updateAllStatus(status: FileChangeStatus): void { - if (!this.currentSet) return; - this.currentSet.changes.forEach(c => { c.status = status; }); + let original: string; + + if (toolName === 'Write') { + // Write: 无法获取旧内容 + original = ''; + } else if (toolName === 'Edit' && oldString) { + // Edit: 从当前文件中反推原始内容 + const idx = currentContent.indexOf(newString); + if (idx !== -1) { + original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); + } else { + // newString 不在文件中,可能已被之前的编辑影响 + original = oldString; + } + } else { + original = currentContent; + } + + this.originalContents.set(filePath, original); + console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`); + return original; } /** - * 接受文件变更(把 newContent 写入磁盘) + * 反推编辑前的内容(用于已 accepted/rejected 后新编辑的场景) + * currentContent = 编辑后内容,reverseEdit = 从后往前还原 */ - applyFileChange(fileId: string): boolean { - if (!this.currentSet) return false; + private reverseEdit(currentContent: string, oldString: string, newString: string): string { + if (!newString) return currentContent; + const idx = currentContent.indexOf(newString); + if (idx !== -1) { + const before = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); + return before; + } + return currentContent; + } + + // ---- 状态更新 ---- + + /** 更新单个编辑记录的状态 */ + updateEditStatus(fileId: string, editId: string, status: ChangeStatus): void { + if (!this.currentSet) return; const change = this.currentSet.changes.find(c => c.id === fileId); - if (!change || change.status !== 'accepted') return false; + if (!change) return; + const edit = change.edits.find(e => e.id === editId); + if (edit) edit.status = status; + + // 如果该文件所有编辑都已处理,更新文件状态 + if (change.edits.every(e => e.status !== 'pending')) { + const allAccepted = change.edits.every(e => e.status === 'accepted'); + change.status = allAccepted ? 'accepted' : 'rejected'; + } + } + + /** 更新整个文件的状态(同时更新所有编辑) */ + updateFileStatus(fileId: string, status: ChangeStatus): void { + if (!this.currentSet) return; + const change = this.currentSet.changes.find(c => c.id === fileId); + if (change) { + change.status = status; + change.edits.forEach(e => { e.status = status; }); + } + } + + updateAllStatus(status: ChangeStatus): void { + if (!this.currentSet) return; + this.currentSet.changes.forEach(c => { + c.status = status; + c.edits.forEach(e => { e.status = status; }); + }); + } + + // ---- Accept(应用到磁盘) ---- + + /** + * 接受单个编辑 — 内容已在磁盘上,只需标记状态 + */ + acceptEditRecord(fileId: string, editId: string): boolean { + this.updateEditStatus(fileId, editId, 'accepted'); + const change = this.findChange(fileId); + if (!change) return false; + + // 如果整个文件所有编辑都已接受,写入最终的 latestContent + if (change.edits.every(e => e.status === 'accepted')) { + return this.writeFile(change.filePath, change.latestContent); + } + return true; + } + + /** + * 接受整个文件的所有编辑 + */ + acceptFile(fileId: string): boolean { + this.updateFileStatus(fileId, 'accepted'); + const change = this.findChange(fileId); + if (!change) return false; + return this.writeFile(change.filePath, change.latestContent); + } + + /** + * 接受所有变更 + */ + acceptAll(): number { + if (!this.currentSet) return 0; + let count = 0; + for (const change of this.currentSet.changes) { + if (change.status === 'pending' && this.acceptFile(change.id)) { + count++; + } + } + return count; + } + + // ---- Reject(恢复原始内容) ---- + + /** + * 拒绝单个编辑 — 从磁盘文件中还原该编辑的变更 + */ + rejectEditRecord(fileId: string, editId: string): boolean { + const change = this.findChange(fileId); + if (!change) return false; + + const edit = change.edits.find(e => e.id === editId); + if (!edit) return false; + + // 从当前文件中还原该编辑:将 newString 替换回 oldString try { - fs.writeFileSync(change.filePath, change.newContent, 'utf-8'); - console.log(`[AI Diff] 已接受: ${change.filePath}`); - return true; + const currentContent = this.readFile(change.filePath); + if (currentContent !== null) { + const revertedContent = this.revertEditInContent(currentContent, edit); + this.writeFile(change.filePath, revertedContent); + } } catch (e) { - console.error(`[AI Diff] 写入文件失败: ${change.filePath}`, e); + console.error(`[AI Diff] 还原编辑失败: ${change.filePath}`, e); return false; } + + this.updateEditStatus(fileId, editId, 'rejected'); + return true; } /** - * 拒绝文件变更(把 oldContent 写回磁盘,恢复原始状态) + * 拒绝整个文件的所有编辑 — 恢复 originalContent */ - rejectFileChange(fileId: string): boolean { - if (!this.currentSet) return false; - const change = this.currentSet.changes.find(c => c.id === fileId); - if (!change || change.status !== 'rejected') return false; + rejectFile(fileId: string): boolean { + const change = this.findChange(fileId); + if (!change) return false; + + this.updateFileStatus(fileId, 'rejected'); + try { if (change.type === 'create') { // 新建的文件 → 删除 @@ -160,8 +319,8 @@ export class ChangeSetManager { fs.unlinkSync(change.filePath); } } else { - // 修改/删除的文件 → 恢复原始内容 - fs.writeFileSync(change.filePath, change.oldContent, 'utf-8'); + // 修改的文件 → 恢复原始内容 + this.writeFile(change.filePath, change.originalContent); } console.log(`[AI Diff] 已拒绝: ${change.filePath}`); return true; @@ -171,26 +330,34 @@ export class ChangeSetManager { } } - applyAllAccepted(): number { + /** + * 拒绝所有变更 + */ + rejectAll(): number { if (!this.currentSet) return 0; let count = 0; for (const change of this.currentSet.changes) { - if (change.status === 'accepted' && this.applyFileChange(change.id)) { + if (change.status === 'pending' && this.rejectFile(change.id)) { count++; } } return count; } - rejectAll(): number { - if (!this.currentSet) return 0; - let count = 0; - for (const change of this.currentSet.changes) { - if (change.status === 'rejected' && this.rejectFileChange(change.id)) { - count++; - } + // ---- 工具方法 ---- + + /** + * 在内容中还原单个编辑 + */ + private revertEditInContent(content: string, edit: EditRecord): string { + // 找到 newString 并替换回 oldString + const idx = content.indexOf(edit.newString); + if (idx !== -1) { + return content.substring(0, idx) + edit.oldString + content.substring(idx + edit.newString.length); } - return count; + // 找不到精确匹配,回退:使用 beforeContent + console.warn(`[AI Diff] 无法精确定位编辑替换位置,回退到 beforeContent`); + return edit.beforeContent; } clear(): void { @@ -204,6 +371,22 @@ export class ChangeSetManager { this.currentSet.changes.some(c => c.status === 'pending'); } + private findChange(fileId: string): FileChange | undefined { + if (!this.currentSet) return undefined; + return this.currentSet.changes.find(c => c.id === fileId); + } + + private writeFile(filePath: string, content: string): boolean { + try { + fs.writeFileSync(filePath, content, 'utf-8'); + console.log(`[AI Diff] 写入文件: ${filePath}`); + return true; + } catch (e) { + console.error(`[AI Diff] 写入文件失败: ${filePath}`, e); + return false; + } + } + private computeDiffLines(oldContent: string, newContent: string): DiffLine[] { const changes: Change[] = diffLines(oldContent, newContent); const lines: DiffLine[] = []; @@ -242,3 +425,6 @@ export class ChangeSetManager { } } } + +/** @legacy 向后兼容别名,供未迁移的模块使用 */ +export { ChangeSetManager as SnapshotManager };