@lobehub/lobehub 2.1.4 → 2.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/apps/desktop/package.json +4 -4
  3. package/apps/desktop/src/main/controllers/LocalFileCtr.ts +45 -11
  4. package/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +509 -1
  5. package/changelog/v2.json +18 -0
  6. package/locales/en-US/plugin.json +1 -0
  7. package/locales/zh-CN/plugin.json +1 -0
  8. package/package.json +1 -1
  9. package/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts +4 -4
  10. package/packages/builtin-tool-local-system/package.json +1 -2
  11. package/packages/builtin-tool-local-system/src/client/Inspector/GlobLocalFiles/index.tsx +21 -22
  12. package/packages/builtin-tool-local-system/src/executor/index.ts +12 -3
  13. package/packages/builtin-tool-local-system/src/manifest.ts +18 -1
  14. package/packages/builtin-tool-local-system/src/systemRole.ts +8 -2
  15. package/packages/builtin-tool-local-system/src/types.ts +1 -0
  16. package/packages/const/src/file.ts +11 -1
  17. package/packages/const/src/index.ts +1 -0
  18. package/packages/const/src/version.ts +1 -1
  19. package/packages/electron-client-ipc/src/types/localSystem.ts +32 -0
  20. package/packages/file-loaders/src/blackList.ts +8 -1
  21. package/packages/prompts/src/prompts/fileSystem/formatFileList.test.ts +297 -5
  22. package/packages/prompts/src/prompts/fileSystem/formatFileList.ts +86 -3
  23. package/src/envs/file.ts +1 -1
  24. package/src/features/ChatInput/ActionBar/Tools/KlavisServerItem.tsx +28 -5
  25. package/src/features/ChatInput/ActionBar/Tools/LobehubSkillServerItem.tsx +32 -9
  26. package/src/features/ChatInput/ActionBar/Tools/useControls.tsx +16 -3
  27. package/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx +100 -32
  28. package/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx +5 -0
  29. package/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx +1 -1
  30. package/src/features/ProfileEditor/AgentTool.tsx +10 -3
  31. package/src/locales/default/plugin.ts +1 -0
  32. package/src/services/electron/localFileService.ts +2 -1
  33. package/packages/builtin-tool-local-system/src/ExecutionRuntime/index.ts +0 -466
  34. package/src/features/Conversation/Messages/Supervisor/components/MessageContent.tsx +0 -29
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 2.1.6](https://github.com/lobehub/lobe-chat/compare/v2.1.5...v2.1.6)
6
+
7
+ <sup>Released on **2026-02-01**</sup>
8
+
9
+ #### 💄 Styles
10
+
11
+ - **misc**: Improve local-system tool implement.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Styles
19
+
20
+ - **misc**: Improve local-system tool implement, closes [#12022](https://github.com/lobehub/lobe-chat/issues/12022) ([5e203b8](https://github.com/lobehub/lobe-chat/commit/5e203b8))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ### [Version 2.1.5](https://github.com/lobehub/lobe-chat/compare/v2.1.4...v2.1.5)
31
+
32
+ <sup>Released on **2026-01-31**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Slove the group member agents cant set skills problem.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Slove the group member agents cant set skills problem, closes [#12021](https://github.com/lobehub/lobe-chat/issues/12021) ([2302940](https://github.com/lobehub/lobe-chat/commit/2302940))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 2.1.4](https://github.com/lobehub/lobe-chat/compare/v2.1.3...v2.1.4)
6
56
 
7
57
  <sup>Released on **2026-01-31**</sup>
@@ -12,11 +12,11 @@
12
12
  "main": "./dist/main/index.js",
13
13
  "scripts": {
14
14
  "build": "electron-vite build",
15
- "build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
16
15
  "build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never",
17
16
  "build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never",
18
17
  "build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
19
18
  "build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never",
19
+ "build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
20
20
  "dev": "electron-vite dev",
21
21
  "dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
22
22
  "electron:dev": "electron-vite dev",
@@ -47,9 +47,6 @@
47
47
  "get-port-please": "^3.2.0",
48
48
  "superjson": "^2.2.6"
49
49
  },
50
- "optionalDependencies": {
51
- "node-mac-permissions": "^2.5.0"
52
- },
53
50
  "devDependencies": {
54
51
  "@electron-toolkit/eslint-config-prettier": "^3.0.0",
55
52
  "@electron-toolkit/eslint-config-ts": "^3.1.0",
@@ -103,6 +100,9 @@
103
100
  "vitest": "^3.2.4",
104
101
  "zod": "^3.25.76"
105
102
  },
103
+ "optionalDependencies": {
104
+ "node-mac-permissions": "^2.5.0"
105
+ },
106
106
  "pnpm": {
107
107
  "onlyBuiltDependencies": [
108
108
  "@napi-rs/canvas",
@@ -218,8 +218,13 @@ export default class LocalFileCtr extends ControllerModule {
218
218
  }
219
219
 
220
220
  @IpcMethod()
221
- async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
222
- logger.debug('Listing directory contents:', { dirPath });
221
+ async listLocalFiles({
222
+ path: dirPath,
223
+ sortBy = 'modifiedTime',
224
+ sortOrder = 'desc',
225
+ limit = 100,
226
+ }: ListLocalFileParams): Promise<{ files: FileResult[]; totalCount: number }> {
227
+ logger.debug('Listing directory contents:', { dirPath, limit, sortBy, sortOrder });
223
228
 
224
229
  const results: FileResult[] = [];
225
230
  try {
@@ -256,22 +261,51 @@ export default class LocalFileCtr extends ControllerModule {
256
261
  }
257
262
  }
258
263
 
259
- // Sort entries: folders first, then by name
264
+ // Sort entries based on sortBy and sortOrder
260
265
  results.sort((a, b) => {
261
- if (a.isDirectory !== b.isDirectory) {
262
- return a.isDirectory ? -1 : 1; // Directories first
266
+ let comparison = 0;
267
+
268
+ switch (sortBy) {
269
+ case 'name': {
270
+ comparison = (a.name || '').localeCompare(b.name || '');
271
+ break;
272
+ }
273
+ case 'modifiedTime': {
274
+ comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
275
+ break;
276
+ }
277
+ case 'createdTime': {
278
+ comparison = a.createdTime.getTime() - b.createdTime.getTime();
279
+ break;
280
+ }
281
+ case 'size': {
282
+ comparison = a.size - b.size;
283
+ break;
284
+ }
285
+ default: {
286
+ comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
287
+ }
263
288
  }
264
- // Add null/undefined checks for robustness if needed, though names should exist
265
- return (a.name || '').localeCompare(b.name || ''); // Then sort by name
289
+
290
+ return sortOrder === 'desc' ? -comparison : comparison;
266
291
  });
267
292
 
268
- logger.debug('Directory listing successful', { dirPath, resultCount: results.length });
269
- return results;
293
+ const totalCount = results.length;
294
+
295
+ // Apply limit
296
+ const limitedResults = results.slice(0, limit);
297
+
298
+ logger.debug('Directory listing successful', {
299
+ dirPath,
300
+ resultCount: limitedResults.length,
301
+ totalCount,
302
+ });
303
+ return { files: limitedResults, totalCount };
270
304
  } catch (error) {
271
305
  logger.error(`Failed to list directory ${dirPath}:`, error);
272
306
  // Rethrow or return an empty array/error object depending on desired behavior
273
- // For now, returning empty array on error listing directory itself
274
- return [];
307
+ // For now, returning empty result on error listing directory itself
308
+ return { files: [], totalCount: 0 };
275
309
  }
276
310
  }
277
311
 
@@ -20,8 +20,8 @@ vi.mock('@/utils/logger', () => ({
20
20
 
21
21
  // Mock file-loaders
22
22
  vi.mock('@lobechat/file-loaders', () => ({
23
- SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'],
24
23
  loadFile: vi.fn(),
24
+ SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db', '$RECYCLE.BIN'],
25
25
  }));
26
26
 
27
27
  // Mock electron
@@ -553,6 +553,514 @@ describe('LocalFileCtr', () => {
553
553
  });
554
554
  });
555
555
 
556
+ describe('listLocalFiles', () => {
557
+ it('should list directory contents successfully', async () => {
558
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', 'file2.txt', 'folder1']);
559
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
560
+ const name = (filePath as string).split('/').pop();
561
+ if (name === 'folder1') {
562
+ return {
563
+ isDirectory: () => true,
564
+ birthtime: new Date('2024-01-01'),
565
+ mtime: new Date('2024-01-15'),
566
+ atime: new Date('2024-01-20'),
567
+ size: 4096,
568
+ } as any;
569
+ }
570
+ return {
571
+ isDirectory: () => false,
572
+ birthtime: new Date('2024-01-02'),
573
+ mtime: new Date('2024-01-10'),
574
+ atime: new Date('2024-01-18'),
575
+ size: 1024,
576
+ } as any;
577
+ });
578
+
579
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
580
+
581
+ expect(result.files).toHaveLength(3);
582
+ expect(result.totalCount).toBe(3);
583
+ expect(mockFsPromises.readdir).toHaveBeenCalledWith('/test');
584
+ });
585
+
586
+ it('should filter out system files like .DS_Store and Thumbs.db', async () => {
587
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue([
588
+ 'file1.txt',
589
+ '.DS_Store',
590
+ 'Thumbs.db',
591
+ 'folder1',
592
+ ]);
593
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
594
+ const name = (filePath as string).split('/').pop();
595
+ if (name === 'folder1') {
596
+ return {
597
+ isDirectory: () => true,
598
+ birthtime: new Date('2024-01-01'),
599
+ mtime: new Date('2024-01-15'),
600
+ atime: new Date('2024-01-20'),
601
+ size: 4096,
602
+ } as any;
603
+ }
604
+ return {
605
+ isDirectory: () => false,
606
+ birthtime: new Date('2024-01-02'),
607
+ mtime: new Date('2024-01-10'),
608
+ atime: new Date('2024-01-18'),
609
+ size: 1024,
610
+ } as any;
611
+ });
612
+
613
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
614
+
615
+ // Should only contain file1.txt and folder1, not .DS_Store or Thumbs.db
616
+ expect(result.files).toHaveLength(2);
617
+ expect(result.totalCount).toBe(2);
618
+ expect(result.files.map((r) => r.name)).not.toContain('.DS_Store');
619
+ expect(result.files.map((r) => r.name)).not.toContain('Thumbs.db');
620
+ expect(result.files.map((r) => r.name)).toContain('folder1');
621
+ expect(result.files.map((r) => r.name)).toContain('file1.txt');
622
+ });
623
+
624
+ it('should filter out $RECYCLE.BIN system folder', async () => {
625
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', '$RECYCLE.BIN', 'folder1']);
626
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
627
+ const name = (filePath as string).split('/').pop();
628
+ const isDir = name === 'folder1' || name === '$RECYCLE.BIN';
629
+ return {
630
+ isDirectory: () => isDir,
631
+ birthtime: new Date('2024-01-01'),
632
+ mtime: new Date('2024-01-15'),
633
+ atime: new Date('2024-01-20'),
634
+ size: isDir ? 4096 : 1024,
635
+ } as any;
636
+ });
637
+
638
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
639
+
640
+ // Should not contain $RECYCLE.BIN
641
+ expect(result.files).toHaveLength(2);
642
+ expect(result.totalCount).toBe(2);
643
+ expect(result.files.map((r) => r.name)).not.toContain('$RECYCLE.BIN');
644
+ });
645
+
646
+ it('should sort by name ascending when specified', async () => {
647
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['zebra.txt', 'alpha.txt', 'apple.txt']);
648
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
649
+ isDirectory: () => false,
650
+ birthtime: new Date('2024-01-01'),
651
+ mtime: new Date('2024-01-15'),
652
+ atime: new Date('2024-01-20'),
653
+ size: 1024,
654
+ } as any);
655
+
656
+ const result = await localFileCtr.listLocalFiles({
657
+ path: '/test',
658
+ sortBy: 'name',
659
+ sortOrder: 'asc',
660
+ });
661
+
662
+ expect(result.files.map((r) => r.name)).toEqual(['alpha.txt', 'apple.txt', 'zebra.txt']);
663
+ });
664
+
665
+ it('should sort by modifiedTime descending by default', async () => {
666
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['old.txt', 'new.txt', 'mid.txt']);
667
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
668
+ const name = (filePath as string).split('/').pop();
669
+ const dates: Record<string, Date> = {
670
+ 'new.txt': new Date('2024-01-20'),
671
+ 'mid.txt': new Date('2024-01-15'),
672
+ 'old.txt': new Date('2024-01-01'),
673
+ };
674
+ return {
675
+ isDirectory: () => false,
676
+ birthtime: new Date('2024-01-01'),
677
+ mtime: dates[name!] || new Date('2024-01-01'),
678
+ atime: new Date('2024-01-20'),
679
+ size: 1024,
680
+ } as any;
681
+ });
682
+
683
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
684
+
685
+ // Default sort: modifiedTime descending (newest first)
686
+ expect(result.files.map((r) => r.name)).toEqual(['new.txt', 'mid.txt', 'old.txt']);
687
+ });
688
+
689
+ it('should sort by size ascending when specified', async () => {
690
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['large.txt', 'small.txt', 'medium.txt']);
691
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
692
+ const name = (filePath as string).split('/').pop();
693
+ const sizes: Record<string, number> = {
694
+ 'large.txt': 10000,
695
+ 'medium.txt': 5000,
696
+ 'small.txt': 1000,
697
+ };
698
+ return {
699
+ isDirectory: () => false,
700
+ birthtime: new Date('2024-01-01'),
701
+ mtime: new Date('2024-01-15'),
702
+ atime: new Date('2024-01-20'),
703
+ size: sizes[name!] || 1024,
704
+ } as any;
705
+ });
706
+
707
+ const result = await localFileCtr.listLocalFiles({
708
+ path: '/test',
709
+ sortBy: 'size',
710
+ sortOrder: 'asc',
711
+ });
712
+
713
+ expect(result.files.map((r) => r.name)).toEqual(['small.txt', 'medium.txt', 'large.txt']);
714
+ });
715
+
716
+ it('should apply limit parameter', async () => {
717
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue([
718
+ 'file1.txt',
719
+ 'file2.txt',
720
+ 'file3.txt',
721
+ 'file4.txt',
722
+ 'file5.txt',
723
+ ]);
724
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
725
+ isDirectory: () => false,
726
+ birthtime: new Date('2024-01-01'),
727
+ mtime: new Date('2024-01-15'),
728
+ atime: new Date('2024-01-20'),
729
+ size: 1024,
730
+ } as any);
731
+
732
+ const result = await localFileCtr.listLocalFiles({
733
+ path: '/test',
734
+ limit: 3,
735
+ });
736
+
737
+ expect(result.files).toHaveLength(3);
738
+ expect(result.totalCount).toBe(5); // Total is 5, but limited to 3
739
+ });
740
+
741
+ it('should use default limit of 100', async () => {
742
+ // Create 150 files
743
+ const files = Array.from({ length: 150 }, (_, i) => `file${i}.txt`);
744
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(files);
745
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
746
+ isDirectory: () => false,
747
+ birthtime: new Date('2024-01-01'),
748
+ mtime: new Date('2024-01-15'),
749
+ atime: new Date('2024-01-20'),
750
+ size: 1024,
751
+ } as any);
752
+
753
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
754
+
755
+ expect(result.files).toHaveLength(100);
756
+ expect(result.totalCount).toBe(150); // Total is 150, but limited to 100
757
+ });
758
+
759
+ it('should sort by createdTime ascending when specified', async () => {
760
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue([
761
+ 'newest.txt',
762
+ 'oldest.txt',
763
+ 'middle.txt',
764
+ ]);
765
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
766
+ const name = (filePath as string).split('/').pop();
767
+ const dates: Record<string, Date> = {
768
+ 'newest.txt': new Date('2024-03-01'),
769
+ 'middle.txt': new Date('2024-02-01'),
770
+ 'oldest.txt': new Date('2024-01-01'),
771
+ };
772
+ return {
773
+ isDirectory: () => false,
774
+ birthtime: dates[name!] || new Date('2024-01-01'),
775
+ mtime: new Date('2024-01-15'),
776
+ atime: new Date('2024-01-20'),
777
+ size: 1024,
778
+ } as any;
779
+ });
780
+
781
+ const result = await localFileCtr.listLocalFiles({
782
+ path: '/test',
783
+ sortBy: 'createdTime',
784
+ sortOrder: 'asc',
785
+ });
786
+
787
+ expect(result.files.map((r) => r.name)).toEqual(['oldest.txt', 'middle.txt', 'newest.txt']);
788
+ });
789
+
790
+ it('should sort by createdTime descending when specified', async () => {
791
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue([
792
+ 'newest.txt',
793
+ 'oldest.txt',
794
+ 'middle.txt',
795
+ ]);
796
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
797
+ const name = (filePath as string).split('/').pop();
798
+ const dates: Record<string, Date> = {
799
+ 'newest.txt': new Date('2024-03-01'),
800
+ 'middle.txt': new Date('2024-02-01'),
801
+ 'oldest.txt': new Date('2024-01-01'),
802
+ };
803
+ return {
804
+ isDirectory: () => false,
805
+ birthtime: dates[name!] || new Date('2024-01-01'),
806
+ mtime: new Date('2024-01-15'),
807
+ atime: new Date('2024-01-20'),
808
+ size: 1024,
809
+ } as any;
810
+ });
811
+
812
+ const result = await localFileCtr.listLocalFiles({
813
+ path: '/test',
814
+ sortBy: 'createdTime',
815
+ sortOrder: 'desc',
816
+ });
817
+
818
+ expect(result.files.map((r) => r.name)).toEqual(['newest.txt', 'middle.txt', 'oldest.txt']);
819
+ });
820
+
821
+ it('should sort by name descending when specified', async () => {
822
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['alpha.txt', 'zebra.txt', 'middle.txt']);
823
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
824
+ isDirectory: () => false,
825
+ birthtime: new Date('2024-01-01'),
826
+ mtime: new Date('2024-01-15'),
827
+ atime: new Date('2024-01-20'),
828
+ size: 1024,
829
+ } as any);
830
+
831
+ const result = await localFileCtr.listLocalFiles({
832
+ path: '/test',
833
+ sortBy: 'name',
834
+ sortOrder: 'desc',
835
+ });
836
+
837
+ expect(result.files.map((r) => r.name)).toEqual(['zebra.txt', 'middle.txt', 'alpha.txt']);
838
+ });
839
+
840
+ it('should sort by size descending when specified', async () => {
841
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['small.txt', 'large.txt', 'medium.txt']);
842
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
843
+ const name = (filePath as string).split('/').pop();
844
+ const sizes: Record<string, number> = {
845
+ 'large.txt': 10000,
846
+ 'medium.txt': 5000,
847
+ 'small.txt': 1000,
848
+ };
849
+ return {
850
+ isDirectory: () => false,
851
+ birthtime: new Date('2024-01-01'),
852
+ mtime: new Date('2024-01-15'),
853
+ atime: new Date('2024-01-20'),
854
+ size: sizes[name!] || 1024,
855
+ } as any;
856
+ });
857
+
858
+ const result = await localFileCtr.listLocalFiles({
859
+ path: '/test',
860
+ sortBy: 'size',
861
+ sortOrder: 'desc',
862
+ });
863
+
864
+ expect(result.files.map((r) => r.name)).toEqual(['large.txt', 'medium.txt', 'small.txt']);
865
+ });
866
+
867
+ it('should sort by modifiedTime ascending when specified', async () => {
868
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['old.txt', 'new.txt', 'mid.txt']);
869
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
870
+ const name = (filePath as string).split('/').pop();
871
+ const dates: Record<string, Date> = {
872
+ 'new.txt': new Date('2024-01-20'),
873
+ 'mid.txt': new Date('2024-01-15'),
874
+ 'old.txt': new Date('2024-01-01'),
875
+ };
876
+ return {
877
+ isDirectory: () => false,
878
+ birthtime: new Date('2024-01-01'),
879
+ mtime: dates[name!] || new Date('2024-01-01'),
880
+ atime: new Date('2024-01-20'),
881
+ size: 1024,
882
+ } as any;
883
+ });
884
+
885
+ const result = await localFileCtr.listLocalFiles({
886
+ path: '/test',
887
+ sortBy: 'modifiedTime',
888
+ sortOrder: 'asc',
889
+ });
890
+
891
+ expect(result.files.map((r) => r.name)).toEqual(['old.txt', 'mid.txt', 'new.txt']);
892
+ });
893
+
894
+ it('should handle empty directory with sort options', async () => {
895
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue([]);
896
+
897
+ const result = await localFileCtr.listLocalFiles({
898
+ path: '/empty',
899
+ sortBy: 'name',
900
+ sortOrder: 'asc',
901
+ });
902
+
903
+ expect(result.files).toEqual([]);
904
+ expect(result.totalCount).toBe(0);
905
+ });
906
+
907
+ it('should apply limit after sorting', async () => {
908
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue([
909
+ 'file1.txt',
910
+ 'file2.txt',
911
+ 'file3.txt',
912
+ 'file4.txt',
913
+ 'file5.txt',
914
+ ]);
915
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
916
+ const name = (filePath as string).split('/').pop();
917
+ const dates: Record<string, Date> = {
918
+ 'file1.txt': new Date('2024-01-01'),
919
+ 'file2.txt': new Date('2024-01-02'),
920
+ 'file3.txt': new Date('2024-01-03'),
921
+ 'file4.txt': new Date('2024-01-04'),
922
+ 'file5.txt': new Date('2024-01-05'),
923
+ };
924
+ return {
925
+ isDirectory: () => false,
926
+ birthtime: new Date('2024-01-01'),
927
+ mtime: dates[name!] || new Date('2024-01-01'),
928
+ atime: new Date('2024-01-20'),
929
+ size: 1024,
930
+ } as any;
931
+ });
932
+
933
+ // Sort by modifiedTime desc (default) and limit to 3
934
+ const result = await localFileCtr.listLocalFiles({
935
+ path: '/test',
936
+ limit: 3,
937
+ });
938
+
939
+ // Should get the 3 newest files
940
+ expect(result.files).toHaveLength(3);
941
+ expect(result.totalCount).toBe(5); // Total is 5, but limited to 3
942
+ expect(result.files.map((r) => r.name)).toEqual(['file5.txt', 'file4.txt', 'file3.txt']);
943
+ });
944
+
945
+ it('should handle limit larger than file count', async () => {
946
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', 'file2.txt']);
947
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
948
+ isDirectory: () => false,
949
+ birthtime: new Date('2024-01-01'),
950
+ mtime: new Date('2024-01-15'),
951
+ atime: new Date('2024-01-20'),
952
+ size: 1024,
953
+ } as any);
954
+
955
+ const result = await localFileCtr.listLocalFiles({
956
+ path: '/test',
957
+ limit: 1000,
958
+ });
959
+
960
+ expect(result.files).toHaveLength(2);
961
+ expect(result.totalCount).toBe(2);
962
+ });
963
+
964
+ it('should return file metadata including size, times and type', async () => {
965
+ const createdTime = new Date('2024-01-01');
966
+ const modifiedTime = new Date('2024-01-15');
967
+ const accessTime = new Date('2024-01-20');
968
+
969
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['document.pdf']);
970
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
971
+ isDirectory: () => false,
972
+ birthtime: createdTime,
973
+ mtime: modifiedTime,
974
+ atime: accessTime,
975
+ size: 2048,
976
+ } as any);
977
+
978
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
979
+
980
+ expect(result.files).toHaveLength(1);
981
+ expect(result.totalCount).toBe(1);
982
+ expect(result.files[0]).toEqual({
983
+ name: 'document.pdf',
984
+ path: '/test/document.pdf',
985
+ isDirectory: false,
986
+ size: 2048,
987
+ type: 'pdf',
988
+ createdTime,
989
+ modifiedTime,
990
+ lastAccessTime: accessTime,
991
+ });
992
+ });
993
+
994
+ it('should return empty result when directory read fails', async () => {
995
+ vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('Permission denied'));
996
+
997
+ const result = await localFileCtr.listLocalFiles({ path: '/protected' });
998
+
999
+ expect(result.files).toEqual([]);
1000
+ expect(result.totalCount).toBe(0);
1001
+ });
1002
+
1003
+ it('should skip files that cannot be stat', async () => {
1004
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['good.txt', 'bad.txt']);
1005
+ vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
1006
+ if ((filePath as string).includes('bad.txt')) {
1007
+ throw new Error('Cannot stat file');
1008
+ }
1009
+ return {
1010
+ isDirectory: () => false,
1011
+ birthtime: new Date('2024-01-01'),
1012
+ mtime: new Date('2024-01-15'),
1013
+ atime: new Date('2024-01-20'),
1014
+ size: 1024,
1015
+ } as any;
1016
+ });
1017
+
1018
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
1019
+
1020
+ // Should only contain good.txt, bad.txt should be skipped
1021
+ expect(result.files).toHaveLength(1);
1022
+ expect(result.totalCount).toBe(1);
1023
+ expect(result.files[0].name).toBe('good.txt');
1024
+ });
1025
+
1026
+ it('should handle directory type correctly', async () => {
1027
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['my_folder']);
1028
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
1029
+ isDirectory: () => true,
1030
+ birthtime: new Date('2024-01-01'),
1031
+ mtime: new Date('2024-01-15'),
1032
+ atime: new Date('2024-01-20'),
1033
+ size: 4096,
1034
+ } as any);
1035
+
1036
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
1037
+
1038
+ expect(result.files).toHaveLength(1);
1039
+ expect(result.totalCount).toBe(1);
1040
+ expect(result.files[0].isDirectory).toBe(true);
1041
+ expect(result.files[0].type).toBe('directory');
1042
+ });
1043
+
1044
+ it('should handle files without extension', async () => {
1045
+ vi.mocked(mockFsPromises.readdir).mockResolvedValue(['Makefile', 'README']);
1046
+ vi.mocked(mockFsPromises.stat).mockResolvedValue({
1047
+ isDirectory: () => false,
1048
+ birthtime: new Date('2024-01-01'),
1049
+ mtime: new Date('2024-01-15'),
1050
+ atime: new Date('2024-01-20'),
1051
+ size: 512,
1052
+ } as any);
1053
+
1054
+ const result = await localFileCtr.listLocalFiles({ path: '/test' });
1055
+
1056
+ expect(result.files).toHaveLength(2);
1057
+ expect(result.totalCount).toBe(2);
1058
+ // Files without extension should have empty type
1059
+ expect(result.files[0].type).toBe('');
1060
+ expect(result.files[1].type).toBe('');
1061
+ });
1062
+ });
1063
+
556
1064
  describe('handleGrepContent', () => {
557
1065
  it('should search content in a single file', async () => {
558
1066
  vi.mocked(mockFsPromises.stat).mockResolvedValue({