💄 style: improve local-system tool implement (#12022)

* improve local system ability

* fix build

* improve tools title render

* fix tools

* update

* try to fix lint

* update

* refactor the LocalFileCtr.ts result

* refactor the exector result
This commit is contained in:
Arvin Xu
2026-02-01 12:13:27 +08:00
committed by GitHub
parent 6e4ad89c82
commit 5e203b868c
27 changed files with 1171 additions and 591 deletions

View File

@@ -12,11 +12,11 @@
"main": "./dist/main/index.js",
"scripts": {
"build": "electron-vite build",
"build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never",
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never",
"build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"dev": "electron-vite dev",
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
"electron:dev": "electron-vite dev",
@@ -47,9 +47,6 @@
"get-port-please": "^3.2.0",
"superjson": "^2.2.6"
},
"optionalDependencies": {
"node-mac-permissions": "^2.5.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
@@ -103,6 +100,9 @@
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
"optionalDependencies": {
"node-mac-permissions": "^2.5.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"@napi-rs/canvas",

View File

@@ -218,8 +218,13 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
logger.debug('Listing directory contents:', { dirPath });
async listLocalFiles({
path: dirPath,
sortBy = 'modifiedTime',
sortOrder = 'desc',
limit = 100,
}: ListLocalFileParams): Promise<{ files: FileResult[]; totalCount: number }> {
logger.debug('Listing directory contents:', { dirPath, limit, sortBy, sortOrder });
const results: FileResult[] = [];
try {
@@ -256,22 +261,51 @@ export default class LocalFileCtr extends ControllerModule {
}
}
// Sort entries: folders first, then by name
// Sort entries based on sortBy and sortOrder
results.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1; // Directories first
let comparison = 0;
switch (sortBy) {
case 'name': {
comparison = (a.name || '').localeCompare(b.name || '');
break;
}
case 'modifiedTime': {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
break;
}
case 'createdTime': {
comparison = a.createdTime.getTime() - b.createdTime.getTime();
break;
}
case 'size': {
comparison = a.size - b.size;
break;
}
default: {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
}
}
// Add null/undefined checks for robustness if needed, though names should exist
return (a.name || '').localeCompare(b.name || ''); // Then sort by name
return sortOrder === 'desc' ? -comparison : comparison;
});
logger.debug('Directory listing successful', { dirPath, resultCount: results.length });
return results;
const totalCount = results.length;
// Apply limit
const limitedResults = results.slice(0, limit);
logger.debug('Directory listing successful', {
dirPath,
resultCount: limitedResults.length,
totalCount,
});
return { files: limitedResults, totalCount };
} catch (error) {
logger.error(`Failed to list directory ${dirPath}:`, error);
// Rethrow or return an empty array/error object depending on desired behavior
// For now, returning empty array on error listing directory itself
return [];
// For now, returning empty result on error listing directory itself
return { files: [], totalCount: 0 };
}
}

View File

@@ -20,8 +20,8 @@ vi.mock('@/utils/logger', () => ({
// Mock file-loaders
vi.mock('@lobechat/file-loaders', () => ({
SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'],
loadFile: vi.fn(),
SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db', '$RECYCLE.BIN'],
}));
// Mock electron
@@ -553,6 +553,514 @@ describe('LocalFileCtr', () => {
});
});
describe('listLocalFiles', () => {
it('should list directory contents successfully', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', 'file2.txt', 'folder1']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
if (name === 'folder1') {
return {
isDirectory: () => true,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 4096,
} as any;
}
return {
isDirectory: () => false,
birthtime: new Date('2024-01-02'),
mtime: new Date('2024-01-10'),
atime: new Date('2024-01-18'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({ path: '/test' });
expect(result.files).toHaveLength(3);
expect(result.totalCount).toBe(3);
expect(mockFsPromises.readdir).toHaveBeenCalledWith('/test');
});
it('should filter out system files like .DS_Store and Thumbs.db', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue([
'file1.txt',
'.DS_Store',
'Thumbs.db',
'folder1',
]);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
if (name === 'folder1') {
return {
isDirectory: () => true,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 4096,
} as any;
}
return {
isDirectory: () => false,
birthtime: new Date('2024-01-02'),
mtime: new Date('2024-01-10'),
atime: new Date('2024-01-18'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({ path: '/test' });
// Should only contain file1.txt and folder1, not .DS_Store or Thumbs.db
expect(result.files).toHaveLength(2);
expect(result.totalCount).toBe(2);
expect(result.files.map((r) => r.name)).not.toContain('.DS_Store');
expect(result.files.map((r) => r.name)).not.toContain('Thumbs.db');
expect(result.files.map((r) => r.name)).toContain('folder1');
expect(result.files.map((r) => r.name)).toContain('file1.txt');
});
it('should filter out $RECYCLE.BIN system folder', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', '$RECYCLE.BIN', 'folder1']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const isDir = name === 'folder1' || name === '$RECYCLE.BIN';
return {
isDirectory: () => isDir,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: isDir ? 4096 : 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({ path: '/test' });
// Should not contain $RECYCLE.BIN
expect(result.files).toHaveLength(2);
expect(result.totalCount).toBe(2);
expect(result.files.map((r) => r.name)).not.toContain('$RECYCLE.BIN');
});
it('should sort by name ascending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['zebra.txt', 'alpha.txt', 'apple.txt']);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any);
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.files.map((r) => r.name)).toEqual(['alpha.txt', 'apple.txt', 'zebra.txt']);
});
it('should sort by modifiedTime descending by default', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['old.txt', 'new.txt', 'mid.txt']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const dates: Record<string, Date> = {
'new.txt': new Date('2024-01-20'),
'mid.txt': new Date('2024-01-15'),
'old.txt': new Date('2024-01-01'),
};
return {
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: dates[name!] || new Date('2024-01-01'),
atime: new Date('2024-01-20'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({ path: '/test' });
// Default sort: modifiedTime descending (newest first)
expect(result.files.map((r) => r.name)).toEqual(['new.txt', 'mid.txt', 'old.txt']);
});
it('should sort by size ascending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['large.txt', 'small.txt', 'medium.txt']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const sizes: Record<string, number> = {
'large.txt': 10000,
'medium.txt': 5000,
'small.txt': 1000,
};
return {
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: sizes[name!] || 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'size',
sortOrder: 'asc',
});
expect(result.files.map((r) => r.name)).toEqual(['small.txt', 'medium.txt', 'large.txt']);
});
it('should apply limit parameter', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue([
'file1.txt',
'file2.txt',
'file3.txt',
'file4.txt',
'file5.txt',
]);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any);
const result = await localFileCtr.listLocalFiles({
path: '/test',
limit: 3,
});
expect(result.files).toHaveLength(3);
expect(result.totalCount).toBe(5); // Total is 5, but limited to 3
});
it('should use default limit of 100', async () => {
// Create 150 files
const files = Array.from({ length: 150 }, (_, i) => `file${i}.txt`);
vi.mocked(mockFsPromises.readdir).mockResolvedValue(files);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any);
const result = await localFileCtr.listLocalFiles({ path: '/test' });
expect(result.files).toHaveLength(100);
expect(result.totalCount).toBe(150); // Total is 150, but limited to 100
});
it('should sort by createdTime ascending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue([
'newest.txt',
'oldest.txt',
'middle.txt',
]);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const dates: Record<string, Date> = {
'newest.txt': new Date('2024-03-01'),
'middle.txt': new Date('2024-02-01'),
'oldest.txt': new Date('2024-01-01'),
};
return {
isDirectory: () => false,
birthtime: dates[name!] || new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'createdTime',
sortOrder: 'asc',
});
expect(result.files.map((r) => r.name)).toEqual(['oldest.txt', 'middle.txt', 'newest.txt']);
});
it('should sort by createdTime descending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue([
'newest.txt',
'oldest.txt',
'middle.txt',
]);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const dates: Record<string, Date> = {
'newest.txt': new Date('2024-03-01'),
'middle.txt': new Date('2024-02-01'),
'oldest.txt': new Date('2024-01-01'),
};
return {
isDirectory: () => false,
birthtime: dates[name!] || new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'createdTime',
sortOrder: 'desc',
});
expect(result.files.map((r) => r.name)).toEqual(['newest.txt', 'middle.txt', 'oldest.txt']);
});
it('should sort by name descending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['alpha.txt', 'zebra.txt', 'middle.txt']);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any);
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'name',
sortOrder: 'desc',
});
expect(result.files.map((r) => r.name)).toEqual(['zebra.txt', 'middle.txt', 'alpha.txt']);
});
it('should sort by size descending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['small.txt', 'large.txt', 'medium.txt']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const sizes: Record<string, number> = {
'large.txt': 10000,
'medium.txt': 5000,
'small.txt': 1000,
};
return {
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: sizes[name!] || 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'size',
sortOrder: 'desc',
});
expect(result.files.map((r) => r.name)).toEqual(['large.txt', 'medium.txt', 'small.txt']);
});
it('should sort by modifiedTime ascending when specified', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['old.txt', 'new.txt', 'mid.txt']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const dates: Record<string, Date> = {
'new.txt': new Date('2024-01-20'),
'mid.txt': new Date('2024-01-15'),
'old.txt': new Date('2024-01-01'),
};
return {
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: dates[name!] || new Date('2024-01-01'),
atime: new Date('2024-01-20'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({
path: '/test',
sortBy: 'modifiedTime',
sortOrder: 'asc',
});
expect(result.files.map((r) => r.name)).toEqual(['old.txt', 'mid.txt', 'new.txt']);
});
it('should handle empty directory with sort options', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue([]);
const result = await localFileCtr.listLocalFiles({
path: '/empty',
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.files).toEqual([]);
expect(result.totalCount).toBe(0);
});
it('should apply limit after sorting', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue([
'file1.txt',
'file2.txt',
'file3.txt',
'file4.txt',
'file5.txt',
]);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
const name = (filePath as string).split('/').pop();
const dates: Record<string, Date> = {
'file1.txt': new Date('2024-01-01'),
'file2.txt': new Date('2024-01-02'),
'file3.txt': new Date('2024-01-03'),
'file4.txt': new Date('2024-01-04'),
'file5.txt': new Date('2024-01-05'),
};
return {
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: dates[name!] || new Date('2024-01-01'),
atime: new Date('2024-01-20'),
size: 1024,
} as any;
});
// Sort by modifiedTime desc (default) and limit to 3
const result = await localFileCtr.listLocalFiles({
path: '/test',
limit: 3,
});
// Should get the 3 newest files
expect(result.files).toHaveLength(3);
expect(result.totalCount).toBe(5); // Total is 5, but limited to 3
expect(result.files.map((r) => r.name)).toEqual(['file5.txt', 'file4.txt', 'file3.txt']);
});
it('should handle limit larger than file count', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', 'file2.txt']);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any);
const result = await localFileCtr.listLocalFiles({
path: '/test',
limit: 1000,
});
expect(result.files).toHaveLength(2);
expect(result.totalCount).toBe(2);
});
it('should return file metadata including size, times and type', async () => {
const createdTime = new Date('2024-01-01');
const modifiedTime = new Date('2024-01-15');
const accessTime = new Date('2024-01-20');
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['document.pdf']);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: createdTime,
mtime: modifiedTime,
atime: accessTime,
size: 2048,
} as any);
const result = await localFileCtr.listLocalFiles({ path: '/test' });
expect(result.files).toHaveLength(1);
expect(result.totalCount).toBe(1);
expect(result.files[0]).toEqual({
name: 'document.pdf',
path: '/test/document.pdf',
isDirectory: false,
size: 2048,
type: 'pdf',
createdTime,
modifiedTime,
lastAccessTime: accessTime,
});
});
it('should return empty result when directory read fails', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('Permission denied'));
const result = await localFileCtr.listLocalFiles({ path: '/protected' });
expect(result.files).toEqual([]);
expect(result.totalCount).toBe(0);
});
it('should skip files that cannot be stat', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['good.txt', 'bad.txt']);
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if ((filePath as string).includes('bad.txt')) {
throw new Error('Cannot stat file');
}
return {
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 1024,
} as any;
});
const result = await localFileCtr.listLocalFiles({ path: '/test' });
// Should only contain good.txt, bad.txt should be skipped
expect(result.files).toHaveLength(1);
expect(result.totalCount).toBe(1);
expect(result.files[0].name).toBe('good.txt');
});
it('should handle directory type correctly', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['my_folder']);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => true,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 4096,
} as any);
const result = await localFileCtr.listLocalFiles({ path: '/test' });
expect(result.files).toHaveLength(1);
expect(result.totalCount).toBe(1);
expect(result.files[0].isDirectory).toBe(true);
expect(result.files[0].type).toBe('directory');
});
it('should handle files without extension', async () => {
vi.mocked(mockFsPromises.readdir).mockResolvedValue(['Makefile', 'README']);
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isDirectory: () => false,
birthtime: new Date('2024-01-01'),
mtime: new Date('2024-01-15'),
atime: new Date('2024-01-20'),
size: 512,
} as any);
const result = await localFileCtr.listLocalFiles({ path: '/test' });
expect(result.files).toHaveLength(2);
expect(result.totalCount).toBe(2);
// Files without extension should have empty type
expect(result.files[0].type).toBe('');
expect(result.files[1].type).toBe('');
});
});
describe('handleGrepContent', () => {
it('should search content in a single file', async () => {
vi.mocked(mockFsPromises.stat).mockResolvedValue({