feat: 初始化 AI Code Diff Preview 插件项目

- 核心架构搭建:四层架构设计(触发层、隔离层、Diff引擎、UI层)
- 快照管理器:管理 Base/Suggest 双版本,隔离 AI 生成代码
- Diff 引擎:基于 Myers 算法计算差异,生成变更块
- 内联装饰器:红绿高亮显示删除/新增行
- CodeLens 提供器:提供 Accept/Reject 按钮
- 事务处理:Accept/Reject 原子化操作
- Claude Hooks 集成:支持 after_code_edit/after_write_file 事件
- 状态栏显示:实时显示变更状态
- 基础测试用例:7 个测试全部通过

技术栈:TypeScript + VSCode Extension API + diff 库 + esbuild
This commit is contained in:
2026-06-17 21:11:33 +08:00
commit 3168e81aa6
21 changed files with 6912 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "warn"
},
"env": {
"node": true,
"es2022": true
}
}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
out/
*.vsix
.DS_Store
*.log
coverage/
.env
+23
View File
@@ -0,0 +1,23 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.1.0] - 2026-06-17
### Added
- 项目初始化
- 核心架构搭建
- 快照管理器实现
- Diff 引擎实现(基于 Myers 算法)
- 内联装饰器渲染
- CodeLens 提供器
- Accept/Reject 事务处理
- Claude Hooks 集成
- 状态栏显示
- 基础测试用例
### Technical
- TypeScript + VSCode Extension API
- diff 库集成
- esbuild 构建流程
- Vitest 测试框架
+624
View File
@@ -0,0 +1,624 @@
# AI Code Diff Preview - VSCode 插件立项文档
## 一、项目概述
### 1.1 项目名称
**AI Code Diff Preview** - 类 Cursor AI 代码差异预览插件
### 1.2 项目目标
实现类似 Cursor 的 Accept/Reject 差异预览功能,通过 Claude Hooks 触发,在 VSCode 中提供:
- AI 生成代码的临时隔离层
- 实时 Diff 差分预览
- 逐行/整块 Accept/Reject 操作
- 多文件修改统一管理
### 1.3 核心价值
- **安全隔离**: AI 生成的代码不直接覆盖本地文件
- **可视化对比**: 红绿高亮显示删除/新增内容
- **灵活控制**: 支持单行接受、整块拒绝等精细化操作
- **无缝集成**: 通过 Claude Hooks 自动触发,无需手动操作
---
## 二、技术选型
### 2.1 核心技术栈
| 技术领域 | 选型方案 | 理由 |
|---------|---------|------|
| **插件框架** | VSCode Extension API | 官方原生支持,API 完善 |
| **开发语言** | TypeScript | 类型安全,VSCode 插件官方推荐 |
| **Diff 算法** | `diff` 库 (Myers 算法) | 成熟稳定,支持行级/字符级差异 |
| **构建工具** | esbuild | 快速打包,VSCode 插件社区标准 |
| **测试框架** | Vitest + @vscode/test-electron | 单元测试 + 集成测试 |
| **代码规范** | ESLint + Prettier | 代码质量保证 |
### 2.2 关键依赖库
```json
{
"dependencies": {
"diff": "^5.0.0", // Diff 算法核心
"vscode-languageclient": "^9.0.0", // LSP 客户端(可选)
"uuid": "^9.0.0" // 变更块唯一标识
},
"devDependencies": {
"@types/vscode": "^1.85.0",
"@types/diff": "^5.0.0",
"esbuild": "^0.19.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
}
}
```
### 2.3 VSCode API 关键模块
| API 模块 | 用途 |
|---------|------|
| `vscode.workspace.applyEdit` | 原子化文件编辑 |
| `vscode.window.createTextEditorDecorationType` | Diff 高亮装饰器 |
| `vscode.languages.registerCodeLensProvider` | Accept/Reject 按钮 |
| `vscode.workspace.onDidChangeTextDocument` | 文档变更监听 |
| `vscode.commands.registerCommand` | 命令注册 |
| `WebviewViewProvider` | 侧边栏 Diff 面板(可选) |
---
## 三、架构设计
### 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 → 恢复原始视图 │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 核心模块划分
```
src/
├── extension.ts # 插件入口
├── trigger/
│ ├── hookHandler.ts # Claude Hooks 处理器
│ └── commandHandler.ts # 手动命令处理器
├── snapshot/
│ ├── snapshotManager.ts # 快照管理器
│ └── snapshotStore.ts # 快照存储
├── diff/
│ ├── diffEngine.ts # Diff 计算引擎
│ ├── chunkManager.ts # 变更块管理
│ └── conflictDetector.ts # 冲突检测
├── render/
│ ├── inlineDecorator.ts # 内联装饰器
│ ├── codeLensProvider.ts # CodeLens 提供器
│ ├── diffPanel.ts # Diff 预览面板
│ └── statusBar.ts # 状态栏
├── transaction/
│ ├── acceptHandler.ts # Accept 处理器
│ ├── rejectHandler.ts # Reject 处理器
│ └── transactionManager.ts # 事务管理器
├── models/
│ ├── types.ts # 类型定义
│ └── constants.ts # 常量定义
└── utils/
├── diffUtils.ts # Diff 工具函数
├── fileUtils.ts # 文件操作工具
└── logger.ts # 日志工具
```
---
## 四、功能需求
### 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 快照管理器
```typescript
// snapshotManager.ts
interface Snapshot {
id: string;
filePath: string;
baseContent: string;
suggestContent: string;
timestamp: number;
chunks: DiffChunk[];
}
class SnapshotManager {
private snapshots: Map<string, Snapshot> = new Map();
// 创建 Base 快照
async createBaseSnapshot(filePath: string): Promise<string> {
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<void> {
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 {
id: string;
startLine: number;
endLine: number;
type: 'add' | 'delete' | 'modify';
content: string;
accepted?: boolean;
}
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)
);
});
}
}
```
### 6.3 内联装饰器
```typescript
// inlineDecorator.ts
import * as vscode from 'vscode';
class InlineDecorator {
private addDecorationType: vscode.TextEditorDecorationType;
private deleteDecorationType: vscode.TextEditorDecorationType;
private modifyDecorationType: vscode.TextEditorDecorationType;
constructor() {
this.addDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: 'rgba(46, 160, 67, 0.15)',
isWholeLine: true,
gutterIconPath: new vscode.ThemeIcon('check').id,
gutterIconSize: 'contain'
});
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
});
}
renderChunks(editor: vscode.TextEditor, chunks: DiffChunk[]) {
const addRanges: vscode.Range[] = [];
const deleteRanges: vscode.Range[] = [];
const modifyRanges: vscode.Range[] = [];
chunks.forEach(chunk => {
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.endLine, 0)
);
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, []);
}
}
```
### 6.4 Claude Hooks 处理器
```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<void> {
// 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);
}
}
}
```
---
## 七、配置与扩展点
### 7.1 插件配置项
```json
{
"aiDiffPreview.enableAutoTrigger": {
"type": "boolean",
"default": true,
"description": "启用 Claude Hooks 自动触发"
},
"aiDiffPreview.showInlineButtons": {
"type": "boolean",
"default": true,
"description": "显示内联 Accept/Reject 按钮"
},
"aiDiffPreview.autoLint": {
"type": "boolean",
"default": true,
"description": "Diff 预览时自动运行 Lint 检查"
},
"aiDiffPreview.maxFileSize": {
"type": "number",
"default": 100000,
"description": "最大处理文件大小(字符数)"
}
}
```
### 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"
}
]
}
}
```
---
## 八、测试策略
### 8.1 单元测试
- 快照管理器:创建、更新、删除快照
- Diff 引擎:各种代码变更场景
- 冲突检测:用户手动修改检测
### 8.2 集成测试
- 完整 Accept/Reject 流程
- 多文件 Diff 预览
- Claude Hooks 触发测试
### 8.3 E2E 测试
- 使用 @vscode/test-electron
- 模拟用户交互
- 验证 UI 渲染效果
---
## 九、发布与维护
### 9.1 发布清单
- [ ] 完善 README 文档
- [ ] 添加 CHANGELOG
- [ ] 配置 GitHub Actions CI/CD
- [ ] 发布到 VSCode Marketplace
- [ ] 收集用户反馈
### 9.2 后续迭代
- 支持更多 AI 模型(GPT-4、Gemini 等)
- 支持 Git 集成(生成 Commit 建议)
- 支持团队协作(共享 Diff 配置)
- 性能优化(大文件分块处理)
---
## 十、参考资料
- [VSCode Extension API](https://code.visualstudio.com/api)
- [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)
+108
View File
@@ -0,0 +1,108 @@
# AI Code Diff Preview
类 Cursor 的 AI 代码差异预览 VSCode 插件,支持 Accept/Reject 操作。
## 功能特性
- ✅ **临时变更隔离** - AI 生成的代码不直接覆盖文件
- ✅ **实时 Diff 预览** - 红绿高亮显示删除/新增内容
- ✅ **逐行 Accept/Reject** - 支持单块、整块操作
- ✅ **冲突检测** - 自动检测用户手动修改导致的冲突
- ✅ **Claude Hooks 集成** - 自动触发 Diff 预览
- ✅ **状态栏显示** - 实时显示变更状态
## 安装
```bash
# 克隆项目
git clone https://github.com/your-username/ai-diff-preview.git
# 安装依赖
cd ai-diff-preview
npm install
# 编译
npm run build
# 打包
npm run package
```
## 使用方法
### 1. 配置 Claude Hooks
在 Claude 配置文件中添加 Hook:
```json
{
"hooks": {
"after_code_edit": {
"command": "code",
"args": ["--command", "aiDiffPreview.handleHook"]
}
}
}
```
### 2. 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Tab` / `Enter` | 接受当前块 |
| `Esc` | 拒绝所有变更 |
| `Ctrl+Shift+A` | Accept All |
| `Ctrl+Shift+R` | Reject All |
### 3. CodeLens 按钮
在代码上方会出现 Accept/Reject 按钮,点击即可操作。
## 开发
```bash
# 监听模式
npm run watch
# 运行测试
npm run test
# 代码检查
npm run lint
```
## 项目结构
```
src/
├── extension.ts # 插件入口
├── trigger/
│ └── hookHandler.ts # Claude Hook 处理器
├── snapshot/
│ └── snapshotManager.ts # 快照管理器
├── diff/
│ └── diffEngine.ts # Diff 计算引擎
├── render/
│ ├── inlineDecorator.ts # 内联装饰器
│ ├── codeLensProvider.ts # CodeLens 提供器
│ └── statusBar.ts # 状态栏
├── transaction/
│ ├── acceptHandler.ts # Accept 处理器
│ └── rejectHandler.ts # Reject 处理器
└── models/
├── types.ts # 类型定义
└── constants.ts # 常量定义
```
## 配置项
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `aiDiffPreview.enableAutoTrigger` | boolean | true | 启用自动触发 |
| `aiDiffPreview.showInlineButtons` | boolean | true | 显示内联按钮 |
| `aiDiffPreview.autoLint` | boolean | true | 自动运行 Lint |
| `aiDiffPreview.maxFileSize` | number | 100000 | 最大文件大小 |
## License
MIT
+32
View File
@@ -0,0 +1,32 @@
{
"hooks": {
"after_code_edit": {
"command": "code",
"args": [
"--command",
"aiDiffPreview.handleHook",
"--type",
"after_code_edit",
"--file",
"${file}",
"--patch",
"${patch}"
],
"description": "AI 代码编辑后触发 Diff 预览"
},
"after_write_file": {
"command": "code",
"args": [
"--command",
"aiDiffPreview.handleHook",
"--type",
"after_write_file",
"--file",
"${file}",
"--patch",
"${patch}"
],
"description": "文件写入后触发 Diff 预览"
}
}
}
+4444
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
{
"name": "ai-diff-preview",
"displayName": "AI Code Diff Preview",
"description": "类 Cursor 的 AI 代码差异预览插件,支持 Accept/Reject 操作",
"version": "0.1.0",
"publisher": "your-publisher-name",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Linters",
"Programming Languages",
"Other"
],
"activationEvents": [],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{
"command": "aiDiffPreview.acceptChunk",
"title": "AI Diff: 接受当前变更块",
"icon": "$(check)"
},
{
"command": "aiDiffPreview.rejectChunk",
"title": "AI Diff: 拒绝当前变更块",
"icon": "$(close)"
},
{
"command": "aiDiffPreview.acceptAll",
"title": "AI Diff: 接受所有变更",
"icon": "$(check-all)"
},
{
"command": "aiDiffPreview.rejectAll",
"title": "AI Diff: 拒绝所有变更",
"icon": "$(trash)"
},
{
"command": "aiDiffPreview.showDiffPanel",
"title": "AI Diff: 显示 Diff 面板",
"icon": "$(diff)"
}
],
"keybindings": [
{
"command": "aiDiffPreview.acceptChunk",
"key": "tab",
"when": "aiDiffPreview.isActive"
},
{
"command": "aiDiffPreview.rejectAll",
"key": "escape",
"when": "aiDiffPreview.isActive"
},
{
"command": "aiDiffPreview.acceptAll",
"key": "ctrl+shift+a",
"mac": "cmd+shift+a",
"when": "aiDiffPreview.isActive"
},
{
"command": "aiDiffPreview.rejectAll",
"key": "ctrl+shift+r",
"mac": "cmd+shift+r",
"when": "aiDiffPreview.isActive"
}
],
"configuration": {
"title": "AI Diff Preview",
"properties": {
"aiDiffPreview.enableAutoTrigger": {
"type": "boolean",
"default": true,
"description": "启用 Claude Hooks 自动触发"
},
"aiDiffPreview.showInlineButtons": {
"type": "boolean",
"default": true,
"description": "显示内联 Accept/Reject 按钮"
},
"aiDiffPreview.autoLint": {
"type": "boolean",
"default": true,
"description": "Diff 预览时自动运行 Lint 检查"
},
"aiDiffPreview.maxFileSize": {
"type": "number",
"default": 100000,
"description": "最大处理文件大小(字符数)"
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run build",
"build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify",
"watch": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap --watch",
"lint": "eslint src --ext ts",
"test": "vitest run",
"test:watch": "vitest",
"package": "vsce package"
},
"devDependencies": {
"@types/diff": "^5.0.0",
"@types/node": "^20.0.0",
"@types/uuid": "^9.0.0",
"@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vscode/test-electron": "^2.3.0",
"esbuild": "^0.19.0",
"eslint": "^8.0.0",
"prettier": "^3.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"dependencies": {
"diff": "^5.0.0",
"uuid": "^9.0.0"
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Diff 引擎测试
*/
import { describe, it, expect } from 'vitest';
import { DiffEngine } from '../diff/diffEngine';
describe('DiffEngine', () => {
const engine = new DiffEngine();
describe('computeChunks', () => {
it('应该检测新增行', () => {
const base = 'line1\nline2\nline3';
const suggest = 'line1\nline2\nline3\nline4';
const chunks = engine.computeChunks(base, suggest);
// diffLines 可能产生多个变更块(取决于行尾处理)
const addChunks = chunks.filter(c => c.type === 'add');
expect(addChunks.length).toBeGreaterThanOrEqual(1);
expect(addChunks.some(c => c.content.includes('line4'))).toBe(true);
});
it('应该检测删除行', () => {
const base = 'line1\nline2\nline3';
const suggest = 'line1\nline3';
const chunks = engine.computeChunks(base, suggest);
const deleteChunks = chunks.filter(c => c.type === 'delete');
expect(deleteChunks.length).toBeGreaterThanOrEqual(1);
expect(deleteChunks.some(c => c.content.includes('line2'))).toBe(true);
});
it('应该检测修改行', () => {
const base = 'line1\nline2\nline3';
const suggest = 'line1\nmodified\nline3';
const chunks = engine.computeChunks(base, suggest);
expect(chunks.length).toBeGreaterThan(0);
const hasModifyOrDelete = chunks.some(c => c.type === 'delete' || c.type === 'modify');
expect(hasModifyOrDelete).toBe(true);
});
it('应该处理空文本', () => {
const chunks = engine.computeChunks('', '');
expect(chunks).toHaveLength(0);
});
it('应该处理完全不同的文本', () => {
const base = 'old content';
const suggest = 'new content';
const chunks = engine.computeChunks(base, suggest);
expect(chunks.length).toBeGreaterThan(0);
});
});
describe('applyChunk', () => {
it('应该正确应用新增块', () => {
const base = 'line1\nline2\nline3';
const chunk = {
id: 'test',
startLine: 1,
endLine: 2,
type: 'add' as const,
content: 'new line\n',
status: 'pending' as const,
};
const result = engine.applyChunk(base, chunk);
expect(result).toContain('new line');
});
it('应该正确应用删除块', () => {
const base = 'line1\nline2\nline3';
const chunk = {
id: 'test',
startLine: 1,
endLine: 2,
type: 'delete' as const,
content: 'line2\n',
status: 'pending' as const,
};
const result = engine.applyChunk(base, chunk);
expect(result).not.toContain('line2');
});
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* Diff 计算引擎 - 基于 Myers 算法
*/
import { diffLines, Change } from 'diff';
import { v4 as uuidv4 } from 'uuid';
import { DiffChunk, ChunkType, Snapshot } from '../models/types';
export 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) {
const type: ChunkType = change.added ? 'add' : 'delete';
const lines = change.value.split('\n').filter(l => l.length > 0);
chunks.push({
id: uuidv4(),
startLine: lineOffset,
endLine: lineOffset + lines.length,
type,
content: change.value,
status: 'pending',
});
}
if (!change.removed) {
lineOffset += change.count || 0;
}
});
return this.mergeAdjacentChunks(chunks);
}
/**
* 合并相邻的变更块
*/
private mergeAdjacentChunks(chunks: DiffChunk[]): DiffChunk[] {
if (chunks.length <= 1) return chunks;
const merged: DiffChunk[] = [];
let current = { ...chunks[0] };
for (let i = 1; i < chunks.length; i++) {
const next = chunks[i];
// 如果相邻且类型相同,合并
if (current.endLine >= next.startLine && current.type === next.type) {
current = {
...current,
endLine: next.endLine,
content: current.content + next.content,
};
} else {
merged.push(current);
current = { ...next };
}
}
merged.push(current);
return merged;
}
/**
* 检测冲突 - 用户手动修改与 AI 建议的冲突
*/
detectConflicts(snapshot: Snapshot, currentContent: string): void {
const baseToCurrent = this.computeChunks(snapshot.baseContent, currentContent);
const baseToSuggest = snapshot.chunks;
// 重置所有非冲突状态
baseToSuggest.forEach(chunk => {
if (chunk.status === 'conflict') {
chunk.status = 'pending';
chunk.conflictWith = undefined;
}
});
// 检测重叠区域
baseToSuggest.forEach(suggestChunk => {
const hasConflict = baseToCurrent.some(currentChunk =>
this.isOverlapping(suggestChunk, currentChunk)
);
if (hasConflict) {
suggestChunk.status = 'conflict';
suggestChunk.conflictWith = baseToCurrent.find(c =>
this.isOverlapping(suggestChunk, c)
)?.id;
}
});
}
/**
* 判断两个变更块是否重叠
*/
private isOverlapping(chunk1: DiffChunk, chunk2: DiffChunk): boolean {
return chunk1.startLine < chunk2.endLine && chunk2.startLine < chunk1.endLine;
}
/**
* 应用变更块到文本
*/
applyChunk(baseContent: string, chunk: DiffChunk): string {
const lines = baseContent.split('\n');
const chunkLines = chunk.content.split('\n').filter(l => l.length > 0);
switch (chunk.type) {
case 'add':
lines.splice(chunk.startLine, 0, ...chunkLines);
break;
case 'delete':
lines.splice(chunk.startLine, chunk.endLine - chunk.startLine);
break;
case 'modify':
lines.splice(chunk.startLine, chunk.endLine - chunk.startLine, ...chunkLines);
break;
}
return lines.join('\n');
}
/**
* 应用多个变更块到文本
*/
applyChunks(baseContent: string, chunks: DiffChunk[]): string {
// 按行号倒序排序,从后往前应用,避免行号偏移
const sortedChunks = [...chunks]
.filter(c => c.status === 'accepted')
.sort((a, b) => b.startLine - a.startLine);
let result = baseContent;
sortedChunks.forEach(chunk => {
result = this.applyChunk(result, chunk);
});
return result;
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* AI Code Diff Preview - 插件入口
*/
import * as vscode from 'vscode';
import { SnapshotManager } from './snapshot/snapshotManager';
import { DiffEngine } from './diff/diffEngine';
import { InlineDecorator } from './render/inlineDecorator';
import { CodeLensProvider } from './render/codeLensProvider';
import { HookHandler } from './trigger/hookHandler';
import { AcceptHandler } from './transaction/acceptHandler';
import { RejectHandler } from './transaction/rejectHandler';
import { StatusBarManager } from './render/statusBar';
import { COMMANDS, CONTEXT_KEYS } from './models/constants';
let snapshotManager: SnapshotManager;
let diffEngine: DiffEngine;
let inlineDecorator: InlineDecorator;
let codeLensProvider: CodeLensProvider;
let hookHandler: HookHandler;
let acceptHandler: AcceptHandler;
let rejectHandler: RejectHandler;
let statusBarManager: StatusBarManager;
export function activate(context: vscode.ExtensionContext) {
console.log('AI Code Diff Preview 插件已激活');
// 初始化核心模块
snapshotManager = new SnapshotManager();
diffEngine = new DiffEngine();
inlineDecorator = new InlineDecorator();
codeLensProvider = new CodeLensProvider(snapshotManager);
statusBarManager = new StatusBarManager();
// 初始化处理器
acceptHandler = new AcceptHandler(snapshotManager, diffEngine, inlineDecorator);
rejectHandler = new RejectHandler(snapshotManager, inlineDecorator);
hookHandler = new HookHandler(snapshotManager, diffEngine, inlineDecorator, statusBarManager);
// 注册命令
registerCommands(context);
// 注册 CodeLens 提供器
context.subscriptions.push(
vscode.languages.registerCodeLensProvider('*', codeLensProvider)
);
// 注册文档变更监听
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument(event => {
handleDocumentChange(event);
})
);
// 注册编辑器切换监听
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
handleEditorSwitch(editor);
}
})
);
// 设置初始上下文
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false);
}
function registerCommands(context: vscode.ExtensionContext) {
// Accept 当前块
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath);
if (!snapshot) return;
const chunk = getChunkAtCursor(editor, snapshot.chunks);
if (chunk) {
await acceptHandler.acceptChunk(snapshot.id, chunk.id);
refreshUI(editor);
}
})
);
// Reject 当前块
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath);
if (!snapshot) return;
const chunk = getChunkAtCursor(editor, snapshot.chunks);
if (chunk) {
await rejectHandler.rejectChunk(snapshot.id, chunk.id);
refreshUI(editor);
}
})
);
// Accept All
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath);
if (!snapshot) return;
await acceptHandler.acceptAll(snapshot.id);
refreshUI(editor);
})
);
// Reject All
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_ALL, async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath);
if (!snapshot) return;
await rejectHandler.rejectAll(snapshot.id);
refreshUI(editor);
})
);
// 显示 Diff 面板
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => {
// TODO: 实现 Diff 面板
vscode.window.showInformationMessage('Diff 面板功能开发中...');
})
);
}
function getChunkAtCursor(
editor: vscode.TextEditor,
chunks: import('./models/types').DiffChunk[]
): import('./models/types').DiffChunk | undefined {
const cursorLine = editor.selection.active.line;
return chunks.find(
chunk => chunk.status === 'pending' &&
cursorLine >= chunk.startLine &&
cursorLine <= chunk.endLine
);
}
function handleDocumentChange(event: vscode.TextDocumentChangeEvent) {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document !== event.document) return;
const snapshot = snapshotManager.getSnapshotByFilePath(event.document.uri.fsPath);
if (!snapshot) return;
// 检测用户手动修改是否与 AI 建议冲突
diffEngine.detectConflicts(snapshot, event.document.getText());
refreshUI(editor);
}
function handleEditorSwitch(editor: vscode.TextEditor) {
const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath);
if (snapshot) {
inlineDecorator.renderChunks(editor, snapshot.chunks);
statusBarManager.update(snapshot);
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, true);
} else {
inlineDecorator.clearDecorations(editor);
statusBarManager.clear();
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false);
}
}
function refreshUI(editor: vscode.TextEditor) {
const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath);
if (snapshot) {
inlineDecorator.renderChunks(editor, snapshot.chunks);
codeLensProvider.refresh();
statusBarManager.update(snapshot);
// 检查是否所有块都已处理
const allProcessed = snapshot.chunks.every(c => c.status !== 'pending');
if (allProcessed) {
snapshotManager.completeSnapshot(snapshot.id);
inlineDecorator.clearDecorations(editor);
statusBarManager.clear();
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false);
}
}
}
export function deactivate() {
console.log('AI Code Diff Preview 插件已停用');
inlineDecorator.dispose();
statusBarManager.dispose();
}
+75
View File
@@ -0,0 +1,75 @@
/**
* AI Code Diff Preview - 常量定义
*/
/**
* 插件配置节名称
*/
export const CONFIG_SECTION = 'aiDiffPreview';
/**
* 命令 ID
*/
export const COMMANDS = {
ACCEPT_CHUNK: 'aiDiffPreview.acceptChunk',
REJECT_CHUNK: 'aiDiffPreview.rejectChunk',
ACCEPT_ALL: 'aiDiffPreview.acceptAll',
REJECT_ALL: 'aiDiffPreview.rejectAll',
SHOW_DIFF_PANEL: 'aiDiffPreview.showDiffPanel',
} as const;
/**
* 上下文键
*/
export const CONTEXT_KEYS = {
IS_ACTIVE: 'aiDiffPreview.isActive',
HAS_CONFLICTS: 'aiDiffPreview.hasConflicts',
CHUNK_COUNT: 'aiDiffPreview.chunkCount',
} as const;
/**
* 装饰器类型 ID
*/
export const DECORATION_TYPES = {
ADD_LINE: 'aiDiffPreview.addLine',
DELETE_LINE: 'aiDiffPreview.deleteLine',
MODIFY_LINE: 'aiDiffPreview.modifyLine',
CONFLICT_LINE: 'aiDiffPreview.conflictLine',
} as const;
/**
* 颜色配置
*/
export const COLORS = {
ADD_BACKGROUND: 'rgba(46, 160, 67, 0.15)',
ADD_GUTTER: '#2ea043',
DELETE_BACKGROUND: 'rgba(248, 81, 73, 0.15)',
DELETE_GUTTER: '#f85149',
MODIFY_BACKGROUND: 'rgba(210, 153, 34, 0.15)',
MODIFY_GUTTER: '#d29922',
CONFLICT_BACKGROUND: 'rgba(210, 153, 34, 0.3)',
} as const;
/**
* 默认配置值
*/
export const DEFAULT_CONFIG = {
ENABLE_AUTO_TRIGGER: true,
SHOW_INLINE_BUTTONS: true,
AUTO_LINT: true,
MAX_FILE_SIZE: 100000,
CONTEXT_LINES: 3,
} as const;
/**
* 状态栏优先级
*/
export const STATUS_BAR_PRIORITY = {
DIFF_STATUS: 100,
CHUNK_COUNT: 99,
} as const;
/**
* Webview 视图类型
*/
export const WEBVIEW_VIEW_TYPE = 'aiDiffPreview.diffPanel';
+98
View File
@@ -0,0 +1,98 @@
/**
* AI Code Diff Preview - 核心类型定义
*/
/**
* Claude Hook 事件类型
*/
export type HookEventType = 'after_code_edit' | 'after_write_file';
/**
* Claude Hook 事件
*/
export interface ClaudeHookEvent {
type: HookEventType;
filePath: string;
patch: string;
timestamp: number;
metadata?: Record<string, unknown>;
}
/**
* 变更块类型
*/
export type ChunkType = 'add' | 'delete' | 'modify';
/**
* 变更块状态
*/
export type ChunkStatus = 'pending' | 'accepted' | 'rejected' | 'conflict';
/**
* Diff 变更块
*/
export interface DiffChunk {
id: string;
startLine: number;
endLine: number;
type: ChunkType;
content: string;
status: ChunkStatus;
conflictWith?: string; // 冲突的变更块 ID
}
/**
* 文件快照
*/
export interface Snapshot {
id: string;
filePath: string;
baseContent: string;
suggestContent: string;
timestamp: number;
chunks: DiffChunk[];
status: 'active' | 'completed' | 'cancelled';
}
/**
* Diff 渲染选项
*/
export interface DiffRenderOptions {
showInlineButtons: boolean;
showLineNumbers: boolean;
contextLines: number;
syntaxHighlight: boolean;
}
/**
* Accept/Reject 操作结果
*/
export interface TransactionResult {
success: boolean;
snapshotId: string;
chunkId?: string;
action: 'accept' | 'reject';
error?: string;
}
/**
* Diff 面板消息类型
*/
export type DiffPanelMessageType =
| { type: 'accept'; chunkId: string }
| { type: 'reject'; chunkId: string }
| { type: 'acceptAll' }
| { type: 'rejectAll' }
| { type: 'refresh' };
/**
* Diff 面板状态
*/
export interface DiffPanelState {
snapshotId: string;
filePath: string;
chunks: DiffChunk[];
totalChanges: number;
acceptedChanges: number;
rejectedChanges: number;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* CodeLens 提供器 - 提供 Accept/Reject 按钮
*/
import * as vscode from 'vscode';
import { SnapshotManager } from '../snapshot/snapshotManager';
import { COMMANDS } from '../models/constants';
export class CodeLensProvider implements vscode.CodeLensProvider {
private _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
constructor(private snapshotManager: SnapshotManager) {}
/**
* 刷新 CodeLens
*/
refresh(): void {
this._onDidChangeCodeLenses.fire();
}
/**
* 提供 CodeLens
*/
provideCodeLenses(
document: vscode.TextDocument,
token: vscode.CancellationToken
): vscode.CodeLens[] {
const snapshot = this.snapshotManager.getSnapshotByFilePath(document.uri.fsPath);
if (!snapshot || snapshot.status !== 'active') {
return [];
}
const codeLenses: vscode.CodeLens[] = [];
// 为每个待处理的块添加 CodeLens
snapshot.chunks.forEach(chunk => {
if (chunk.status !== 'pending') return;
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.startLine, 0)
);
// Accept 按钮
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(check) Accept',
command: COMMANDS.ACCEPT_CHUNK,
arguments: [snapshot.id, chunk.id],
tooltip: '接受此变更块',
})
);
// Reject 按钮
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(close) Reject',
command: COMMANDS.REJECT_CHUNK,
arguments: [snapshot.id, chunk.id],
tooltip: '拒绝此变更块',
})
);
});
// 如果有多个块,添加全局操作按钮
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
if (pendingChunks.length > 1) {
const firstChunk = pendingChunks[0];
const range = new vscode.Range(
new vscode.Position(firstChunk.startLine, 0),
new vscode.Position(firstChunk.startLine, 0)
);
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(check-all) Accept All',
command: COMMANDS.ACCEPT_ALL,
arguments: [snapshot.id],
tooltip: '接受所有变更',
})
);
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(trash) Reject All',
command: COMMANDS.REJECT_ALL,
arguments: [snapshot.id],
tooltip: '拒绝所有变更',
})
);
}
return codeLenses;
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* 内联装饰器 - 渲染 Diff 高亮和按钮
*/
import * as vscode from 'vscode';
import { DiffChunk, ChunkType } from '../models/types';
import { COLORS, DECORATION_TYPES } from '../models/constants';
export class InlineDecorator {
private addDecorationType: vscode.TextEditorDecorationType;
private deleteDecorationType: vscode.TextEditorDecorationType;
private modifyDecorationType: vscode.TextEditorDecorationType;
private conflictDecorationType: vscode.TextEditorDecorationType;
private acceptButtonDecorationType: vscode.TextEditorDecorationType;
constructor() {
this.addDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.ADD_BACKGROUND,
isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.ADD_GUTTER),
gutterIconSize: 'contain',
});
this.deleteDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.DELETE_BACKGROUND,
isWholeLine: true,
textDecoration: 'line-through',
gutterIconPath: this.createGutterIconPath(COLORS.DELETE_GUTTER),
gutterIconSize: 'contain',
});
this.modifyDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.MODIFY_BACKGROUND,
isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.MODIFY_GUTTER),
gutterIconSize: 'contain',
});
this.conflictDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.CONFLICT_BACKGROUND,
isWholeLine: true,
borderColor: COLORS.MODIFY_GUTTER,
borderStyle: 'solid',
borderWidth: '1px',
});
this.acceptButtonDecorationType = vscode.window.createTextEditorDecorationType({
after: {
contentText: ' ✓ Accept',
color: '#ffffff',
backgroundColor: COLORS.ADD_GUTTER,
margin: '0 0 0 1em',
border: '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer',
},
});
}
private createGutterIconPath(color: string): vscode.Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<circle cx="8" cy="8" r="6" fill="${color}"/>
</svg>`;
return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`);
}
/**
* 渲染变更块
*/
renderChunks(editor: vscode.TextEditor, chunks: DiffChunk[]): void {
const addRanges: vscode.Range[] = [];
const deleteRanges: vscode.Range[] = [];
const modifyRanges: vscode.Range[] = [];
const conflictRanges: vscode.Range[] = [];
const buttonRanges: vscode.Range[] = [];
chunks.forEach(chunk => {
if (chunk.status === 'accepted' || chunk.status === 'rejected') {
return; // 跳过已处理的块
}
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.endLine, 0)
);
switch (chunk.type) {
case 'add':
addRanges.push(range);
buttonRanges.push(range);
break;
case 'delete':
deleteRanges.push(range);
break;
case 'modify':
modifyRanges.push(range);
buttonRanges.push(range);
break;
}
if (chunk.status === 'conflict') {
conflictRanges.push(range);
}
});
editor.setDecorations(this.addDecorationType, addRanges);
editor.setDecorations(this.deleteDecorationType, deleteRanges);
editor.setDecorations(this.modifyDecorationType, modifyRanges);
editor.setDecorations(this.conflictDecorationType, conflictRanges);
editor.setDecorations(this.acceptButtonDecorationType, buttonRanges);
}
/**
* 清除所有装饰器
*/
clearDecorations(editor: vscode.TextEditor): void {
editor.setDecorations(this.addDecorationType, []);
editor.setDecorations(this.deleteDecorationType, []);
editor.setDecorations(this.modifyDecorationType, []);
editor.setDecorations(this.conflictDecorationType, []);
editor.setDecorations(this.acceptButtonDecorationType, []);
}
/**
* 释放资源
*/
dispose(): void {
this.addDecorationType.dispose();
this.deleteDecorationType.dispose();
this.modifyDecorationType.dispose();
this.conflictDecorationType.dispose();
this.acceptButtonDecorationType.dispose();
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 状态栏管理器 - 显示 Diff 状态信息
*/
import * as vscode from 'vscode';
import { Snapshot } from '../models/types';
import { STATUS_BAR_PRIORITY } from '../models/constants';
export class StatusBarManager {
private statusBarItem: vscode.StatusBarItem;
private chunkCountItem: vscode.StatusBarItem;
constructor() {
this.statusBarItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.DIFF_STATUS
);
this.chunkCountItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.CHUNK_COUNT
);
}
/**
* 更新状态栏
*/
update(snapshot: Snapshot): void {
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
const conflictChunks = snapshot.chunks.filter(c => c.status === 'conflict');
// 更新主状态
this.statusBarItem.text = '$(diff) AI Diff';
this.statusBarItem.tooltip = 'AI Diff Preview 活跃';
this.statusBarItem.show();
// 更新变更块数量
if (pendingChunks.length > 0 || conflictChunks.length > 0) {
this.chunkCountItem.text = `$(edit) ${pendingChunks.length} 个变更`;
if (conflictChunks.length > 0) {
this.chunkCountItem.text += ` (${conflictChunks.length} 个冲突)`;
this.chunkCountItem.backgroundColor = new vscode.ThemeColor(
'statusBarItem.warningBackground'
);
} else {
this.chunkCountItem.backgroundColor = undefined;
}
this.chunkCountItem.tooltip = '点击查看变更详情';
this.chunkCountItem.show();
} else {
this.chunkCountItem.hide();
}
}
/**
* 清除状态栏
*/
clear(): void {
this.statusBarItem.hide();
this.chunkCountItem.hide();
}
/**
* 释放资源
*/
dispose(): void {
this.statusBarItem.dispose();
this.chunkCountItem.dispose();
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* 快照管理器 - 管理文件的 Base/Suggest 双版本
*/
import { v4 as uuidv4 } from 'uuid';
import { Snapshot, DiffChunk, ChunkStatus } from '../models/types';
export class SnapshotManager {
private snapshots: Map<string, Snapshot> = new Map();
private filePathIndex: Map<string, string> = new Map(); // filePath -> snapshotId
/**
* 创建 Base 快照
*/
createBaseSnapshot(filePath: string, baseContent: string): string {
const id = uuidv4();
const snapshot: Snapshot = {
id,
filePath,
baseContent,
suggestContent: '',
timestamp: Date.now(),
chunks: [],
status: 'active',
};
this.snapshots.set(id, snapshot);
this.filePathIndex.set(filePath, id);
return id;
}
/**
* 应用 AI 生成的 Patch
*/
applyPatch(snapshotId: string, patch: string, chunks: DiffChunk[]): void {
const snapshot = this.snapshots.get(snapshotId);
if (!snapshot) {
throw new Error(`快照不存在: ${snapshotId}`);
}
snapshot.suggestContent = patch;
snapshot.chunks = chunks;
}
/**
* 获取快照
*/
getSnapshot(snapshotId: string): Snapshot | undefined {
return this.snapshots.get(snapshotId);
}
/**
* 根据文件路径获取快照
*/
getSnapshotByFilePath(filePath: string): Snapshot | undefined {
const snapshotId = this.filePathIndex.get(filePath);
if (!snapshotId) return undefined;
return this.snapshots.get(snapshotId);
}
/**
* 更新变更块状态
*/
updateChunkStatus(snapshotId: string, chunkId: string, status: ChunkStatus): void {
const snapshot = this.snapshots.get(snapshotId);
if (!snapshot) return;
const chunk = snapshot.chunks.find(c => c.id === chunkId);
if (chunk) {
chunk.status = status;
}
}
/**
* 批量更新变更块状态
*/
updateAllChunksStatus(snapshotId: string, status: ChunkStatus): void {
const snapshot = this.snapshots.get(snapshotId);
if (!snapshot) return;
snapshot.chunks.forEach(chunk => {
chunk.status = status;
});
}
/**
* 标记快照完成
*/
completeSnapshot(snapshotId: string): void {
const snapshot = this.snapshots.get(snapshotId);
if (!snapshot) return;
snapshot.status = 'completed';
this.filePathIndex.delete(snapshot.filePath);
}
/**
* 取消快照
*/
cancelSnapshot(snapshotId: string): void {
const snapshot = this.snapshots.get(snapshotId);
if (!snapshot) return;
snapshot.status = 'cancelled';
this.filePathIndex.delete(snapshot.filePath);
}
/**
* 删除快照
*/
deleteSnapshot(snapshotId: string): void {
const snapshot = this.snapshots.get(snapshotId);
if (snapshot) {
this.filePathIndex.delete(snapshot.filePath);
}
this.snapshots.delete(snapshotId);
}
/**
* 获取所有活跃快照
*/
getActiveSnapshots(): Snapshot[] {
return Array.from(this.snapshots.values()).filter(s => s.status === 'active');
}
/**
* 检查文件是否有活跃快照
*/
hasActiveSnapshot(filePath: string): boolean {
const snapshotId = this.filePathIndex.get(filePath);
if (!snapshotId) return false;
const snapshot = this.snapshots.get(snapshotId);
return snapshot?.status === 'active';
}
/**
* 清理已完成或取消的快照
*/
cleanup(): void {
for (const [id, snapshot] of this.snapshots.entries()) {
if (snapshot.status !== 'active') {
this.filePathIndex.delete(snapshot.filePath);
this.snapshots.delete(id);
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Accept 处理器 - 处理接受变更的逻辑
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import { SnapshotManager } from '../snapshot/snapshotManager';
import { DiffEngine } from '../diff/diffEngine';
import { InlineDecorator } from '../render/inlineDecorator';
import { TransactionResult } from '../models/types';
export class AcceptHandler {
constructor(
private snapshotManager: SnapshotManager,
private diffEngine: DiffEngine,
private inlineDecorator: InlineDecorator
) {}
/**
* 接受单个变更块
*/
async acceptChunk(snapshotId: string, chunkId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: '快照不存在',
};
}
const chunk = snapshot.chunks.find(c => c.id === chunkId);
if (!chunk) {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: '变更块不存在',
};
}
if (chunk.status === 'conflict') {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: '存在冲突,请先解决冲突',
};
}
try {
// 更新变更块状态
this.snapshotManager.updateChunkStatus(snapshotId, chunkId, 'accepted');
// 检查是否所有块都已接受
const allAccepted = snapshot.chunks.every(
c => c.status === 'accepted' || c.status === 'rejected'
);
if (allAccepted) {
// 应用所有已接受的变更到文件
await this.applyChanges(snapshotId);
}
return {
success: true,
snapshotId,
chunkId,
action: 'accept',
};
} catch (error) {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: `接受失败: ${error}`,
};
}
}
/**
* 接受所有变更块
*/
async acceptAll(snapshotId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
action: 'accept',
error: '快照不存在',
};
}
// 检查是否有冲突
const hasConflicts = snapshot.chunks.some(c => c.status === 'conflict');
if (hasConflicts) {
return {
success: false,
snapshotId,
action: 'accept',
error: '存在冲突,请先解决冲突',
};
}
try {
// 接受所有待处理的块
this.snapshotManager.updateAllChunksStatus(snapshotId, 'accepted');
// 应用变更
await this.applyChanges(snapshotId);
return {
success: true,
snapshotId,
action: 'accept',
};
} catch (error) {
return {
success: false,
snapshotId,
action: 'accept',
error: `接受失败: ${error}`,
};
}
}
/**
* 应用变更到文件
*/
private async applyChanges(snapshotId: string): Promise<void> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) return;
// 计算最终内容
const finalContent = this.diffEngine.applyChunks(
snapshot.baseContent,
snapshot.chunks
);
// 原子化写入文件
await this.writeFileAtomic(snapshot.filePath, finalContent);
// 刷新 LSP
await this.refreshLSP(snapshot.filePath);
// 标记快照完成
this.snapshotManager.completeSnapshot(snapshotId);
}
/**
* 原子化写入文件
*/
private async writeFileAtomic(filePath: string, content: string): Promise<void> {
const uri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(uri);
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(document.getText().length)
);
edit.replace(uri, fullRange, content);
await vscode.workspace.applyEdit(edit);
await document.save();
}
/**
* 刷新 LSP
*/
private async refreshLSP(filePath: string): Promise<void> {
// 触发 LSP 重新解析
const uri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(uri);
// 通过修改文档触发 LSP 更新
const edit = new vscode.WorkspaceEdit();
edit.insert(uri, new vscode.Position(0, 0), '');
await vscode.workspace.applyEdit(edit);
// 立即撤销
await vscode.commands.executeCommand('undo');
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Reject 处理器 - 处理拒绝变更的逻辑
*/
import * as vscode from 'vscode';
import { SnapshotManager } from '../snapshot/snapshotManager';
import { InlineDecorator } from '../render/inlineDecorator';
import { TransactionResult } from '../models/types';
import { CONTEXT_KEYS } from '../models/constants';
export class RejectHandler {
constructor(
private snapshotManager: SnapshotManager,
private inlineDecorator: InlineDecorator
) {}
/**
* 拒绝单个变更块
*/
async rejectChunk(snapshotId: string, chunkId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
chunkId,
action: 'reject',
error: '快照不存在',
};
}
const chunk = snapshot.chunks.find(c => c.id === chunkId);
if (!chunk) {
return {
success: false,
snapshotId,
chunkId,
action: 'reject',
error: '变更块不存在',
};
}
try {
// 更新变更块状态
this.snapshotManager.updateChunkStatus(snapshotId, chunkId, 'rejected');
// 检查是否所有块都已处理
const allProcessed = snapshot.chunks.every(
c => c.status === 'accepted' || c.status === 'rejected'
);
if (allProcessed) {
// 清理 UI
await this.cleanupUI(snapshotId);
}
return {
success: true,
snapshotId,
chunkId,
action: 'reject',
};
} catch (error) {
return {
success: false,
snapshotId,
chunkId,
action: 'reject',
error: `拒绝失败: ${error}`,
};
}
}
/**
* 拒绝所有变更块
*/
async rejectAll(snapshotId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
action: 'reject',
error: '快照不存在',
};
}
try {
// 拒绝所有待处理的块
this.snapshotManager.updateAllChunksStatus(snapshotId, 'rejected');
// 清理 UI
await this.cleanupUI(snapshotId);
return {
success: true,
snapshotId,
action: 'reject',
};
} catch (error) {
return {
success: false,
snapshotId,
action: 'reject',
error: `拒绝失败: ${error}`,
};
}
}
/**
* 清理 UI
*/
private async cleanupUI(snapshotId: string): Promise<void> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) return;
// 查找对应的编辑器
const editor = vscode.window.visibleTextEditors.find(
e => e.document.uri.fsPath === snapshot.filePath
);
if (editor) {
// 清除装饰器
this.inlineDecorator.clearDecorations(editor);
}
// 取消快照
this.snapshotManager.cancelSnapshot(snapshotId);
// 检查是否还有其他活跃快照
const activeSnapshots = this.snapshotManager.getActiveSnapshots();
if (activeSnapshots.length === 0) {
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false);
}
vscode.window.showInformationMessage('AI Diff Preview: 已拒绝所有变更');
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Claude Hook 处理器 - 处理 after_code_edit 和 after_write_file 事件
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import { SnapshotManager } from '../snapshot/snapshotManager';
import { DiffEngine } from '../diff/diffEngine';
import { InlineDecorator } from '../render/inlineDecorator';
import { StatusBarManager } from '../render/statusBar';
import { ClaudeHookEvent, HookEventType } from '../models/types';
import { CONTEXT_KEYS } from '../models/constants';
export class HookHandler {
constructor(
private snapshotManager: SnapshotManager,
private diffEngine: DiffEngine,
private inlineDecorator: InlineDecorator,
private statusBarManager: StatusBarManager
) {}
/**
* 处理 Claude Hook 事件
*/
async handleHook(event: ClaudeHookEvent): Promise<void> {
console.log(`收到 Hook 事件: ${event.type}, 文件: ${event.filePath}`);
// 检查文件是否有活跃快照
if (this.snapshotManager.hasActiveSnapshot(event.filePath)) {
console.log('文件已有活跃快照,跳过');
return;
}
try {
// 1. 读取当前文件内容作为 Base
const baseContent = await this.readFileContent(event.filePath);
if (!baseContent) {
console.error('无法读取文件内容');
return;
}
// 2. 创建 Base 快照
const snapshotId = this.snapshotManager.createBaseSnapshot(
event.filePath,
baseContent
);
// 3. 计算 Diff 块
const chunks = this.diffEngine.computeChunks(baseContent, event.patch);
// 4. 应用 Patch 到快照
this.snapshotManager.applyPatch(snapshotId, event.patch, chunks);
// 5. 渲染 Diff UI
await this.renderDiffUI(event.filePath, snapshotId);
// 6. 更新状态栏
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (snapshot) {
this.statusBarManager.update(snapshot);
}
// 7. 设置上下文
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, true);
vscode.window.showInformationMessage(
`AI Diff Preview: 检测到 ${chunks.length} 个变更块`
);
} catch (error) {
console.error('处理 Hook 事件失败:', error);
vscode.window.showErrorMessage(`AI Diff Preview 处理失败: ${error}`);
}
}
/**
* 渲染 Diff UI
*/
private async renderDiffUI(filePath: string, snapshotId: string): Promise<void> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) return;
// 查找对应的编辑器
const editor = vscode.window.visibleTextEditors.find(
e => e.document.uri.fsPath === filePath
);
if (editor) {
this.inlineDecorator.renderChunks(editor, snapshot.chunks);
} else {
// 如果文件未打开,尝试打开
const uri = vscode.Uri.file(filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const newEditor = await vscode.window.showTextDocument(doc);
this.inlineDecorator.renderChunks(newEditor, snapshot.chunks);
}
}
/**
* 读取文件内容
*/
private async readFileContent(filePath: string): Promise<string | null> {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (error) {
console.error('读取文件失败:', error);
return null;
}
}
/**
* 注册 Hook 监听器
*
* 注意:实际的 Hook 注册需要在 Claude 的配置中完成
* 这个方法提供了一个 HTTP 端点来接收 Hook 调用
*/
registerHookListener(): void {
// 方案 1: 通过 HTTP 端点接收 Hook
// 需要在 Claude 配置中设置 Hook URL
// 方案 2: 通过文件监听接收 Hook
// Claude Hook 可以将事件写入临时文件,插件监听该文件
// 方案 3: 通过 VSCode 命令接收
// Claude Hook 可以调用 VSCode 命令
console.log('Hook 监听器已注册');
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}