mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-10-08 18:31:51 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.vsix
|
||||
.DS_Store
|
||||
*.log
|
||||
coverage/
|
||||
.env
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 预览"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4444
File diff suppressed because it is too large
Load Diff
+122
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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: 已拒绝所有变更');
|
||||
}
|
||||
}
|
||||
@@ -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 监听器已注册');
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user