feat: 添加文件写入 diff 预览和 LSP 诊断优化
- 文件写入/编辑前显示 diff 对比供用户确认 - LSP 诊断只显示错误和警告,忽略 hint/info - 优化 LSP 等待时间:首次启动 2s,后续 300ms
This commit is contained in:
+9
-5
@@ -92,20 +92,24 @@ export function formatDiagnostics(diagnostics: FileDiagnostic[]): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件诊断并格式化为 AI 可读的格式
|
* 获取文件诊断并格式化为 AI 可读的格式
|
||||||
|
* 只显示错误和警告,忽略 hint 和 info
|
||||||
*/
|
*/
|
||||||
export async function getFormattedFileDiagnostics(filePath: string): Promise<string> {
|
export async function getFormattedFileDiagnostics(filePath: string): Promise<string> {
|
||||||
const diagnostics = getFileDiagnostics(filePath);
|
const diagnostics = getFileDiagnostics(filePath);
|
||||||
|
|
||||||
if (diagnostics.length === 0) {
|
// 只关注错误和警告,忽略 hint 和 info
|
||||||
|
const errors = diagnostics.filter(d => d.severity === 'error');
|
||||||
|
const warnings = diagnostics.filter(d => d.severity === 'warning');
|
||||||
|
const relevantDiagnostics = [...errors, ...warnings];
|
||||||
|
|
||||||
|
// 没有错误和警告就不显示
|
||||||
|
if (relevantDiagnostics.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors = diagnostics.filter(d => d.severity === 'error');
|
|
||||||
const warnings = diagnostics.filter(d => d.severity === 'warning');
|
|
||||||
|
|
||||||
let result = `\n<file_diagnostics file="${filePath}">\n`;
|
let result = `\n<file_diagnostics file="${filePath}">\n`;
|
||||||
result += `发现 ${errors.length} 个错误, ${warnings.length} 个警告:\n`;
|
result += `发现 ${errors.length} 个错误, ${warnings.length} 个警告:\n`;
|
||||||
result += formatDiagnostics(diagnostics);
|
result += formatDiagnostics(relevantDiagnostics);
|
||||||
result += '\n</file_diagnostics>';
|
result += '\n</file_diagnostics>';
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
PermissionContext,
|
PermissionContext,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type { PermissionChecker } from './base.js';
|
import type { PermissionChecker } from './base.js';
|
||||||
|
import { promptFilePermission } from '../file-prompt.js';
|
||||||
|
|
||||||
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
||||||
const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json');
|
const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json');
|
||||||
@@ -251,6 +252,28 @@ export class FilePermissionChecker implements PermissionChecker {
|
|||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
reason: string
|
reason: string
|
||||||
): Promise<PermissionCheckResult> {
|
): Promise<PermissionCheckResult> {
|
||||||
|
// 对于 write/edit 操作,如果有内容信息,使用 diff 显示
|
||||||
|
if ((ctx.operation === 'write' || ctx.operation === 'edit') && ctx.newContent !== undefined) {
|
||||||
|
// 更新 ctx 中的路径为绝对路径
|
||||||
|
const ctxWithAbsPath: FilePermissionContext = {
|
||||||
|
...ctx,
|
||||||
|
path: absolutePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const decision = await promptFilePermission(ctxWithAbsPath);
|
||||||
|
|
||||||
|
if (decision.remember) {
|
||||||
|
this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: decision.allow,
|
||||||
|
action: decision.allow ? 'allow' : 'deny',
|
||||||
|
reason: decision.allow ? '用户允许' : '用户拒绝',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他操作使用原有的回调
|
||||||
if (!this.askCallback) {
|
if (!this.askCallback) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* 文件操作确认提示
|
||||||
|
* 显示 diff 对比并让用户确认
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as readline from 'readline';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import type { FilePermissionContext, PermissionDecision } from './types.js';
|
||||||
|
import { computeDiff, formatDiff, countChanges, formatEditDiff } from '../utils/diff.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示文件写入的 diff 并请求确认
|
||||||
|
*/
|
||||||
|
export async function promptFileWrite(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||||
|
const { path: filePath, newContent } = ctx;
|
||||||
|
|
||||||
|
if (!newContent) {
|
||||||
|
// 没有内容,使用简单确认
|
||||||
|
return promptSimpleConfirm(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取原文件内容
|
||||||
|
let oldContent: string | null = null;
|
||||||
|
try {
|
||||||
|
oldContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// 文件不存在,是新文件
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果内容相同,直接允许
|
||||||
|
if (oldContent === newContent) {
|
||||||
|
return { allow: true, remember: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 diff
|
||||||
|
const diff = computeDiff(oldContent, newContent);
|
||||||
|
const changes = countChanges(diff);
|
||||||
|
|
||||||
|
// 显示 diff
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.yellow('📝 文件写入预览'));
|
||||||
|
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
|
||||||
|
|
||||||
|
if (diff.isNew) {
|
||||||
|
console.log(chalk.green('状态: ') + chalk.white('新文件'));
|
||||||
|
console.log(chalk.green(`+${changes.additions} 行`));
|
||||||
|
} else {
|
||||||
|
console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
|
||||||
|
// 限制显示行数
|
||||||
|
const diffOutput = formatDiff(diff, filePath);
|
||||||
|
const lines = diffOutput.split('\n');
|
||||||
|
const MAX_LINES = 50;
|
||||||
|
|
||||||
|
if (lines.length > MAX_LINES) {
|
||||||
|
console.log(lines.slice(0, MAX_LINES).join('\n'));
|
||||||
|
console.log(chalk.yellow(`\n... 省略 ${lines.length - MAX_LINES} 行 ...`));
|
||||||
|
} else {
|
||||||
|
console.log(diffOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 询问用户确认
|
||||||
|
return promptConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示文件编辑的 diff 并请求确认
|
||||||
|
*/
|
||||||
|
export async function promptFileEdit(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||||
|
const { path: filePath, oldContent, newContent } = ctx;
|
||||||
|
|
||||||
|
if (!oldContent || !newContent) {
|
||||||
|
// 没有内容,使用简单确认
|
||||||
|
return promptSimpleConfirm(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示编辑 diff
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.yellow('✏️ 文件编辑预览'));
|
||||||
|
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
console.log(formatEditDiff(oldContent, newContent));
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 询问用户确认
|
||||||
|
return promptConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单确认(无 diff)
|
||||||
|
*/
|
||||||
|
async function promptSimpleConfirm(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.yellow('⚠️ 文件操作确认'));
|
||||||
|
console.log(chalk.cyan('操作: ') + chalk.white(ctx.operation));
|
||||||
|
console.log(chalk.cyan('文件: ') + chalk.white(ctx.path));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
showConfirmOptions();
|
||||||
|
|
||||||
|
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(parseAnswer(answer));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用确认提示
|
||||||
|
*/
|
||||||
|
async function promptConfirm(): Promise<PermissionDecision> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
showConfirmOptions();
|
||||||
|
|
||||||
|
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(parseAnswer(answer));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示确认选项
|
||||||
|
*/
|
||||||
|
function showConfirmOptions(): void {
|
||||||
|
console.log(chalk.white('选择操作:'));
|
||||||
|
console.log(chalk.green(' [y] ') + '确认执行');
|
||||||
|
console.log(chalk.green(' [Y] ') + '确认执行,并记住此类操作(本次会话)');
|
||||||
|
console.log(chalk.red(' [n] ') + '拒绝执行');
|
||||||
|
console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类操作(本次会话)');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析用户输入
|
||||||
|
*/
|
||||||
|
function parseAnswer(answer: string): PermissionDecision {
|
||||||
|
const choice = answer.trim();
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 'y':
|
||||||
|
return { allow: true, remember: false };
|
||||||
|
case 'Y':
|
||||||
|
return { allow: true, remember: true };
|
||||||
|
case 'N':
|
||||||
|
return { allow: false, remember: true };
|
||||||
|
case 'n':
|
||||||
|
default:
|
||||||
|
return { allow: false, remember: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据操作类型选择合适的确认提示
|
||||||
|
*/
|
||||||
|
export async function promptFilePermission(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||||
|
switch (ctx.operation) {
|
||||||
|
case 'write':
|
||||||
|
return promptFileWrite(ctx);
|
||||||
|
case 'edit':
|
||||||
|
return promptFileEdit(ctx);
|
||||||
|
default:
|
||||||
|
return promptSimpleConfirm(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ export { PermissionManager, getPermissionManager, resetPermissionManager } from
|
|||||||
|
|
||||||
export { promptPermission, showPermissionDenied, showPermissionAllowed } from './prompt.js';
|
export { promptPermission, showPermissionDenied, showPermissionAllowed } from './prompt.js';
|
||||||
|
|
||||||
|
export { promptFilePermission, promptFileWrite, promptFileEdit } from './file-prompt.js';
|
||||||
|
|
||||||
// Checker pattern exports
|
// Checker pattern exports
|
||||||
export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js';
|
export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js';
|
||||||
export { BashPermissionChecker } from './checkers/bash.js';
|
export { BashPermissionChecker } from './checkers/bash.js';
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export interface FilePermissionContext {
|
|||||||
operation: FileOperation;
|
operation: FileOperation;
|
||||||
path: string; // 目标路径
|
path: string; // 目标路径
|
||||||
workdir: string; // 当前工作目录
|
workdir: string; // 当前工作目录
|
||||||
|
// 用于 diff 显示的内容(仅 write/edit 操作)
|
||||||
|
newContent?: string; // 新内容
|
||||||
|
oldContent?: string; // 原内容(edit 操作时,要被替换的部分)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件权限配置
|
// 文件权限配置
|
||||||
|
|||||||
@@ -42,32 +42,11 @@ export const editFileTool: ToolWithMetadata = {
|
|||||||
? filePath
|
? filePath
|
||||||
: path.join(cwd, filePath);
|
: path.join(cwd, filePath);
|
||||||
|
|
||||||
// 权限检查
|
|
||||||
const permissionManager = getPermissionManager();
|
|
||||||
const permResult = await permissionManager.checkFilePermission({
|
|
||||||
operation: 'edit',
|
|
||||||
path: absolutePath,
|
|
||||||
workdir: cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!permResult.allowed) {
|
|
||||||
if (permResult.needsConfirmation) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
output: '',
|
|
||||||
error: `需要用户确认: 编辑 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
output: '',
|
|
||||||
error: `权限被拒绝: ${permResult.reason || '不允许编辑此文件'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 先读取文件内容,用于验证和 diff 显示
|
||||||
const content = await fs.readFile(absolutePath, 'utf-8');
|
const content = await fs.readFile(absolutePath, 'utf-8');
|
||||||
|
|
||||||
|
// 验证 old_string 是否存在且唯一
|
||||||
if (!content.includes(oldString)) {
|
if (!content.includes(oldString)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -85,6 +64,31 @@ export const editFileTool: ToolWithMetadata = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 权限检查(传递内容用于 diff 显示)
|
||||||
|
const permissionManager = getPermissionManager();
|
||||||
|
const permResult = await permissionManager.checkFilePermission({
|
||||||
|
operation: 'edit',
|
||||||
|
path: absolutePath,
|
||||||
|
workdir: cwd,
|
||||||
|
oldContent: oldString,
|
||||||
|
newContent: newString,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!permResult.allowed) {
|
||||||
|
if (permResult.needsConfirmation) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `需要用户确认: 编辑 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `权限被拒绝: ${permResult.reason || '不允许编辑此文件'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const newContent = content.replace(oldString, newString);
|
const newContent = content.replace(oldString, newString);
|
||||||
await fs.writeFile(absolutePath, newContent, 'utf-8');
|
await fs.writeFile(absolutePath, newContent, 'utf-8');
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ export const writeFileTool: ToolWithMetadata = {
|
|||||||
? filePath
|
? filePath
|
||||||
: path.join(cwd, filePath);
|
: path.join(cwd, filePath);
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查(传递内容用于 diff 显示)
|
||||||
const permissionManager = getPermissionManager();
|
const permissionManager = getPermissionManager();
|
||||||
const permResult = await permissionManager.checkFilePermission({
|
const permResult = await permissionManager.checkFilePermission({
|
||||||
operation: 'write',
|
operation: 'write',
|
||||||
path: absolutePath,
|
path: absolutePath,
|
||||||
workdir: cwd,
|
workdir: cwd,
|
||||||
|
newContent: content,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!permResult.allowed) {
|
if (!permResult.allowed) {
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* 文件 diff 对比和确认工具
|
||||||
|
* 用于在写入文件前显示变更并让用户确认
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as readline from 'readline';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
export interface DiffLine {
|
||||||
|
type: 'add' | 'remove' | 'context';
|
||||||
|
lineNumber: number | null;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffResult {
|
||||||
|
oldContent: string | null;
|
||||||
|
newContent: string;
|
||||||
|
isNew: boolean;
|
||||||
|
hunks: DiffHunk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffHunk {
|
||||||
|
oldStart: number;
|
||||||
|
oldCount: number;
|
||||||
|
newStart: number;
|
||||||
|
newCount: number;
|
||||||
|
lines: DiffLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个字符串的 diff
|
||||||
|
* 使用简化的 LCS (最长公共子序列) 算法
|
||||||
|
*/
|
||||||
|
export function computeDiff(oldContent: string | null, newContent: string): DiffResult {
|
||||||
|
const isNew = oldContent === null;
|
||||||
|
const oldLines = oldContent ? oldContent.split('\n') : [];
|
||||||
|
const newLines = newContent.split('\n');
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
// 新文件,所有行都是新增
|
||||||
|
return {
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
isNew: true,
|
||||||
|
hunks: [{
|
||||||
|
oldStart: 0,
|
||||||
|
oldCount: 0,
|
||||||
|
newStart: 1,
|
||||||
|
newCount: newLines.length,
|
||||||
|
lines: newLines.map((line, i) => ({
|
||||||
|
type: 'add' as const,
|
||||||
|
lineNumber: i + 1,
|
||||||
|
content: line,
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 diff hunks
|
||||||
|
const hunks = computeHunks(oldLines, newLines);
|
||||||
|
|
||||||
|
return {
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
isNew: false,
|
||||||
|
hunks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 diff hunks
|
||||||
|
*/
|
||||||
|
function computeHunks(oldLines: string[], newLines: string[]): DiffHunk[] {
|
||||||
|
// 使用简化的 diff 算法
|
||||||
|
const lcs = computeLCS(oldLines, newLines);
|
||||||
|
const hunks: DiffHunk[] = [];
|
||||||
|
|
||||||
|
let oldIdx = 0;
|
||||||
|
let newIdx = 0;
|
||||||
|
let lcsIdx = 0;
|
||||||
|
let currentHunk: DiffHunk | null = null;
|
||||||
|
|
||||||
|
const CONTEXT_LINES = 3;
|
||||||
|
|
||||||
|
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
||||||
|
const lcsLine = lcsIdx < lcs.length ? lcs[lcsIdx] : null;
|
||||||
|
|
||||||
|
// 检查是否匹配 LCS
|
||||||
|
const oldMatch = lcsLine !== null && oldIdx < oldLines.length && oldLines[oldIdx] === lcsLine.content;
|
||||||
|
const newMatch = lcsLine !== null && newIdx < newLines.length && newLines[newIdx] === lcsLine.content;
|
||||||
|
|
||||||
|
if (oldMatch && newMatch) {
|
||||||
|
// 上下文行
|
||||||
|
if (currentHunk) {
|
||||||
|
currentHunk.lines.push({
|
||||||
|
type: 'context',
|
||||||
|
lineNumber: newIdx + 1,
|
||||||
|
content: newLines[newIdx],
|
||||||
|
});
|
||||||
|
currentHunk.oldCount++;
|
||||||
|
currentHunk.newCount++;
|
||||||
|
}
|
||||||
|
oldIdx++;
|
||||||
|
newIdx++;
|
||||||
|
lcsIdx++;
|
||||||
|
} else if (!oldMatch && oldIdx < oldLines.length && (lcsLine === null || oldLines[oldIdx] !== lcsLine.content)) {
|
||||||
|
// 删除行
|
||||||
|
if (!currentHunk) {
|
||||||
|
currentHunk = {
|
||||||
|
oldStart: oldIdx + 1,
|
||||||
|
oldCount: 0,
|
||||||
|
newStart: newIdx + 1,
|
||||||
|
newCount: 0,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
currentHunk.lines.push({
|
||||||
|
type: 'remove',
|
||||||
|
lineNumber: oldIdx + 1,
|
||||||
|
content: oldLines[oldIdx],
|
||||||
|
});
|
||||||
|
currentHunk.oldCount++;
|
||||||
|
oldIdx++;
|
||||||
|
} else if (!newMatch && newIdx < newLines.length) {
|
||||||
|
// 新增行
|
||||||
|
if (!currentHunk) {
|
||||||
|
currentHunk = {
|
||||||
|
oldStart: oldIdx + 1,
|
||||||
|
oldCount: 0,
|
||||||
|
newStart: newIdx + 1,
|
||||||
|
newCount: 0,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
currentHunk.lines.push({
|
||||||
|
type: 'add',
|
||||||
|
lineNumber: newIdx + 1,
|
||||||
|
content: newLines[newIdx],
|
||||||
|
});
|
||||||
|
currentHunk.newCount++;
|
||||||
|
newIdx++;
|
||||||
|
} else {
|
||||||
|
// 匹配但还没到
|
||||||
|
if (currentHunk && currentHunk.lines.length > 0) {
|
||||||
|
// 检查是否应该结束当前 hunk
|
||||||
|
const lastNonContext = [...currentHunk.lines].reverse().findIndex(l => l.type !== 'context');
|
||||||
|
if (lastNonContext > CONTEXT_LINES) {
|
||||||
|
hunks.push(currentHunk);
|
||||||
|
currentHunk = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldIdx++;
|
||||||
|
newIdx++;
|
||||||
|
if (lcsLine) lcsIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHunk && currentHunk.lines.some(l => l.type !== 'context')) {
|
||||||
|
hunks.push(currentHunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有实际变化,返回空
|
||||||
|
if (hunks.length === 0 && oldLines.join('\n') !== newLines.join('\n')) {
|
||||||
|
// 全文替换的情况
|
||||||
|
return [{
|
||||||
|
oldStart: 1,
|
||||||
|
oldCount: oldLines.length,
|
||||||
|
newStart: 1,
|
||||||
|
newCount: newLines.length,
|
||||||
|
lines: [
|
||||||
|
...oldLines.map((line, i) => ({
|
||||||
|
type: 'remove' as const,
|
||||||
|
lineNumber: i + 1,
|
||||||
|
content: line,
|
||||||
|
})),
|
||||||
|
...newLines.map((line, i) => ({
|
||||||
|
type: 'add' as const,
|
||||||
|
lineNumber: i + 1,
|
||||||
|
content: line,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return hunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算最长公共子序列
|
||||||
|
*/
|
||||||
|
function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: string; oldIdx: number; newIdx: number }> {
|
||||||
|
const m = oldLines.length;
|
||||||
|
const n = newLines.length;
|
||||||
|
|
||||||
|
// DP 表
|
||||||
|
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回溯找出 LCS
|
||||||
|
const result: Array<{ content: string; oldIdx: number; newIdx: number }> = [];
|
||||||
|
let i = m, j = n;
|
||||||
|
|
||||||
|
while (i > 0 && j > 0) {
|
||||||
|
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||||
|
result.unshift({ content: oldLines[i - 1], oldIdx: i - 1, newIdx: j - 1 });
|
||||||
|
i--;
|
||||||
|
j--;
|
||||||
|
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||||
|
i--;
|
||||||
|
} else {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 diff 输出
|
||||||
|
*/
|
||||||
|
export function formatDiff(diff: DiffResult, filePath: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (diff.isNew) {
|
||||||
|
lines.push(chalk.green(`+++ 新文件: ${filePath}`));
|
||||||
|
} else {
|
||||||
|
lines.push(chalk.gray(`--- ${filePath} (原文件)`));
|
||||||
|
lines.push(chalk.green(`+++ ${filePath} (修改后)`));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const hunk of diff.hunks) {
|
||||||
|
// Hunk 头部
|
||||||
|
lines.push(chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`));
|
||||||
|
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
const lineNum = line.lineNumber ? chalk.gray(`${line.lineNumber.toString().padStart(4)} `) : ' ';
|
||||||
|
|
||||||
|
switch (line.type) {
|
||||||
|
case 'add':
|
||||||
|
lines.push(chalk.green(`${lineNum}+ ${line.content}`));
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
lines.push(chalk.red(`${lineNum}- ${line.content}`));
|
||||||
|
break;
|
||||||
|
case 'context':
|
||||||
|
lines.push(chalk.gray(`${lineNum} ${line.content}`));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计变更数量
|
||||||
|
*/
|
||||||
|
export function countChanges(diff: DiffResult): { additions: number; deletions: number } {
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
|
||||||
|
for (const hunk of diff.hunks) {
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.type === 'add') additions++;
|
||||||
|
if (line.type === 'remove') deletions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { additions, deletions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileConfirmResult {
|
||||||
|
confirmed: boolean;
|
||||||
|
remember: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示 diff 并让用户确认
|
||||||
|
*/
|
||||||
|
export async function confirmFileChange(
|
||||||
|
filePath: string,
|
||||||
|
newContent: string,
|
||||||
|
operation: 'write' | 'edit'
|
||||||
|
): Promise<FileConfirmResult> {
|
||||||
|
// 读取原文件内容
|
||||||
|
let oldContent: string | null = null;
|
||||||
|
try {
|
||||||
|
oldContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// 文件不存在,是新文件
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果内容相同,直接通过
|
||||||
|
if (oldContent === newContent) {
|
||||||
|
return { confirmed: true, remember: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 diff
|
||||||
|
const diff = computeDiff(oldContent, newContent);
|
||||||
|
const changes = countChanges(diff);
|
||||||
|
|
||||||
|
// 显示 diff
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.yellow('📝 文件变更预览'));
|
||||||
|
console.log(chalk.cyan('操作: ') + chalk.white(operation === 'write' ? '写入文件' : '编辑文件'));
|
||||||
|
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
|
||||||
|
console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`));
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
console.log(formatDiff(diff, filePath));
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 询问用户确认
|
||||||
|
return promptFileConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示用户确认文件操作
|
||||||
|
*/
|
||||||
|
async function promptFileConfirm(): Promise<FileConfirmResult> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(chalk.white('选择操作:'));
|
||||||
|
console.log(chalk.green(' [y] ') + '确认写入');
|
||||||
|
console.log(chalk.green(' [Y] ') + '确认写入,并记住此类操作(本次会话)');
|
||||||
|
console.log(chalk.red(' [n] ') + '取消操作');
|
||||||
|
console.log(chalk.red(' [N] ') + '取消操作,并记住此类操作(本次会话)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
const choice = answer.trim();
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 'y':
|
||||||
|
resolve({ confirmed: true, remember: false });
|
||||||
|
break;
|
||||||
|
case 'Y':
|
||||||
|
resolve({ confirmed: true, remember: true });
|
||||||
|
break;
|
||||||
|
case 'N':
|
||||||
|
resolve({ confirmed: false, remember: true });
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
default:
|
||||||
|
resolve({ confirmed: false, remember: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化的 diff 显示(用于编辑操作,只显示变更部分)
|
||||||
|
*/
|
||||||
|
export function formatEditDiff(oldString: string, newString: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(chalk.gray('变更内容:'));
|
||||||
|
|
||||||
|
// 显示删除的内容
|
||||||
|
const oldLines = oldString.split('\n');
|
||||||
|
for (const line of oldLines) {
|
||||||
|
lines.push(chalk.red(`- ${line}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示新增的内容
|
||||||
|
const newLines = newString.split('\n');
|
||||||
|
for (const line of newLines) {
|
||||||
|
lines.push(chalk.green(`+ ${line}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user