Skip to content

Commit b242f7f

Browse files
echoVicclaude
andcommitted
feat(文件编辑): 完成文本编辑器三阶段增强
## 阶段 1: 集中式快照管理 - ✅ 创建 SnapshotManager 类 - ✅ 实现 ~/.blade/file-history/{sessionId}/ 目录结构 - ✅ 集成到 Edit 和 Write 工具 - ✅ 自动清理旧快照(保留最近 10 个) ## 阶段 2: 安全加固 - ✅ 实现 FileAccessTracker 跟踪已读文件 - ✅ 添加 Read-Before-Write 验证(宽松模式,仅警告) - ✅ 实现 FileLockManager 文件锁机制(防止并发编辑冲突) - ✅ 添加智能引号标准化(支持富文本引号) ## 阶段 3: 用户体验优化 - ✅ 增强多重匹配警告(显示所有匹配位置的行:列) - ✅ 实现差异可视化 - 后端: 使用 diff 库生成 unified diff 格式 - 前端: 创建 DiffRenderer 组件,语法高亮显示差异 - 显示 ±4 行上下文 - ✅ 添加 UndoEdit 回滚工具 - 支持按 message_id 回滚文件 - 支持列出文件的所有历史版本 ## 配置修正 - 🔧 移除 strictReadBeforeWrite 配置(Read-Before-Write 始终使用宽松模式) - 🔧 添加工具级别 strict 配置(用于 OpenAI Structured Outputs) ## 文件变更 **新增**: - src/tools/builtin/file/SnapshotManager.ts - src/tools/builtin/file/FileAccessTracker.ts - src/tools/execution/FileLockManager.ts - src/tools/builtin/file/undoEdit.ts - src/ui/components/DiffRenderer.tsx **修改**: - src/tools/builtin/file/edit.ts - src/tools/builtin/file/write.ts - src/ui/components/MessageRenderer.tsx - src/config/types.ts - src/config/defaults.ts **依赖**: - 新增 diff@8.0.2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2005957 commit b242f7f

20 files changed

Lines changed: 2591 additions & 66 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"@biomejs/biome": "^2.2.4",
8585
"@testing-library/react": "^16.2.0",
8686
"@testing-library/user-event": "^14.5.0",
87+
"@types/diff": "^8.0.0",
8788
"@types/inquirer": "^9.0.8",
8889
"@types/json-schema": "^7.0.15",
8990
"@types/lodash-es": "^4.17.12",
@@ -106,6 +107,7 @@
106107
"ahooks": "^3.9.5",
107108
"axios": "^1.12.2",
108109
"chalk": "^5.4.1",
110+
"diff": "^8.0.2",
109111
"ink": "^6.2.3",
110112
"ink-big-text": "^2.0.0",
111113
"ink-gradient": "^3.0.0",

pnpm-lock.yaml

Lines changed: 20 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { promises as fs } from 'node:fs';
2+
3+
/**
4+
* 文件访问记录
5+
*/
6+
export interface FileAccessRecord {
7+
filePath: string; // 文件绝对路径
8+
readTime: number; // 读取时间戳(毫秒)
9+
mtime: number; // 读取时文件的修改时间戳
10+
sessionId: string; // 会话 ID
11+
}
12+
13+
/**
14+
* 文件访问跟踪器
15+
*
16+
* 功能:
17+
* 1. 跟踪已读文件的时间戳
18+
* 2. 验证编辑前文件是否已通过 Read 工具读取
19+
* 3. 检查文件修改时间是否晚于读取时间(防止并发编辑)
20+
*/
21+
export class FileAccessTracker {
22+
// 全局单例实例
23+
private static instance: FileAccessTracker | null = null;
24+
25+
// 已读文件映射: filePath -> FileAccessRecord
26+
private accessedFiles: Map<string, FileAccessRecord> = new Map();
27+
28+
// 私有构造函数(单例模式)
29+
private constructor() {}
30+
31+
/**
32+
* 获取全局单例实例
33+
*/
34+
static getInstance(): FileAccessTracker {
35+
if (!FileAccessTracker.instance) {
36+
FileAccessTracker.instance = new FileAccessTracker();
37+
}
38+
return FileAccessTracker.instance;
39+
}
40+
41+
/**
42+
* 记录文件读取
43+
*
44+
* @param filePath 文件绝对路径
45+
* @param sessionId 会话 ID
46+
*/
47+
async recordFileRead(filePath: string, sessionId: string): Promise<void> {
48+
try {
49+
// 获取文件的当前修改时间
50+
const stats = await fs.stat(filePath);
51+
52+
const record: FileAccessRecord = {
53+
filePath,
54+
readTime: Date.now(),
55+
mtime: stats.mtimeMs,
56+
sessionId,
57+
};
58+
59+
this.accessedFiles.set(filePath, record);
60+
61+
console.log(`[FileAccessTracker] 记录文件读取: ${filePath}`);
62+
} catch (error) {
63+
console.warn(`[FileAccessTracker] 记录文件读取失败: ${filePath}`, error);
64+
}
65+
}
66+
67+
/**
68+
* 验证文件是否已读取
69+
*
70+
* @param filePath 文件绝对路径
71+
* @param sessionId 会话 ID(可选,用于会话隔离)
72+
* @returns 是否已读取
73+
*/
74+
hasFileBeenRead(filePath: string, sessionId?: string): boolean {
75+
const record = this.accessedFiles.get(filePath);
76+
77+
if (!record) {
78+
return false;
79+
}
80+
81+
// 如果提供了 sessionId,验证是否是同一会话
82+
if (sessionId && record.sessionId !== sessionId) {
83+
return false;
84+
}
85+
86+
return true;
87+
}
88+
89+
/**
90+
* 验证文件是否在读取后被修改
91+
*
92+
* @param filePath 文件绝对路径
93+
* @returns { modified: boolean, message?: string }
94+
*/
95+
async checkFileModification(
96+
filePath: string
97+
): Promise<{ modified: boolean; message?: string }> {
98+
const record = this.accessedFiles.get(filePath);
99+
100+
if (!record) {
101+
return {
102+
modified: false,
103+
message: '文件未被跟踪',
104+
};
105+
}
106+
107+
try {
108+
// 获取文件当前的修改时间
109+
const stats = await fs.stat(filePath);
110+
111+
// 比较修改时间(容差 1ms,避免浮点精度问题)
112+
const timeDiff = Math.abs(stats.mtimeMs - record.mtime);
113+
if (timeDiff > 1) {
114+
return {
115+
modified: true,
116+
message: `文件在读取后被修改(读取时间: ${new Date(record.readTime).toISOString()}, 当前修改时间: ${stats.mtime.toISOString()})`,
117+
};
118+
}
119+
120+
return { modified: false };
121+
} catch (error: any) {
122+
if (error.code === 'ENOENT') {
123+
return {
124+
modified: true,
125+
message: '文件已被删除',
126+
};
127+
}
128+
129+
return {
130+
modified: false,
131+
message: `无法检查文件状态: ${error.message}`,
132+
};
133+
}
134+
}
135+
136+
/**
137+
* 获取文件的访问记录
138+
*
139+
* @param filePath 文件绝对路径
140+
* @returns 访问记录或 undefined
141+
*/
142+
getFileRecord(filePath: string): FileAccessRecord | undefined {
143+
return this.accessedFiles.get(filePath);
144+
}
145+
146+
/**
147+
* 清除文件的访问记录
148+
*
149+
* @param filePath 文件绝对路径
150+
*/
151+
clearFileRecord(filePath: string): void {
152+
this.accessedFiles.delete(filePath);
153+
}
154+
155+
/**
156+
* 清除所有访问记录
157+
*/
158+
clearAll(): void {
159+
this.accessedFiles.clear();
160+
}
161+
162+
/**
163+
* 清除指定会话的所有访问记录
164+
*
165+
* @param sessionId 会话 ID
166+
*/
167+
clearSession(sessionId: string): void {
168+
for (const [filePath, record] of this.accessedFiles.entries()) {
169+
if (record.sessionId === sessionId) {
170+
this.accessedFiles.delete(filePath);
171+
}
172+
}
173+
}
174+
175+
/**
176+
* 获取所有已跟踪的文件路径
177+
*/
178+
getTrackedFiles(): string[] {
179+
return Array.from(this.accessedFiles.keys());
180+
}
181+
182+
/**
183+
* 获取跟踪的文件数量
184+
*/
185+
getTrackedFileCount(): number {
186+
return this.accessedFiles.size;
187+
}
188+
189+
/**
190+
* 重置单例实例(仅用于测试)
191+
*/
192+
static resetInstance(): void {
193+
FileAccessTracker.instance = null;
194+
}
195+
}

0 commit comments

Comments
 (0)