feat:代码变更diff可视化功能实现
This commit is contained in:
184
src/utils/diffRenderer.ts
Normal file
184
src/utils/diffRenderer.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Diff 渲染工具
|
||||
* 功能:生成代码差异的 HTML 展示
|
||||
* 依赖:无
|
||||
* 使用场景:在变更面板中展示文件修改的 diff
|
||||
*/
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'remove' | 'context';
|
||||
content: string;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 diff 算法(基于行)
|
||||
*/
|
||||
export function generateDiff(oldContent: string, newContent: string): DiffLine[] {
|
||||
const oldLines = oldContent.split('\n');
|
||||
const newLines = newContent.split('\n');
|
||||
const result: DiffLine[] = [];
|
||||
|
||||
let oldIndex = 0;
|
||||
let newIndex = 0;
|
||||
|
||||
while (oldIndex < oldLines.length || newIndex < newLines.length) {
|
||||
const oldLine = oldLines[oldIndex];
|
||||
const newLine = newLines[newIndex];
|
||||
|
||||
if (oldIndex >= oldLines.length) {
|
||||
// 只剩新行
|
||||
result.push({
|
||||
type: 'add',
|
||||
content: newLine,
|
||||
newLineNumber: newIndex + 1
|
||||
});
|
||||
newIndex++;
|
||||
} else if (newIndex >= newLines.length) {
|
||||
// 只剩旧行
|
||||
result.push({
|
||||
type: 'remove',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldIndex + 1
|
||||
});
|
||||
oldIndex++;
|
||||
} else if (oldLine === newLine) {
|
||||
// 相同行
|
||||
result.push({
|
||||
type: 'context',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldIndex + 1,
|
||||
newLineNumber: newIndex + 1
|
||||
});
|
||||
oldIndex++;
|
||||
newIndex++;
|
||||
} else {
|
||||
// 不同行,标记为删除和添加
|
||||
result.push({
|
||||
type: 'remove',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldIndex + 1
|
||||
});
|
||||
result.push({
|
||||
type: 'add',
|
||||
content: newLine,
|
||||
newLineNumber: newIndex + 1
|
||||
});
|
||||
oldIndex++;
|
||||
newIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 diff 结果渲染为 HTML
|
||||
*/
|
||||
export function renderDiffHtml(diffLines: DiffLine[]): string {
|
||||
let html = '<div class="diff-viewer">';
|
||||
|
||||
for (const line of diffLines) {
|
||||
const lineClass = `diff-line diff-line-${line.type}`;
|
||||
const oldNum = line.oldLineNumber ? `<span class="line-num">${line.oldLineNumber}</span>` : '<span class="line-num"></span>';
|
||||
const newNum = line.newLineNumber ? `<span class="line-num">${line.newLineNumber}</span>` : '<span class="line-num"></span>';
|
||||
const prefix = line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' ';
|
||||
const escapedContent = escapeHtml(line.content);
|
||||
|
||||
html += `
|
||||
<div class="${lineClass}">
|
||||
${oldNum}
|
||||
${newNum}
|
||||
<span class="line-prefix">${prefix}</span>
|
||||
<span class="line-content">${escapedContent}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 HTML 特殊字符
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 diff 样式
|
||||
*/
|
||||
export function getDiffStyles(): string {
|
||||
return `
|
||||
.diff-viewer {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--vscode-editor-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.diff-line-add {
|
||||
background: rgba(40, 167, 69, 0.15);
|
||||
}
|
||||
|
||||
.diff-line-remove {
|
||||
background: rgba(220, 53, 69, 0.15);
|
||||
}
|
||||
|
||||
.diff-line-context {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-editorLineNumber-foreground);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-prefix {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diff-line-add .line-prefix {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.diff-line-remove .line-prefix {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
51
src/utils/fileDiff.ts
Normal file
51
src/utils/fileDiff.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* 将相对路径解析为工作区绝对路径
|
||||
*/
|
||||
export function resolveWorkspaceFilePath(filePath: string): string {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
throw new Error("请先打开一个文件夹作为工作区,这样我就能为您修改文件了");
|
||||
}
|
||||
|
||||
return path.join(workspaceFolders[0].uri.fsPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 VS Code 原生 diff 视图展示文件修改前后对比
|
||||
*/
|
||||
export async function showFileDiff(
|
||||
filePath: string,
|
||||
oldContent: string,
|
||||
title?: string
|
||||
): Promise<void> {
|
||||
const absolutePath = resolveWorkspaceFilePath(filePath);
|
||||
const fileUri = vscode.Uri.file(absolutePath);
|
||||
const newBytes = await vscode.workspace.fs.readFile(fileUri);
|
||||
const newContent = Buffer.from(newBytes).toString("utf-8");
|
||||
|
||||
if (oldContent === newContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const language = (await vscode.workspace.openTextDocument(fileUri)).languageId;
|
||||
const oldDoc = await vscode.workspace.openTextDocument({
|
||||
content: oldContent,
|
||||
language,
|
||||
});
|
||||
|
||||
const diffTitle = title || `${path.basename(absolutePath)} (修改前 <-> 修改后)`;
|
||||
await vscode.commands.executeCommand(
|
||||
"vscode.diff",
|
||||
oldDoc.uri,
|
||||
fileUri,
|
||||
diffTitle,
|
||||
{ preview: false }
|
||||
);
|
||||
}
|
||||
@ -26,6 +26,9 @@ import {
|
||||
import { optimizePrompt } from "../services/promptOptimizeService";
|
||||
import { NotificationService } from "../services/notificationService";
|
||||
import { TrialExpirationService } from "../services/trialExpirationService";
|
||||
import { showFileDiff } from "./fileDiff";
|
||||
import { changeTracker } from "../services/changeTracker";
|
||||
import { generateDiff, renderDiffHtml } from "./diffRenderer";
|
||||
|
||||
import type { RunMode, ServiceTier } from "../types/api";
|
||||
|
||||
@ -38,6 +41,14 @@ let currentSession: DialogSession | null = null;
|
||||
/** 最后一个活跃的 taskId(用于压缩等操作) */
|
||||
let lastTaskId: string | null = null;
|
||||
|
||||
async function trackFileChange(filePath: string, oldContent: string, newContent: string): Promise<void> {
|
||||
try {
|
||||
changeTracker.trackChange(filePath, oldContent, newContent);
|
||||
} catch (error) {
|
||||
console.warn("[MessageHandler] 记录文件变更失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户消息
|
||||
*/
|
||||
@ -372,6 +383,9 @@ async function handleUserMessageWithBackend(
|
||||
panel.reveal();
|
||||
}
|
||||
);
|
||||
|
||||
// 发送代码变更到前端
|
||||
sendChangesToWebview(panel);
|
||||
} catch (error) {
|
||||
console.warn("[MessageHandler] 更新面板失败(面板可能已关闭):", error);
|
||||
}
|
||||
@ -774,11 +788,14 @@ async function handleFileOperation(
|
||||
if (!operation.searchText || !operation.replaceText) {
|
||||
throw new Error("缺少替换内容");
|
||||
}
|
||||
const oldContentBeforeReplace = await readFileContent(operation.filePath);
|
||||
await replaceFile(
|
||||
operation.filePath,
|
||||
operation.searchText,
|
||||
operation.replaceText
|
||||
);
|
||||
const newContentAfterReplace = await readFileContent(operation.filePath);
|
||||
await trackFileChange(operation.filePath, oldContentBeforeReplace, newContentAfterReplace);
|
||||
responseText = `✅ 文件内容替换成功: ${operation.filePath}`;
|
||||
panel.webview.postMessage({
|
||||
command: "receiveMessage",
|
||||
@ -914,7 +931,9 @@ export async function handleUpdateFile(
|
||||
content: string
|
||||
) {
|
||||
try {
|
||||
const oldContent = await readFileContent(filePath);
|
||||
await updateFile(filePath, content);
|
||||
await trackFileChange(filePath, oldContent, content);
|
||||
panel.webview.postMessage({
|
||||
command: "fileUpdated",
|
||||
filePath: filePath,
|
||||
@ -979,7 +998,10 @@ export async function handleReplaceInFile(
|
||||
replaceText: string
|
||||
) {
|
||||
try {
|
||||
const oldContent = await readFileContent(filePath);
|
||||
await replaceFile(filePath, searchText, replaceText);
|
||||
const newContent = await readFileContent(filePath);
|
||||
await trackFileChange(filePath, oldContent, newContent);
|
||||
panel.webview.postMessage({
|
||||
command: "fileReplaced",
|
||||
filePath: filePath,
|
||||
@ -1236,3 +1258,165 @@ export async function handleOptimizePrompt(
|
||||
vscode.window.showErrorMessage(`提示词优化失败: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理采纳变更
|
||||
*/
|
||||
export async function handleAcceptChange(
|
||||
panel: vscode.WebviewPanel,
|
||||
changeId: string
|
||||
) {
|
||||
try {
|
||||
const success = await changeTracker.acceptChange(changeId);
|
||||
if (success) {
|
||||
panel.webview.postMessage({
|
||||
command: "changeAccepted",
|
||||
changeId: changeId,
|
||||
success: true
|
||||
});
|
||||
} else {
|
||||
panel.webview.postMessage({
|
||||
command: "changeAccepted",
|
||||
changeId: changeId,
|
||||
success: false,
|
||||
error: "采纳变更失败"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[MessageHandler] 采纳变更失败:", error);
|
||||
panel.webview.postMessage({
|
||||
command: "changeAccepted",
|
||||
changeId: changeId,
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拒绝变更
|
||||
*/
|
||||
export async function handleRejectChange(
|
||||
panel: vscode.WebviewPanel,
|
||||
changeId: string
|
||||
) {
|
||||
try {
|
||||
const success = await changeTracker.rejectChange(changeId);
|
||||
if (success) {
|
||||
panel.webview.postMessage({
|
||||
command: "changeRejected",
|
||||
changeId: changeId,
|
||||
success: true
|
||||
});
|
||||
} else {
|
||||
panel.webview.postMessage({
|
||||
command: "changeRejected",
|
||||
changeId: changeId,
|
||||
success: false,
|
||||
error: "拒绝变更失败"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[MessageHandler] 拒绝变更失败:", error);
|
||||
panel.webview.postMessage({
|
||||
command: "changeRejected",
|
||||
changeId: changeId,
|
||||
success: false,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在对话结束时发送变更列表到前端
|
||||
*/
|
||||
export function sendChangesToWebview(panel: vscode.WebviewPanel) {
|
||||
const session = changeTracker.endSession();
|
||||
if (session && session.changes.length > 0) {
|
||||
const changesWithDiff = session.changes.map(change => {
|
||||
const diffLines = generateDiff(change.oldContent, change.newContent);
|
||||
const diffHtml = renderDiffHtml(diffLines);
|
||||
return {
|
||||
...change,
|
||||
diffHtml
|
||||
};
|
||||
});
|
||||
|
||||
panel.webview.postMessage({
|
||||
command: "showChanges",
|
||||
changes: changesWithDiff
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始新的变更会话
|
||||
*/
|
||||
export function startChangeSession(sessionId: string) {
|
||||
changeTracker.startSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件 diff 编辑器
|
||||
*/
|
||||
export async function handleOpenFileDiff(
|
||||
panel: vscode.WebviewPanel,
|
||||
changeId: string
|
||||
) {
|
||||
try {
|
||||
const session = changeTracker.getCurrentSession();
|
||||
if (!session) {
|
||||
vscode.window.showErrorMessage('没有找到变更会话');
|
||||
return;
|
||||
}
|
||||
|
||||
const change = session.changes.find(c => c.changeId === changeId);
|
||||
if (!change) {
|
||||
vscode.window.showErrorMessage('没有找到该变更');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage('没有打开的工作区');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建临时文件用于对比
|
||||
const filePath = change.filePath;
|
||||
const absolutePath = vscode.Uri.file(
|
||||
path.join(workspaceFolder.uri.fsPath, filePath)
|
||||
);
|
||||
|
||||
// 创建虚拟文档显示旧内容
|
||||
const oldUri = vscode.Uri.parse(
|
||||
`ic-coder-diff:${filePath}.old?${changeId}`
|
||||
).with({ scheme: 'ic-coder-diff' });
|
||||
|
||||
// 注册文档内容提供者(如果还没注册)
|
||||
if (!(global as any).__diffProviderRegistered) {
|
||||
const provider = new (class implements vscode.TextDocumentContentProvider {
|
||||
provideTextDocumentContent(uri: vscode.Uri): string {
|
||||
const changeId = uri.query;
|
||||
const session = changeTracker.getCurrentSession();
|
||||
const change = session?.changes.find(c => c.changeId === changeId);
|
||||
return change?.oldContent || '';
|
||||
}
|
||||
})();
|
||||
|
||||
vscode.workspace.registerTextDocumentContentProvider('ic-coder-diff', provider);
|
||||
(global as any).__diffProviderRegistered = true;
|
||||
}
|
||||
|
||||
// 打开 diff 编辑器
|
||||
await vscode.commands.executeCommand(
|
||||
'vscode.diff',
|
||||
oldUri,
|
||||
absolutePath,
|
||||
`${filePath} (变更对比)`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] 打开 diff 失败:', error);
|
||||
vscode.window.showErrorMessage(`打开 diff 失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user