Skip to content

Commit e22e932

Browse files
authored
💄 style: add write file tool to local-file plugin (lobehub#7684)
1 parent ab40a85 commit e22e932

File tree

19 files changed

+360
-119
lines changed

19 files changed

+360
-119
lines changed

apps/desktop/src/main/controllers/LocalFileCtr.ts

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
OpenLocalFileParams,
1010
OpenLocalFolderParams,
1111
RenameLocalFileResult,
12+
WriteLocalFileParams,
1213
} from '@lobechat/electron-client-ipc';
1314
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
1415
import { shell } from 'electron';
@@ -20,13 +21,18 @@ import { promisify } from 'node:util';
2021
import FileSearchService from '@/services/fileSearchSrv';
2122
import { FileResult, SearchOptions } from '@/types/fileSearch';
2223
import { makeSureDirExist } from '@/utils/file-system';
24+
import { createLogger } from '@/utils/logger';
2325

2426
import { ControllerModule, ipcClientEvent } from './index';
2527

28+
// 创建日志记录器
29+
const logger = createLogger('controllers:LocalFileCtr');
30+
2631
const statPromise = promisify(fs.stat);
2732
const readdirPromise = promisify(fs.readdir);
2833
const renamePromiseFs = promisify(fs.rename);
2934
const accessPromise = promisify(fs.access);
35+
const writeFilePromise = promisify(fs.writeFile);
3036

3137
export default class LocalFileCtr extends ControllerModule {
3238
private get searchService() {
@@ -38,23 +44,35 @@ export default class LocalFileCtr extends ControllerModule {
3844
*/
3945
@ipcClientEvent('searchLocalFiles')
4046
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
47+
logger.debug('Received file search request:', { keywords: params.keywords });
48+
4149
const options: Omit<SearchOptions, 'keywords'> = {
4250
limit: 30,
4351
};
4452

45-
return this.searchService.search(params.keywords, options);
53+
try {
54+
const results = await this.searchService.search(params.keywords, options);
55+
logger.debug('File search completed', { count: results.length });
56+
return results;
57+
} catch (error) {
58+
logger.error('File search failed:', error);
59+
return [];
60+
}
4661
}
4762

4863
@ipcClientEvent('openLocalFile')
4964
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
5065
error?: string;
5166
success: boolean;
5267
}> {
68+
logger.debug('Attempting to open file:', { filePath });
69+
5370
try {
5471
await shell.openPath(filePath);
72+
logger.debug('File opened successfully:', { filePath });
5573
return { success: true };
5674
} catch (error) {
57-
console.error(`Failed to open file ${filePath}:`, error);
75+
logger.error(`Failed to open file ${filePath}:`, error);
5876
return { error: (error as Error).message, success: false };
5977
}
6078
}
@@ -64,35 +82,42 @@ export default class LocalFileCtr extends ControllerModule {
6482
error?: string;
6583
success: boolean;
6684
}> {
85+
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
86+
logger.debug('Attempting to open folder:', { folderPath, isDirectory, targetPath });
87+
6788
try {
68-
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
6989
await shell.openPath(folderPath);
90+
logger.debug('Folder opened successfully:', { folderPath });
7091
return { success: true };
7192
} catch (error) {
72-
console.error(`Failed to open folder for path ${targetPath}:`, error);
93+
logger.error(`Failed to open folder ${folderPath}:`, error);
7394
return { error: (error as Error).message, success: false };
7495
}
7596
}
7697

7798
@ipcClientEvent('readLocalFiles')
7899
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
100+
logger.debug('Starting batch file reading:', { count: paths.length });
101+
79102
const results: LocalReadFileResult[] = [];
80103

81104
for (const filePath of paths) {
82105
// 初始化结果对象
106+
logger.debug('Reading single file:', { filePath });
83107
const result = await this.readFile({ path: filePath });
84-
85108
results.push(result);
86109
}
87110

111+
logger.debug('Batch file reading completed', { count: results.length });
88112
return results;
89113
}
90114

91115
@ipcClientEvent('readLocalFile')
92116
async readFile({ path: filePath, loc }: LocalReadFileParams): Promise<LocalReadFileResult> {
93-
try {
94-
const effectiveLoc = loc ?? [0, 200];
117+
const effectiveLoc = loc ?? [0, 200];
118+
logger.debug('Starting to read file:', { filePath, loc: effectiveLoc });
95119

120+
try {
96121
const fileDocument = await loadFile(filePath);
97122

98123
const [startLine, endLine] = effectiveLoc;
@@ -106,6 +131,13 @@ export default class LocalFileCtr extends ControllerModule {
106131
const charCount = content.length;
107132
const lineCount = selectedLines.length;
108133

134+
logger.debug('File read successfully:', {
135+
filePath,
136+
selectedLineCount: lineCount,
137+
totalCharCount,
138+
totalLineCount,
139+
});
140+
109141
const result: LocalReadFileResult = {
110142
// Char count for the selected range
111143
charCount,
@@ -128,6 +160,7 @@ export default class LocalFileCtr extends ControllerModule {
128160
try {
129161
const stats = await statPromise(filePath);
130162
if (stats.isDirectory()) {
163+
logger.warn('Attempted to read directory content:', { filePath });
131164
result.content = 'This is a directory and cannot be read as plain text.';
132165
result.charCount = 0;
133166
result.lineCount = 0;
@@ -136,12 +169,12 @@ export default class LocalFileCtr extends ControllerModule {
136169
result.totalLineCount = 0;
137170
}
138171
} catch (statError) {
139-
console.error(`Stat failed for ${filePath} after loadFile:`, statError);
172+
logger.error(`Failed to get file status ${filePath}:`, statError);
140173
}
141174

142175
return result;
143176
} catch (error) {
144-
console.error(`Error processing file ${filePath}:`, error);
177+
logger.error(`Failed to read file ${filePath}:`, error);
145178
const errorMessage = (error as Error).message;
146179
return {
147180
charCount: 0,
@@ -160,13 +193,20 @@ export default class LocalFileCtr extends ControllerModule {
160193

161194
@ipcClientEvent('listLocalFiles')
162195
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
196+
logger.debug('Listing directory contents:', { dirPath });
197+
163198
const results: FileResult[] = [];
164199
try {
165200
const entries = await readdirPromise(dirPath);
201+
logger.debug('Directory entries retrieved successfully:', {
202+
dirPath,
203+
entriesCount: entries.length,
204+
});
166205

167206
for (const entry of entries) {
168207
// Skip specific system files based on the ignore list
169208
if (SYSTEM_FILES_TO_IGNORE.includes(entry)) {
209+
logger.debug('Ignoring system file:', { fileName: entry });
170210
continue;
171211
}
172212

@@ -186,7 +226,7 @@ export default class LocalFileCtr extends ControllerModule {
186226
});
187227
} catch (statError) {
188228
// Silently ignore files we can't stat (e.g. permissions)
189-
console.error(`Failed to stat ${fullPath}:`, statError);
229+
logger.error(`Failed to get file status ${fullPath}:`, statError);
190230
}
191231
}
192232

@@ -199,9 +239,10 @@ export default class LocalFileCtr extends ControllerModule {
199239
return (a.name || '').localeCompare(b.name || ''); // Then sort by name
200240
});
201241

242+
logger.debug('Directory listing successful', { dirPath, resultCount: results.length });
202243
return results;
203244
} catch (error) {
204-
console.error(`Failed to list directory ${dirPath}:`, error);
245+
logger.error(`Failed to list directory ${dirPath}:`, error);
205246
// Rethrow or return an empty array/error object depending on desired behavior
206247
// For now, returning empty array on error listing directory itself
207248
return [];
@@ -210,16 +251,21 @@ export default class LocalFileCtr extends ControllerModule {
210251

211252
@ipcClientEvent('moveLocalFiles')
212253
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
254+
logger.debug('Starting batch file move:', { itemsCount: items?.length });
255+
213256
const results: LocalMoveFilesResultItem[] = [];
214257

215258
if (!items || items.length === 0) {
216-
console.warn('moveLocalFiles called with empty items array.');
259+
logger.warn('moveLocalFiles called with empty parameters');
217260
return [];
218261
}
219262

220263
// 逐个处理移动请求
221264
for (const item of items) {
222265
const { oldPath: sourcePath, newPath } = item;
266+
const logPrefix = `[Moving file ${sourcePath} -> ${newPath}]`;
267+
logger.debug(`${logPrefix} Starting process`);
268+
223269
const resultItem: LocalMoveFilesResultItem = {
224270
newPath: undefined,
225271
sourcePath,
@@ -228,6 +274,7 @@ export default class LocalFileCtr extends ControllerModule {
228274

229275
// 基本验证
230276
if (!sourcePath || !newPath) {
277+
logger.error(`${logPrefix} Parameter validation failed: source or target path is empty`);
231278
resultItem.error = 'Both oldPath and newPath are required for each item.';
232279
results.push(resultItem);
233280
continue;
@@ -237,10 +284,13 @@ export default class LocalFileCtr extends ControllerModule {
237284
// 检查源是否存在
238285
try {
239286
await accessPromise(sourcePath, fs.constants.F_OK);
287+
logger.debug(`${logPrefix} Source file exists`);
240288
} catch (accessError: any) {
241289
if (accessError.code === 'ENOENT') {
290+
logger.error(`${logPrefix} Source file does not exist`);
242291
throw new Error(`Source path not found: ${sourcePath}`);
243292
} else {
293+
logger.error(`${logPrefix} Permission error accessing source file:`, accessError);
244294
throw new Error(
245295
`Permission denied accessing source path: ${sourcePath}. ${accessError.message}`,
246296
);
@@ -249,7 +299,7 @@ export default class LocalFileCtr extends ControllerModule {
249299

250300
// 检查目标路径是否与源路径相同
251301
if (path.normalize(sourcePath) === path.normalize(newPath)) {
252-
console.log(`Skipping move: source and target path are identical: ${sourcePath}`);
302+
logger.info(`${logPrefix} Source and target paths are identical, skipping move`);
253303
resultItem.success = true;
254304
resultItem.newPath = newPath; // 即使未移动,也报告目标路径
255305
results.push(resultItem);
@@ -259,14 +309,15 @@ export default class LocalFileCtr extends ControllerModule {
259309
// LBYL: 确保目标目录存在
260310
const targetDir = path.dirname(newPath);
261311
makeSureDirExist(targetDir);
312+
logger.debug(`${logPrefix} Ensured target directory exists: ${targetDir}`);
262313

263314
// 执行移动 (rename)
264315
await renamePromiseFs(sourcePath, newPath);
265316
resultItem.success = true;
266317
resultItem.newPath = newPath;
267-
console.log(`Successfully moved ${sourcePath} to ${newPath}`);
318+
logger.info(`${logPrefix} Move successful`);
268319
} catch (error) {
269-
console.error(`Error moving ${sourcePath} to ${newPath}:`, error);
320+
logger.error(`${logPrefix} Move failed:`, error);
270321
// 使用与 handleMoveFile 类似的错误处理逻辑
271322
let errorMessage = (error as Error).message;
272323
if ((error as any).code === 'ENOENT')
@@ -296,6 +347,10 @@ export default class LocalFileCtr extends ControllerModule {
296347
results.push(resultItem);
297348
}
298349

350+
logger.debug('Batch file move completed', {
351+
successCount: results.filter((r) => r.success).length,
352+
totalCount: results.length,
353+
});
299354
return results;
300355
}
301356

@@ -307,8 +362,12 @@ export default class LocalFileCtr extends ControllerModule {
307362
newName: string;
308363
path: string;
309364
}): Promise<RenameLocalFileResult> {
365+
const logPrefix = `[Renaming ${currentPath} -> ${newName}]`;
366+
logger.debug(`${logPrefix} Starting rename request`);
367+
310368
// Basic validation (can also be done in frontend action)
311369
if (!currentPath || !newName) {
370+
logger.error(`${logPrefix} Parameter validation failed: path or new name is empty`);
312371
return { error: 'Both path and newName are required.', newPath: '', success: false };
313372
}
314373
// Prevent path traversal or using invalid characters/names
@@ -319,6 +378,7 @@ export default class LocalFileCtr extends ControllerModule {
319378
newName === '..' ||
320379
/["*/:<>?\\|]/.test(newName) // Check for typical invalid filename characters
321380
) {
381+
logger.error(`${logPrefix} New filename contains illegal characters: ${newName}`);
322382
return {
323383
error:
324384
'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.',
@@ -331,18 +391,19 @@ export default class LocalFileCtr extends ControllerModule {
331391
try {
332392
const dir = path.dirname(currentPath);
333393
newPath = path.join(dir, newName);
394+
logger.debug(`${logPrefix} Calculated new path: ${newPath}`);
334395

335396
// Check if paths are identical after calculation
336397
if (path.normalize(currentPath) === path.normalize(newPath)) {
337-
console.log(
338-
`Skipping rename: oldPath and calculated newPath are identical: ${currentPath}`,
398+
logger.info(
399+
`${logPrefix} Source path and calculated target path are identical, skipping rename`,
339400
);
340401
// Consider success as no change is needed, but maybe inform the user?
341402
// Return success for now.
342403
return { newPath, success: true };
343404
}
344405
} catch (error) {
345-
console.error(`Error calculating new path for rename ${currentPath} to ${newName}:`, error);
406+
logger.error(`${logPrefix} Failed to calculate new path:`, error);
346407
return {
347408
error: `Internal error calculating the new path: ${(error as Error).message}`,
348409
newPath: '',
@@ -353,12 +414,12 @@ export default class LocalFileCtr extends ControllerModule {
353414
// Perform the rename operation using fs.promises.rename directly
354415
try {
355416
await renamePromise(currentPath, newPath);
356-
console.log(`Successfully renamed ${currentPath} to ${newPath}`);
417+
logger.info(`${logPrefix} Rename successful: ${currentPath} -> ${newPath}`);
357418
// Optionally return the newPath if frontend needs it
358419
// return { success: true, newPath: newPath };
359420
return { newPath, success: true };
360421
} catch (error) {
361-
console.error(`Error renaming ${currentPath} to ${newPath}:`, error);
422+
logger.error(`${logPrefix} Rename failed:`, error);
362423
let errorMessage = (error as Error).message;
363424
// Provide more specific error messages based on common codes
364425
if ((error as any).code === 'ENOENT') {
@@ -377,4 +438,44 @@ export default class LocalFileCtr extends ControllerModule {
377438
return { error: errorMessage, newPath: '', success: false };
378439
}
379440
}
441+
442+
@ipcClientEvent('writeLocalFile')
443+
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
444+
const logPrefix = `[Writing file ${filePath}]`;
445+
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
446+
447+
// 验证参数
448+
if (!filePath) {
449+
logger.error(`${logPrefix} Parameter validation failed: path is empty`);
450+
return { error: 'Path cannot be empty', success: false };
451+
}
452+
453+
if (content === undefined) {
454+
logger.error(`${logPrefix} Parameter validation failed: content is empty`);
455+
return { error: 'Content cannot be empty', success: false };
456+
}
457+
458+
try {
459+
// 确保目标目录存在
460+
const dirname = path.dirname(filePath);
461+
logger.debug(`${logPrefix} Creating directory: ${dirname}`);
462+
fs.mkdirSync(dirname, { recursive: true });
463+
464+
// 写入文件内容
465+
logger.debug(`${logPrefix} Starting to write content to file`);
466+
await writeFilePromise(filePath, content, 'utf8');
467+
logger.info(`${logPrefix} File written successfully`, {
468+
path: filePath,
469+
size: content.length,
470+
});
471+
472+
return { success: true };
473+
} catch (error) {
474+
logger.error(`${logPrefix} Failed to write file:`, error);
475+
return {
476+
error: `Failed to write file: ${(error as Error).message}`,
477+
success: false,
478+
};
479+
}
480+
}
380481
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@
213213
"openai": "^4.91.1",
214214
"openapi-fetch": "^0.9.8",
215215
"partial-json": "^0.1.7",
216+
"path-browserify-esm": "^1.0.6",
216217
"pdf-parse": "^1.1.1",
217218
"pdfjs-dist": "4.8.69",
218219
"pg": "^8.14.1",

0 commit comments

Comments
 (0)