mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
♻️ refactor(fileSearch): File/Content Search by Platform (#12074)
* ♻️ refactor(fileSearch): consolidate determineContentType to base class and use os.homedir() - Move determineContentType method to FileSearchImpl base class with merged type mappings - Replace process.env.HOME with os.homedir() in macOS and Linux implementations - Replace process.env.USERPROFILE with os.homedir() in Windows implementation - Remove duplicate implementation from three platform-specific classes * refactor: content search service * chore: add engine text * fix: show engine * test: add unit tests for contentSearch and fileSearch modules - Add base.test.ts and index.test.ts for contentSearch module - Add base.test.ts and index.test.ts for fileSearch module - Test buildGrepArgs, determineContentType, escapeGlobPattern, etc. - Test factory functions for platform-specific implementations * 🐛 fix(contentSearch): use correct ripgrep glob patterns for nested path exclusion Change `!node_modules` and `!.git` to `!**/node_modules/**` and `!**/.git/**` to properly exclude nested directories as per ripgrep documentation. * fix: types
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import {
|
||||
EditLocalFileParams,
|
||||
EditLocalFileResult,
|
||||
@@ -22,13 +23,13 @@ import {
|
||||
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
|
||||
import { createPatch } from 'diff';
|
||||
import { dialog, shell } from 'electron';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats, constants } from 'node:fs';
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { FileResult, SearchOptions } from '@/modules/fileSearch';
|
||||
import ContentSearchService from '@/services/contentSearchSrv';
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -43,6 +44,10 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return this.app.getService(FileSearchService);
|
||||
}
|
||||
|
||||
private get contentSearchService() {
|
||||
return this.app.getService(ContentSearchService);
|
||||
}
|
||||
|
||||
// ==================== File Operation ====================
|
||||
|
||||
@IpcMethod()
|
||||
@@ -584,163 +589,12 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting content search`, { output_mode, searchPath });
|
||||
|
||||
try {
|
||||
const regex = new RegExp(
|
||||
pattern,
|
||||
`g${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`,
|
||||
);
|
||||
|
||||
// Determine files to search
|
||||
let filesToSearch: string[] = [];
|
||||
const stats = await stat(searchPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
filesToSearch = [searchPath];
|
||||
} else {
|
||||
// Use glob pattern if provided, otherwise search all files
|
||||
// If glob doesn't contain directory separator and doesn't start with **,
|
||||
// auto-prefix with **/ to make it recursive
|
||||
let globPattern = params.glob || '**/*';
|
||||
if (params.glob && !params.glob.includes('/') && !params.glob.startsWith('**')) {
|
||||
globPattern = `**/${params.glob}`;
|
||||
}
|
||||
|
||||
filesToSearch = await fg(globPattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
// Filter by type if provided
|
||||
if (params.type) {
|
||||
const ext = `.${params.type}`;
|
||||
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
|
||||
|
||||
const matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (const filePath of filesToSearch) {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) continue;
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
if (regex.test(content)) {
|
||||
matches.push(filePath);
|
||||
totalMatches++;
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
const matchedLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const contextBefore = params['-B'] || params['-C'] || 0;
|
||||
const contextAfter = params['-A'] || params['-C'] || 0;
|
||||
|
||||
const startLine = Math.max(0, i - contextBefore);
|
||||
const endLine = Math.min(lines.length - 1, i + contextAfter);
|
||||
|
||||
for (let j = startLine; j <= endLine; j++) {
|
||||
const lineNum = params['-n'] ? `${j + 1}:` : '';
|
||||
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
|
||||
}
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
matches.push(...matchedLines);
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const fileMatches = (content.match(regex) || []).length;
|
||||
if (fileMatches > 0) {
|
||||
matches.push(`${filePath}:${fileMatches}`);
|
||||
totalMatches += fileMatches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
return this.contentSearchService.grep(params);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleGlobFiles({
|
||||
path: searchPath = process.cwd(),
|
||||
pattern,
|
||||
}: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const logPrefix = `[globFiles: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting glob search`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
async handleGlobFiles(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return this.searchService.glob(params);
|
||||
}
|
||||
|
||||
// ==================== File Editing ====================
|
||||
|
||||
115
apps/desktop/src/main/controllers/ToolDetectorCtr.ts
Normal file
115
apps/desktop/src/main/controllers/ToolDetectorCtr.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ToolCategory, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ToolDetectorCtr');
|
||||
|
||||
/**
|
||||
* Tool Detector Controller
|
||||
*
|
||||
* Provides IPC interface for querying tool detection status.
|
||||
* Frontend can use these methods to display tool availability to users.
|
||||
*/
|
||||
export default class ToolDetectorCtr extends ControllerModule {
|
||||
static override readonly groupName = 'toolDetector';
|
||||
|
||||
private get manager() {
|
||||
return this.app.toolDetectorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a single tool
|
||||
*/
|
||||
@IpcMethod()
|
||||
async detectTool(name: string, force = false): Promise<ToolStatus> {
|
||||
logger.debug(`Detecting tool: ${name}, force: ${force}`);
|
||||
return this.manager.detect(name, force);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all registered tools
|
||||
*/
|
||||
@IpcMethod()
|
||||
async detectAllTools(force = false): Promise<Record<string, ToolStatus>> {
|
||||
logger.debug(`Detecting all tools, force: ${force}`);
|
||||
const results = await this.manager.detectAll(force);
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all tools in a category
|
||||
*/
|
||||
@IpcMethod()
|
||||
async detectCategory(category: ToolCategory, force = false): Promise<Record<string, ToolStatus>> {
|
||||
logger.debug(`Detecting category: ${category}, force: ${force}`);
|
||||
const results = await this.manager.detectCategory(category, force);
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available tool in a category
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getBestTool(category: ToolCategory): Promise<string | null> {
|
||||
logger.debug(`Getting best tool for category: ${category}`);
|
||||
return this.manager.getBestTool(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached status for a tool (no detection)
|
||||
*/
|
||||
@IpcMethod()
|
||||
getToolStatus(name: string): ToolStatus | null {
|
||||
return this.manager.getStatus(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached statuses (no detection)
|
||||
*/
|
||||
@IpcMethod()
|
||||
getAllToolStatus(): Record<string, ToolStatus> {
|
||||
return Object.fromEntries(this.manager.getAllStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tool status cache
|
||||
*/
|
||||
@IpcMethod()
|
||||
clearToolCache(name?: string): void {
|
||||
this.manager.clearCache(name);
|
||||
logger.debug(`Cleared tool cache${name ? ` for: ${name}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered tools
|
||||
*/
|
||||
@IpcMethod()
|
||||
getRegisteredTools(): string[] {
|
||||
return this.manager.getRegisteredTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
@IpcMethod()
|
||||
getCategories(): ToolCategory[] {
|
||||
return this.manager.getCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools in a category with their info
|
||||
*/
|
||||
@IpcMethod()
|
||||
getToolsInCategory(category: ToolCategory): Array<{
|
||||
description?: string;
|
||||
name: string;
|
||||
priority?: number;
|
||||
}> {
|
||||
return this.manager.getToolsInCategory(category).map((detector) => ({
|
||||
description: detector.description,
|
||||
name: detector.name,
|
||||
priority: detector.priority,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,6 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fast-glob
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises and node:fs
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
@@ -66,6 +61,14 @@ vi.mock('node:fs', () => ({
|
||||
// Mock FileSearchService
|
||||
const mockSearchService = {
|
||||
search: vi.fn(),
|
||||
glob: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock ContentSearchService
|
||||
const mockContentSearchService = {
|
||||
grep: vi.fn(),
|
||||
astGrep: vi.fn(),
|
||||
checkToolAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock makeSureDirExist
|
||||
@@ -74,13 +77,21 @@ vi.mock('@/utils/file-system', () => ({
|
||||
}));
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockSearchService),
|
||||
getService: vi.fn((ServiceClass: any) => {
|
||||
// Return different mock based on service class name
|
||||
if (ServiceClass?.name === 'ContentSearchService') {
|
||||
return mockContentSearchService;
|
||||
}
|
||||
return mockSearchService;
|
||||
}),
|
||||
toolDetectorManager: {
|
||||
getBestTool: vi.fn(() => null), // No external tools available, use Node.js fallback
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
describe('LocalFileCtr', () => {
|
||||
let localFileCtr: LocalFileCtr;
|
||||
let mockShell: any;
|
||||
let mockFg: any;
|
||||
let mockLoadFile: any;
|
||||
let mockFsPromises: any;
|
||||
|
||||
@@ -89,7 +100,6 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
// Import mocks
|
||||
mockShell = (await import('electron')).shell;
|
||||
mockFg = (await import('fast-glob')).default;
|
||||
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
|
||||
mockFsPromises = await import('node:fs/promises');
|
||||
|
||||
@@ -389,11 +399,12 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockFiles = [
|
||||
{ path: '/test/file1.txt', stats: { mtime: new Date('2024-01-02') } },
|
||||
{ path: '/test/file2.txt', stats: { mtime: new Date('2024-01-01') } },
|
||||
];
|
||||
vi.mocked(mockFg).mockResolvedValue(mockFiles);
|
||||
const mockResult = {
|
||||
success: true,
|
||||
files: ['/test/file1.txt', '/test/file2.txt'],
|
||||
total_files: 2,
|
||||
};
|
||||
mockSearchService.glob.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
@@ -403,10 +414,20 @@ describe('LocalFileCtr', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.files).toEqual(['/test/file1.txt', '/test/file2.txt']);
|
||||
expect(result.total_files).toBe(2);
|
||||
expect(mockSearchService.glob).toHaveBeenCalledWith({
|
||||
pattern: '*.txt',
|
||||
path: '/test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle glob error', async () => {
|
||||
vi.mocked(mockFg).mockRejectedValue(new Error('Glob failed'));
|
||||
const mockResult = {
|
||||
success: false,
|
||||
files: [],
|
||||
total_files: 0,
|
||||
error: 'Glob failed',
|
||||
};
|
||||
mockSearchService.glob.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
@@ -416,6 +437,7 @@ describe('LocalFileCtr', () => {
|
||||
success: false,
|
||||
files: [],
|
||||
total_files: 0,
|
||||
error: 'Glob failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1062,231 +1084,38 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
describe('handleGrepContent', () => {
|
||||
it('should search content in a single file', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('Hello world\nTest line\nAnother test');
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockContentSearchService.grep).mockReset();
|
||||
});
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
it('should delegate grep to contentSearchService', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
matches: ['/test/file.txt'],
|
||||
total_matches: 1,
|
||||
};
|
||||
vi.mocked(mockContentSearchService.grep).mockResolvedValue(mockResult);
|
||||
|
||||
const params = {
|
||||
'pattern': 'test',
|
||||
'path': '/test/file.txt',
|
||||
'-i': true,
|
||||
});
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file.txt');
|
||||
expect(result.total_matches).toBe(1);
|
||||
const result = await localFileCtr.handleGrepContent(params);
|
||||
|
||||
expect(mockContentSearchService.grep).toHaveBeenCalledWith(params);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should search content in directory with default glob pattern', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file1.txt', '/test/file2.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test/file1.txt') return 'Hello world';
|
||||
if (filePath === '/test/file2.txt') return 'Test content';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'Hello',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file1.txt');
|
||||
expect(result.total_matches).toBe(1);
|
||||
expect(mockFg).toHaveBeenCalledWith('**/*', expect.objectContaining({ cwd: '/test' }));
|
||||
});
|
||||
|
||||
it('should auto-prefix glob pattern with **/ for non-recursive patterns', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts', '/test/lib/file2.tsx']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
glob: '*.{ts,tsx}',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should auto-prefix *.{ts,tsx} with **/ to make it recursive
|
||||
expect(mockFg).toHaveBeenCalledWith(
|
||||
'**/*.{ts,tsx}',
|
||||
expect.objectContaining({ cwd: '/test' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify glob pattern that already contains path separator', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
glob: 'src/*.ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should not modify glob pattern that already contains /
|
||||
expect(mockFg).toHaveBeenCalledWith('src/*.ts', expect.objectContaining({ cwd: '/test' }));
|
||||
});
|
||||
|
||||
it('should not modify glob pattern that starts with **', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
glob: '**/components/*.tsx',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should not modify glob pattern that already starts with **
|
||||
expect(mockFg).toHaveBeenCalledWith(
|
||||
'**/components/*.tsx',
|
||||
expect.objectContaining({ cwd: '/test' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by type when provided', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
// fast-glob returns all files, then type filter is applied
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file1.ts', '/test/file2.js', '/test/file3.ts']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('unique_pattern');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'unique_pattern',
|
||||
path: '/test',
|
||||
type: 'ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Type filter should exclude .js files from being searched
|
||||
// Only .ts files should be in the results
|
||||
expect(result.matches).not.toContain('/test/file2.js');
|
||||
// At least one .ts file should match
|
||||
expect(result.matches.length).toBeGreaterThan(0);
|
||||
expect(result.matches.every((m) => m.endsWith('.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return content mode with line numbers', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('line 1\ntest line\nline 3');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
'pattern': 'test',
|
||||
'path': '/test',
|
||||
'output_mode': 'content',
|
||||
'-n': true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches.some((m) => m.includes('2:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return count mode', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('test one\ntest two\ntest three');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
output_mode: 'count',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file.txt:3');
|
||||
expect(result.total_matches).toBe(3);
|
||||
});
|
||||
|
||||
it('should respect head_limit', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue([
|
||||
'/test/file1.txt',
|
||||
'/test/file2.txt',
|
||||
'/test/file3.txt',
|
||||
'/test/file4.txt',
|
||||
'/test/file5.txt',
|
||||
]);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('test content');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
head_limit: 2,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle case insensitive search', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('Hello World\nHELLO world\nhello WORLD');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
'pattern': 'hello',
|
||||
'path': '/test/file.txt',
|
||||
'-i': true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file.txt');
|
||||
});
|
||||
|
||||
it('should handle grep error gracefully', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('Path not found'));
|
||||
it('should return error result from contentSearchService', async () => {
|
||||
const mockResult = {
|
||||
success: false,
|
||||
matches: [],
|
||||
total_matches: 0,
|
||||
error: 'Search failed',
|
||||
};
|
||||
vi.mocked(mockContentSearchService.grep).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
@@ -1294,31 +1123,30 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.matches).toEqual([]);
|
||||
expect(result.total_matches).toBe(0);
|
||||
expect(result.error).toBe('Search failed');
|
||||
});
|
||||
|
||||
it('should skip unreadable files gracefully', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file1.txt', '/test/file2.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test/file1.txt') throw new Error('Permission denied');
|
||||
return 'test content';
|
||||
});
|
||||
it('should pass all parameters to contentSearchService', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
matches: ['/test/file.txt:2:test line'],
|
||||
total_matches: 1,
|
||||
};
|
||||
vi.mocked(mockContentSearchService.grep).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
});
|
||||
const params = {
|
||||
'pattern': 'test',
|
||||
'path': '/test',
|
||||
'output_mode': 'content' as const,
|
||||
'-n': true,
|
||||
'-i': true,
|
||||
'glob': '*.ts',
|
||||
'head_limit': 10,
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should still find match in file2.txt despite file1.txt error
|
||||
expect(result.matches).toContain('/test/file2.txt');
|
||||
await localFileCtr.handleGrepContent(params);
|
||||
|
||||
expect(mockContentSearchService.grep).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import ShortcutController from './ShortcutCtr';
|
||||
import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
@@ -33,6 +34,7 @@ export const controllerIpcConstructors = [
|
||||
ShellCommandCtr,
|
||||
ShortcutController,
|
||||
SystemController,
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
|
||||
@@ -11,6 +11,11 @@ import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import AuthCtr from '@/controllers/AuthCtr';
|
||||
import {
|
||||
astSearchDetectors,
|
||||
contentSearchDetectors,
|
||||
fileSearchDetectors,
|
||||
} from '@/modules/toolDetectors';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -21,6 +26,7 @@ import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
import { StoreManager } from './infrastructure/StoreManager';
|
||||
import { ToolDetectorManager } from './infrastructure/ToolDetectorManager';
|
||||
import { UpdaterManager } from './infrastructure/UpdaterManager';
|
||||
import { MenuManager } from './ui/MenuManager';
|
||||
import { ShortcutManager } from './ui/ShortcutManager';
|
||||
@@ -47,6 +53,7 @@ export class App {
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
toolDetectorManager: ToolDetectorManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
|
||||
/**
|
||||
@@ -120,6 +127,10 @@ export class App {
|
||||
this.trayManager = new TrayManager(this);
|
||||
this.staticFileServerManager = new StaticFileServerManager(this);
|
||||
this.protocolManager = new ProtocolManager(this);
|
||||
this.toolDetectorManager = new ToolDetectorManager(this);
|
||||
|
||||
// Register built-in tool detectors
|
||||
this.registerBuiltinToolDetectors();
|
||||
|
||||
// Configure renderer loading strategy (dev server vs static export)
|
||||
// should register before app ready
|
||||
@@ -159,6 +170,32 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in tool detectors for content search and file search
|
||||
*/
|
||||
private registerBuiltinToolDetectors() {
|
||||
logger.debug('Registering built-in tool detectors');
|
||||
|
||||
// Register content search tools (rg, ag, grep)
|
||||
for (const detector of contentSearchDetectors) {
|
||||
this.toolDetectorManager.register(detector, 'content-search');
|
||||
}
|
||||
|
||||
// Register AST-based code search tools (ast-grep)
|
||||
for (const detector of astSearchDetectors) {
|
||||
this.toolDetectorManager.register(detector, 'ast-search');
|
||||
}
|
||||
|
||||
// Register file search tools (mdfind, fd, find)
|
||||
for (const detector of fileSearchDetectors) {
|
||||
this.toolDetectorManager.register(detector, 'file-search');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Registered ${this.toolDetectorManager.getRegisteredTools().length} tool detectors`,
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap = async () => {
|
||||
logger.info('Bootstrapping application');
|
||||
// make single instance
|
||||
|
||||
351
apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts
Normal file
351
apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { App } from '@/core/App';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const logger = createLogger('core:ToolDetectorManager');
|
||||
|
||||
/**
|
||||
* Tool detection status
|
||||
*/
|
||||
export interface ToolStatus {
|
||||
available: boolean;
|
||||
error?: string;
|
||||
lastChecked?: Date;
|
||||
path?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool detector interface - modules implement this to register detection logic
|
||||
*/
|
||||
export interface IToolDetector {
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Detection method */
|
||||
detect(): Promise<ToolStatus>;
|
||||
/** Tool name, e.g., 'rg', 'mdfind' */
|
||||
name: string;
|
||||
/** Priority within category, lower number = higher priority */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool categories
|
||||
*/
|
||||
export type ToolCategory = 'content-search' | 'ast-search' | 'file-search' | 'system' | 'custom';
|
||||
|
||||
/**
|
||||
* Tool Detector Manager
|
||||
*
|
||||
* A plugin-style manager for detecting system tools availability.
|
||||
* Modules can register their own detectors and query tool status.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Register a detector
|
||||
* manager.register({
|
||||
* name: 'rg',
|
||||
* description: 'ripgrep',
|
||||
* priority: 1,
|
||||
* async detect() { ... }
|
||||
* }, 'content-search');
|
||||
*
|
||||
* // Query status
|
||||
* const status = await manager.detect('rg');
|
||||
* const bestTool = await manager.getBestTool('content-search');
|
||||
* ```
|
||||
*/
|
||||
export class ToolDetectorManager {
|
||||
private app: App;
|
||||
private detectors = new Map<string, IToolDetector>();
|
||||
private statusCache = new Map<string, ToolStatus>();
|
||||
private categoryMap = new Map<ToolCategory, Set<string>>();
|
||||
private initialized = false;
|
||||
|
||||
constructor(app: App) {
|
||||
logger.debug('Initializing ToolDetectorManager');
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool detector
|
||||
* @param detector The detector to register
|
||||
* @param category Tool category for grouping
|
||||
*/
|
||||
register(detector: IToolDetector, category: ToolCategory = 'custom'): void {
|
||||
const { name } = detector;
|
||||
|
||||
if (this.detectors.has(name)) {
|
||||
logger.warn(`Detector for '${name}' already registered, overwriting`);
|
||||
}
|
||||
|
||||
this.detectors.set(name, detector);
|
||||
|
||||
// Add to category
|
||||
if (!this.categoryMap.has(category)) {
|
||||
this.categoryMap.set(category, new Set());
|
||||
}
|
||||
this.categoryMap.get(category)!.add(name);
|
||||
|
||||
logger.debug(
|
||||
`Registered detector: ${name} (category: ${category}, priority: ${detector.priority ?? 'default'})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a tool detector
|
||||
* @param name Tool name to unregister
|
||||
*/
|
||||
unregister(name: string): boolean {
|
||||
if (!this.detectors.has(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.detectors.delete(name);
|
||||
this.statusCache.delete(name);
|
||||
|
||||
// Remove from category
|
||||
for (const tools of this.categoryMap.values()) {
|
||||
tools.delete(name);
|
||||
}
|
||||
|
||||
logger.debug(`Unregistered detector: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a single tool
|
||||
* @param name Tool name
|
||||
* @param force Force detection, bypass cache
|
||||
*/
|
||||
async detect(name: string, force = false): Promise<ToolStatus> {
|
||||
const detector = this.detectors.get(name);
|
||||
if (!detector) {
|
||||
return {
|
||||
available: false,
|
||||
error: `No detector registered for '${name}'`,
|
||||
};
|
||||
}
|
||||
|
||||
// Return cached result if available and not forced
|
||||
if (!force && this.statusCache.has(name)) {
|
||||
return this.statusCache.get(name)!;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Detecting tool: ${name}`);
|
||||
const status = await detector.detect();
|
||||
status.lastChecked = new Date();
|
||||
this.statusCache.set(name, status);
|
||||
|
||||
logger.debug(`Tool ${name} detection result:`, {
|
||||
available: status.available,
|
||||
path: status.path,
|
||||
version: status.version,
|
||||
});
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
const status: ToolStatus = {
|
||||
available: false,
|
||||
error: (error as Error).message,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
this.statusCache.set(name, status);
|
||||
logger.error(`Error detecting tool ${name}:`, error);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all registered tools
|
||||
* @param force Force detection, bypass cache
|
||||
*/
|
||||
async detectAll(force = false): Promise<Map<string, ToolStatus>> {
|
||||
const results = new Map<string, ToolStatus>();
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this.detectors.keys()).map(async (name) => {
|
||||
const status = await this.detect(name, force);
|
||||
results.set(name, status);
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all tools in a category
|
||||
* @param category Tool category
|
||||
* @param force Force detection, bypass cache
|
||||
*/
|
||||
async detectCategory(category: ToolCategory, force = false): Promise<Map<string, ToolStatus>> {
|
||||
const tools = this.categoryMap.get(category);
|
||||
if (!tools) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const results = new Map<string, ToolStatus>();
|
||||
|
||||
await Promise.all(
|
||||
Array.from(tools).map(async (name) => {
|
||||
const status = await this.detect(name, force);
|
||||
results.set(name, status);
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached status for a tool
|
||||
* @param name Tool name
|
||||
*/
|
||||
getStatus(name: string): ToolStatus | undefined {
|
||||
return this.statusCache.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached statuses
|
||||
*/
|
||||
getAllStatus(): Map<string, ToolStatus> {
|
||||
return new Map(this.statusCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available tool in a category
|
||||
* Returns the first available tool sorted by priority
|
||||
* @param category Tool category
|
||||
*/
|
||||
async getBestTool(category: ToolCategory): Promise<string | null> {
|
||||
const tools = this.categoryMap.get(category);
|
||||
if (!tools || tools.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get detectors and sort by priority
|
||||
const sortedDetectors = Array.from(tools)
|
||||
.map((name) => this.detectors.get(name)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||
|
||||
// Find first available tool
|
||||
for (const detector of sortedDetectors) {
|
||||
const status = await this.detect(detector.name);
|
||||
if (status.available) {
|
||||
return detector.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools in a category, sorted by priority
|
||||
* @param category Tool category
|
||||
*/
|
||||
getToolsInCategory(category: ToolCategory): IToolDetector[] {
|
||||
const tools = this.categoryMap.get(category);
|
||||
if (!tools) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(tools)
|
||||
.map((name) => this.detectors.get(name)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear status cache
|
||||
* @param name Optional tool name; if not provided, clears all
|
||||
*/
|
||||
clearCache(name?: string): void {
|
||||
if (name) {
|
||||
this.statusCache.delete(name);
|
||||
logger.debug(`Cleared cache for: ${name}`);
|
||||
} else {
|
||||
this.statusCache.clear();
|
||||
logger.debug('Cleared all cache');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tool names
|
||||
*/
|
||||
getRegisteredTools(): string[] {
|
||||
return Array.from(this.detectors.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
getCategories(): ToolCategory[] {
|
||||
return Array.from(this.categoryMap.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is registered
|
||||
*/
|
||||
isRegistered(name: string): boolean {
|
||||
return this.detectors.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper: Create a command-based detector
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Create a simple command-based detector
|
||||
* Useful for common tools that follow standard patterns
|
||||
*/
|
||||
export function createCommandDetector(
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
priority?: number;
|
||||
versionFlag?: string;
|
||||
whichCommand?: string;
|
||||
} = {},
|
||||
): IToolDetector {
|
||||
const { description, priority, versionFlag = '--version', whichCommand } = options;
|
||||
|
||||
return {
|
||||
description,
|
||||
async detect(): Promise<ToolStatus> {
|
||||
try {
|
||||
// Check if tool exists
|
||||
const whichCmd = whichCommand || (process.platform === 'win32' ? 'where' : 'which');
|
||||
const { stdout: pathOut } = await execPromise(`${whichCmd} ${name}`, { timeout: 3000 });
|
||||
const toolPath = pathOut.trim().split('\n')[0];
|
||||
|
||||
// Try to get version
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const { stdout: versionOut } = await execPromise(`${name} ${versionFlag}`, {
|
||||
timeout: 3000,
|
||||
});
|
||||
version = versionOut.trim().split('\n')[0];
|
||||
} catch {
|
||||
// Some tools don't support version flag
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: toolPath,
|
||||
version,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
name,
|
||||
priority,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BaseContentSearch } from '../base';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fast-glob
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Concrete implementation for testing
|
||||
*/
|
||||
class TestContentSearch extends BaseContentSearch {
|
||||
public currentTool: string | null = null;
|
||||
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
return tool === 'nodejs';
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testBuildGrepArgs(tool: 'rg' | 'ag' | 'grep', params: GrepContentParams): string[] {
|
||||
return this.buildGrepArgs(tool, params);
|
||||
}
|
||||
|
||||
public testGetDefaultIgnorePatterns(): string[] {
|
||||
return this.getDefaultIgnorePatterns();
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseContentSearch', () => {
|
||||
let contentSearch: TestContentSearch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
contentSearch = new TestContentSearch();
|
||||
});
|
||||
|
||||
describe('buildGrepArgs', () => {
|
||||
describe('ripgrep (rg)', () => {
|
||||
it('should build basic rg args for files_with_matches mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
pattern: 'test',
|
||||
output_mode: 'files_with_matches',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-l');
|
||||
expect(args).toContain('test');
|
||||
expect(args).toContain('--glob');
|
||||
expect(args).toContain('!**/node_modules/**');
|
||||
expect(args).toContain('!**/.git/**');
|
||||
});
|
||||
|
||||
it('should build rg args with case insensitive flag', () => {
|
||||
const params: GrepContentParams = {
|
||||
'-i': true,
|
||||
'pattern': 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-i');
|
||||
});
|
||||
|
||||
it('should build rg args with line numbers', () => {
|
||||
const params: GrepContentParams = {
|
||||
'-n': true,
|
||||
'pattern': 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-n');
|
||||
});
|
||||
|
||||
it('should build rg args with context lines', () => {
|
||||
const params: GrepContentParams = {
|
||||
'-A': 3,
|
||||
'-B': 2,
|
||||
'-C': 1,
|
||||
'pattern': 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-A');
|
||||
expect(args).toContain('3');
|
||||
expect(args).toContain('-B');
|
||||
expect(args).toContain('2');
|
||||
expect(args).toContain('-C');
|
||||
expect(args).toContain('1');
|
||||
});
|
||||
|
||||
it('should build rg args with multiline flag', () => {
|
||||
const params: GrepContentParams = {
|
||||
multiline: true,
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-U');
|
||||
});
|
||||
|
||||
it('should build rg args with glob filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
glob: '*.ts',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-g');
|
||||
expect(args).toContain('*.ts');
|
||||
});
|
||||
|
||||
it('should build rg args with type filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
pattern: 'test',
|
||||
type: 'ts',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-t');
|
||||
expect(args).toContain('ts');
|
||||
});
|
||||
|
||||
it('should build rg args for count mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'count',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-c');
|
||||
});
|
||||
|
||||
it('should build rg args for content mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'content',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).not.toContain('-l');
|
||||
expect(args).not.toContain('-c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('silver searcher (ag)', () => {
|
||||
it('should build basic ag args for files_with_matches mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'files_with_matches',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('ag', params);
|
||||
|
||||
expect(args).toContain('-l');
|
||||
expect(args).toContain('--ignore-dir');
|
||||
expect(args).toContain('node_modules');
|
||||
});
|
||||
|
||||
it('should build ag args with glob filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
glob: '*.tsx',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('ag', params);
|
||||
|
||||
expect(args).toContain('-G');
|
||||
expect(args).toContain('*.tsx');
|
||||
});
|
||||
|
||||
it('should build ag args for count mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'count',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('ag', params);
|
||||
|
||||
expect(args).toContain('-c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('grep', () => {
|
||||
it('should build basic grep args for files_with_matches mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'files_with_matches',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('grep', params);
|
||||
|
||||
expect(args).toContain('-r');
|
||||
expect(args).toContain('-l');
|
||||
expect(args).toContain('-E');
|
||||
expect(args).toContain('--exclude-dir');
|
||||
expect(args).toContain('node_modules');
|
||||
});
|
||||
|
||||
it('should build grep args with include filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
glob: '*.js',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('grep', params);
|
||||
|
||||
expect(args).toContain('--include');
|
||||
expect(args).toContain('*.js');
|
||||
});
|
||||
|
||||
it('should build grep args with type filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
pattern: 'test',
|
||||
type: 'py',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('grep', params);
|
||||
|
||||
expect(args).toContain('--include');
|
||||
expect(args).toContain('*.py');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultIgnorePatterns', () => {
|
||||
it('should return default ignore patterns', () => {
|
||||
const patterns = contentSearch.testGetDefaultIgnorePatterns();
|
||||
|
||||
expect(patterns).toContain('**/node_modules/**');
|
||||
expect(patterns).toContain('**/.git/**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkToolAvailable', () => {
|
||||
it('should return true for nodejs', async () => {
|
||||
const available = await contentSearch.checkToolAvailable('nodejs');
|
||||
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other tools', async () => {
|
||||
const available = await contentSearch.checkToolAvailable('rg');
|
||||
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToolDetectorManager', () => {
|
||||
it('should set the tool detector manager', () => {
|
||||
const mockManager = {} as any;
|
||||
|
||||
contentSearch.setToolDetectorManager(mockManager);
|
||||
|
||||
expect((contentSearch as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as os from 'node:os';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LinuxContentSearchImpl } from '../impl/linux';
|
||||
import { MacOSContentSearchImpl } from '../impl/macOS';
|
||||
import { WindowsContentSearchImpl } from '../impl/windows';
|
||||
import { createContentSearchImpl } from '../index';
|
||||
|
||||
// Mock os module before imports
|
||||
vi.mock('node:os', () => ({
|
||||
platform: vi.fn().mockReturnValue('linux'),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('createContentSearchImpl', () => {
|
||||
it('should create MacOSContentSearchImpl on darwin', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(MacOSContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should create WindowsContentSearchImpl on win32', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(WindowsContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxContentSearchImpl on linux', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxContentSearchImpl on unknown platform', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('freebsd' as any);
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should pass toolDetectorManager to implementation', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const mockManager = {} as any;
|
||||
|
||||
const impl = createContentSearchImpl(mockManager);
|
||||
|
||||
expect((impl as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
258
apps/desktop/src/main/modules/contentSearch/base.ts
Normal file
258
apps/desktop/src/main/modules/contentSearch/base.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import fg from 'fast-glob';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:base');
|
||||
|
||||
/**
|
||||
* Content search tool type
|
||||
*/
|
||||
export type ContentSearchTool = 'rg' | 'ag' | 'grep' | 'nodejs';
|
||||
|
||||
/**
|
||||
* Content Search Service Implementation Abstract Class
|
||||
* Defines the interface that different platform content search implementations need to implement
|
||||
*/
|
||||
export abstract class BaseContentSearch {
|
||||
protected toolDetectorManager?: ToolDetectorManager;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
this.toolDetectorManager = toolDetectorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tool detector manager
|
||||
* @param manager ToolDetectorManager instance
|
||||
*/
|
||||
setToolDetectorManager(manager: ToolDetectorManager): void {
|
||||
this.toolDetectorManager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
* @param params Grep parameters
|
||||
* @returns Promise of grep result
|
||||
*/
|
||||
abstract grep(params: GrepContentParams): Promise<GrepContentResult>;
|
||||
|
||||
/**
|
||||
* Check if a specific tool is available
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
abstract checkToolAvailable(tool: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Build command-line arguments for grep tools
|
||||
*/
|
||||
protected buildGrepArgs(tool: 'rg' | 'ag' | 'grep', params: GrepContentParams): string[] {
|
||||
const { pattern, output_mode = 'files_with_matches' } = params;
|
||||
const args: string[] = [];
|
||||
|
||||
switch (tool) {
|
||||
case 'rg': {
|
||||
// ripgrep arguments
|
||||
if (params['-i']) args.push('-i');
|
||||
if (params['-n']) args.push('-n');
|
||||
if (params['-A']) args.push('-A', String(params['-A']));
|
||||
if (params['-B']) args.push('-B', String(params['-B']));
|
||||
if (params['-C']) args.push('-C', String(params['-C']));
|
||||
if (params.multiline) args.push('-U');
|
||||
if (params.glob) args.push('-g', params.glob);
|
||||
if (params.type) args.push('-t', params.type);
|
||||
|
||||
// Output mode
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
args.push('-l');
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
args.push('-c');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore common directories (use **/ prefix to match nested paths)
|
||||
args.push('--glob', '!**/node_modules/**', '--glob', '!**/.git/**', pattern, '.');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ag': {
|
||||
// Silver Searcher arguments
|
||||
if (params['-i']) args.push('-i');
|
||||
if (params['-A']) args.push('-A', String(params['-A']));
|
||||
if (params['-B']) args.push('-B', String(params['-B']));
|
||||
if (params['-C']) args.push('-C', String(params['-C']));
|
||||
if (params.glob) args.push('-G', params.glob);
|
||||
|
||||
// Output mode
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
args.push('-l');
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
args.push('-c');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
args.push('--ignore-dir', 'node_modules', '--ignore-dir', '.git', pattern, '.');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'grep': {
|
||||
// GNU grep arguments
|
||||
args.push('-r'); // recursive
|
||||
if (params['-i']) args.push('-i');
|
||||
if (params['-n']) args.push('-n');
|
||||
if (params['-A']) args.push('-A', String(params['-A']));
|
||||
if (params['-B']) args.push('-B', String(params['-B']));
|
||||
if (params['-C']) args.push('-C', String(params['-C']));
|
||||
if (params.glob) args.push('--include', params.glob);
|
||||
if (params.type) args.push('--include', `*.${params.type}`);
|
||||
|
||||
// Output mode
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
args.push('-l');
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
args.push('-c');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
args.push('--exclude-dir', 'node_modules', '--exclude-dir', '.git', '-E', pattern, '.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using Node.js native implementation (fallback)
|
||||
*/
|
||||
protected async grepWithNodejs(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent:nodejs]`;
|
||||
|
||||
const flags = `${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`;
|
||||
const regex = new RegExp(pattern, flags);
|
||||
|
||||
// Determine files to search
|
||||
let filesToSearch: string[] = [];
|
||||
const stats = await stat(searchPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
filesToSearch = [searchPath];
|
||||
} else {
|
||||
// Use glob pattern if provided, otherwise search all files
|
||||
let globPattern = params.glob || '**/*';
|
||||
if (params.glob && !params.glob.includes('/') && !params.glob.startsWith('**')) {
|
||||
globPattern = `**/${params.glob}`;
|
||||
}
|
||||
|
||||
filesToSearch = await fg(globPattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
ignore: this.getDefaultIgnorePatterns(),
|
||||
});
|
||||
|
||||
// Filter by type if provided
|
||||
if (params.type) {
|
||||
const ext = `.${params.type}`;
|
||||
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
|
||||
|
||||
const matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (const filePath of filesToSearch) {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) continue;
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
if (regex.test(content)) {
|
||||
matches.push(filePath);
|
||||
totalMatches++;
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
const matchedLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const contextBefore = params['-B'] || params['-C'] || 0;
|
||||
const contextAfter = params['-A'] || params['-C'] || 0;
|
||||
|
||||
const startLine = Math.max(0, i - contextBefore);
|
||||
const endLine = Math.min(lines.length - 1, i + contextAfter);
|
||||
|
||||
for (let j = startLine; j <= endLine; j++) {
|
||||
const lineNum = params['-n'] ? `${j + 1}:` : '';
|
||||
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
|
||||
}
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
matches.push(...matchedLines);
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const globalRegex = new RegExp(pattern, `g${flags}`);
|
||||
const fileMatches = (content.match(globalRegex) || []).length;
|
||||
if (fileMatches > 0) {
|
||||
matches.push(`${filePath}:${fileMatches}`);
|
||||
totalMatches += fileMatches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: 'nodejs',
|
||||
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default ignore patterns
|
||||
* Can be overridden by subclasses for platform-specific patterns
|
||||
*/
|
||||
protected getDefaultIgnorePatterns(): string[] {
|
||||
return ['**/node_modules/**', '**/.git/**'];
|
||||
}
|
||||
}
|
||||
24
apps/desktop/src/main/modules/contentSearch/impl/linux.ts
Normal file
24
apps/desktop/src/main/modules/contentSearch/impl/linux.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { UnixContentSearch } from './unix';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:linux');
|
||||
|
||||
/**
|
||||
* Linux content search implementation
|
||||
* Inherits from UnixContentSearch with Linux-specific optimizations
|
||||
*/
|
||||
export class LinuxContentSearchImpl extends UnixContentSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
logger.debug('LinuxContentSearchImpl initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Linux-specific ignore patterns
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [...super.getDefaultIgnorePatterns(), '**/.cache/**', '**/snap/**'];
|
||||
}
|
||||
}
|
||||
30
apps/desktop/src/main/modules/contentSearch/impl/macOS.ts
Normal file
30
apps/desktop/src/main/modules/contentSearch/impl/macOS.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { UnixContentSearch } from './unix';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:macOS');
|
||||
|
||||
/**
|
||||
* macOS content search implementation
|
||||
* Inherits from UnixContentSearch with macOS-specific optimizations
|
||||
*/
|
||||
export class MacOSContentSearchImpl extends UnixContentSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
logger.debug('MacOSContentSearchImpl initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get macOS-specific ignore patterns
|
||||
* Includes Library/Caches which is specific to macOS
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [
|
||||
...super.getDefaultIgnorePatterns(),
|
||||
'**/Library/Caches/**',
|
||||
'**/.cache/**',
|
||||
'**/snap/**',
|
||||
];
|
||||
}
|
||||
}
|
||||
291
apps/desktop/src/main/modules/contentSearch/impl/unix.ts
Normal file
291
apps/desktop/src/main/modules/contentSearch/impl/unix.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseContentSearch } from '../base';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:unix');
|
||||
|
||||
/**
|
||||
* Unix content search tool type
|
||||
* Priority: rg (1) > ag (2) > grep (3)
|
||||
*/
|
||||
export type UnixContentSearchTool = 'rg' | 'ag' | 'grep' | 'nodejs';
|
||||
|
||||
/**
|
||||
* Unix content search base class
|
||||
* Provides common search implementations for macOS and Linux
|
||||
*/
|
||||
export abstract class UnixContentSearch extends BaseContentSearch {
|
||||
/**
|
||||
* Current tool being used
|
||||
*/
|
||||
protected currentTool: UnixContentSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'which' command
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('which', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available Unix tool based on priority
|
||||
* Priority: rg > ag > grep > nodejs
|
||||
* @returns The best available tool
|
||||
*/
|
||||
protected async determineBestUnixTool(): Promise<UnixContentSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('content-search');
|
||||
if (bestTool && ['rg', 'ag', 'grep'].includes(bestTool)) {
|
||||
return bestTool as UnixContentSearchTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('rg')) {
|
||||
return 'rg';
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('ag')) {
|
||||
return 'ag';
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('grep')) {
|
||||
return 'grep';
|
||||
}
|
||||
|
||||
return 'nodejs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
* @param currentTool Current tool that failed
|
||||
* @returns Next tool to try
|
||||
*/
|
||||
protected async fallbackToNextTool(
|
||||
currentTool: UnixContentSearchTool,
|
||||
): Promise<UnixContentSearchTool> {
|
||||
const priority: UnixContentSearchTool[] = ['rg', 'ag', 'grep', 'nodejs'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'nodejs') {
|
||||
return 'nodejs'; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'nodejs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
*/
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { tool: preferredTool } = params;
|
||||
const logPrefix = `[grepContent: ${params.pattern}]`;
|
||||
|
||||
try {
|
||||
// If user specified a grep tool, try to use it
|
||||
if (preferredTool && ['rg', 'ag', 'grep'].includes(preferredTool)) {
|
||||
logger.debug(`${logPrefix} Using preferred tool: ${preferredTool}`);
|
||||
return this.grepWithTool(preferredTool as UnixContentSearchTool, params);
|
||||
}
|
||||
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestUnixTool();
|
||||
logger.info(`Using content search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
engine: this.currentTool || 'nodejs',
|
||||
error: (error as Error).message,
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
protected async grepWithTool(
|
||||
tool: UnixContentSearchTool,
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
switch (tool) {
|
||||
case 'rg': {
|
||||
return this.grepWithRipgrep(params);
|
||||
}
|
||||
case 'ag': {
|
||||
return this.grepWithAg(params);
|
||||
}
|
||||
case 'grep': {
|
||||
return this.grepWithGrep(params);
|
||||
}
|
||||
default: {
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using ripgrep (rg)
|
||||
*/
|
||||
protected async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithExternalTool('rg', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using The Silver Searcher (ag)
|
||||
*/
|
||||
protected async grepWithAg(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithExternalTool('ag', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using GNU grep
|
||||
*/
|
||||
protected async grepWithGrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithExternalTool('grep', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using external tools (rg, ag, grep)
|
||||
*/
|
||||
protected async grepWithExternalTool(
|
||||
tool: 'rg' | 'ag' | 'grep',
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
|
||||
const logPrefix = `[grepContent:${tool}]`;
|
||||
|
||||
try {
|
||||
const args = this.buildGrepArgs(tool, params);
|
||||
logger.debug(`${logPrefix} Executing: ${tool} ${args.join(' ')}`);
|
||||
|
||||
const { stdout, stderr, exitCode } = await execa(tool, args, {
|
||||
cwd: searchPath,
|
||||
reject: false, // Don't throw on non-zero exit code
|
||||
});
|
||||
|
||||
// ripgrep returns 1 when no matches found, which is not an error
|
||||
if (exitCode !== 0 && exitCode !== 1 && stderr) {
|
||||
logger.warn(`${logPrefix} Tool exited with code ${exitCode}: ${stderr}`);
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
let matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
matches = lines;
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
matches = lines;
|
||||
// When context lines are used, lines.length includes context lines
|
||||
// We need to get the actual match count separately
|
||||
const hasContext = params['-A'] || params['-B'] || params['-C'];
|
||||
if (hasContext) {
|
||||
// Run a separate count query to get accurate match count
|
||||
totalMatches = await this.getActualMatchCount(tool, params);
|
||||
} else {
|
||||
totalMatches = lines.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
// Parse count output (file:count format)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
totalMatches += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
matches = lines;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply head_limit
|
||||
if (params.head_limit && matches.length > params.head_limit) {
|
||||
matches = matches.slice(0, params.head_limit);
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: tool,
|
||||
matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`${logPrefix} External tool failed, falling back to next tool:`, error);
|
||||
// Fallback to next tool
|
||||
this.currentTool = await this.fallbackToNextTool(tool as UnixContentSearchTool);
|
||||
logger.info(`Falling back to: ${this.currentTool}`);
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actual match count for content mode when context lines are used
|
||||
*/
|
||||
protected async getActualMatchCount(
|
||||
tool: 'rg' | 'ag' | 'grep',
|
||||
params: GrepContentParams,
|
||||
): Promise<number> {
|
||||
const countParams = { ...params, '-A': undefined, '-B': undefined, '-C': undefined };
|
||||
const args = this.buildGrepArgs(tool, {
|
||||
...countParams,
|
||||
output_mode: 'count',
|
||||
} as GrepContentParams);
|
||||
|
||||
try {
|
||||
const { stdout } = await execa(tool, args, {
|
||||
cwd: params.path || process.cwd(),
|
||||
reject: false,
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
total += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
365
apps/desktop/src/main/modules/contentSearch/impl/windows.ts
Normal file
365
apps/desktop/src/main/modules/contentSearch/impl/windows.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseContentSearch } from '../base';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:windows');
|
||||
|
||||
/**
|
||||
* Windows content search tool type
|
||||
* Priority: rg > findstr/powershell > nodejs
|
||||
*/
|
||||
type WindowsContentSearchTool = 'rg' | 'findstr' | 'nodejs';
|
||||
|
||||
/**
|
||||
* Windows content search implementation
|
||||
* Uses rg > findstr > nodejs fallback strategy
|
||||
*/
|
||||
export class WindowsContentSearchImpl extends BaseContentSearch {
|
||||
/**
|
||||
* Current tool being used
|
||||
*/
|
||||
private currentTool: WindowsContentSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
logger.debug('WindowsContentSearchImpl initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'where' command (Windows equivalent of 'which')
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('where', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available tool based on priority
|
||||
* Priority: rg > findstr > nodejs
|
||||
*/
|
||||
private async determineBestTool(): Promise<WindowsContentSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('content-search');
|
||||
if (bestTool === 'rg') {
|
||||
return 'rg';
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('rg')) {
|
||||
return 'rg';
|
||||
}
|
||||
|
||||
// findstr is always available on Windows
|
||||
return 'findstr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
*/
|
||||
private async fallbackToNextTool(
|
||||
currentTool: WindowsContentSearchTool,
|
||||
): Promise<WindowsContentSearchTool> {
|
||||
const priority: WindowsContentSearchTool[] = ['rg', 'findstr', 'nodejs'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'nodejs' || nextTool === 'findstr') {
|
||||
return nextTool; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'nodejs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
*/
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { tool: preferredTool } = params;
|
||||
const logPrefix = `[grepContent: ${params.pattern}]`;
|
||||
|
||||
try {
|
||||
// If user specified ripgrep, try to use it
|
||||
if (preferredTool === 'rg') {
|
||||
if (await this.checkToolAvailable('rg')) {
|
||||
logger.debug(`${logPrefix} Using preferred tool: rg`);
|
||||
return this.grepWithRipgrep(params);
|
||||
}
|
||||
logger.warn(`${logPrefix} ripgrep (rg) not available, falling back to other tools`);
|
||||
}
|
||||
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestTool();
|
||||
logger.info(`Using content search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
engine: this.currentTool || 'nodejs',
|
||||
error: (error as Error).message,
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
private async grepWithTool(
|
||||
tool: WindowsContentSearchTool,
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
switch (tool) {
|
||||
case 'rg': {
|
||||
return this.grepWithRipgrep(params);
|
||||
}
|
||||
case 'findstr': {
|
||||
return this.grepWithFindstr(params);
|
||||
}
|
||||
default: {
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using ripgrep (rg) - cross-platform
|
||||
*/
|
||||
private async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
|
||||
const logPrefix = `[grepContent:rg]`;
|
||||
|
||||
try {
|
||||
const args = this.buildGrepArgs('rg', params);
|
||||
logger.debug(`${logPrefix} Executing: rg ${args.join(' ')}`);
|
||||
|
||||
const { stdout, stderr, exitCode } = await execa('rg', args, {
|
||||
cwd: searchPath,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
// ripgrep returns 1 when no matches found, which is not an error
|
||||
if (exitCode !== 0 && exitCode !== 1 && stderr) {
|
||||
logger.warn(`${logPrefix} rg exited with code ${exitCode}: ${stderr}`);
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
let matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
matches = lines;
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
matches = lines;
|
||||
const hasContext = params['-A'] || params['-B'] || params['-C'];
|
||||
if (hasContext) {
|
||||
totalMatches = await this.getActualMatchCount(params);
|
||||
} else {
|
||||
totalMatches = lines.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
for (const line of lines) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
totalMatches += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
matches = lines;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply head_limit
|
||||
if (params.head_limit && matches.length > params.head_limit) {
|
||||
matches = matches.slice(0, params.head_limit);
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: 'rg',
|
||||
matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`${logPrefix} rg failed, falling back to findstr:`, error);
|
||||
this.currentTool = await this.fallbackToNextTool('rg');
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actual match count using ripgrep
|
||||
*/
|
||||
private async getActualMatchCount(params: GrepContentParams): Promise<number> {
|
||||
const countParams = { ...params, '-A': undefined, '-B': undefined, '-C': undefined };
|
||||
const args = this.buildGrepArgs('rg', {
|
||||
...countParams,
|
||||
output_mode: 'count',
|
||||
} as GrepContentParams);
|
||||
|
||||
try {
|
||||
const { stdout } = await execa('rg', args, {
|
||||
cwd: params.path || process.cwd(),
|
||||
reject: false,
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
total += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using Windows findstr command
|
||||
* Note: findstr has limited functionality compared to ripgrep
|
||||
*/
|
||||
private async grepWithFindstr(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent:findstr]`;
|
||||
|
||||
try {
|
||||
const args: string[] = ['/S']; // Recursive search
|
||||
|
||||
if (params['-i']) {
|
||||
args.push('/I'); // Case insensitive
|
||||
}
|
||||
|
||||
if (params['-n']) {
|
||||
args.push('/N'); // Line numbers
|
||||
}
|
||||
|
||||
// Pattern
|
||||
args.push('/R'); // Regex
|
||||
args.push(`"${pattern}"`);
|
||||
|
||||
// Search files pattern
|
||||
const filePattern = params.glob || params.type ? `*.${params.type || '*'}` : '*.*';
|
||||
args.push(filePattern);
|
||||
|
||||
logger.debug(`${logPrefix} Executing: findstr ${args.join(' ')}`);
|
||||
|
||||
const { stdout, exitCode } = await execa('cmd', ['/c', `findstr ${args.join(' ')}`], {
|
||||
cwd: searchPath,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
// findstr returns 1 when no matches found
|
||||
if (exitCode !== 0 && exitCode !== 1) {
|
||||
logger.warn(`${logPrefix} findstr exited with code ${exitCode}`);
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\r\n').filter(Boolean);
|
||||
let matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
// Extract unique file names from output
|
||||
const files = new Set<string>();
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^:]+):/);
|
||||
if (match) {
|
||||
files.add(match[1]);
|
||||
}
|
||||
}
|
||||
matches = Array.from(files);
|
||||
totalMatches = matches.length;
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
matches = lines;
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
// Count matches per file
|
||||
const fileCounts = new Map<string, number>();
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^:]+):/);
|
||||
if (match) {
|
||||
fileCounts.set(match[1], (fileCounts.get(match[1]) || 0) + 1);
|
||||
}
|
||||
}
|
||||
matches = Array.from(fileCounts.entries()).map(([file, count]) => `${file}:${count}`);
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply head_limit
|
||||
if (params.head_limit && matches.length > params.head_limit) {
|
||||
matches = matches.slice(0, params.head_limit);
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: 'findstr',
|
||||
matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`${logPrefix} findstr failed, falling back to Node.js:`, error);
|
||||
this.currentTool = 'nodejs';
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Windows-specific ignore patterns
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [
|
||||
...super.getDefaultIgnorePatterns(),
|
||||
'**/AppData/Local/Temp/**',
|
||||
'**/AppData/Local/Microsoft/**',
|
||||
'**/$Recycle.Bin/**',
|
||||
];
|
||||
}
|
||||
}
|
||||
38
apps/desktop/src/main/modules/contentSearch/index.ts
Normal file
38
apps/desktop/src/main/modules/contentSearch/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
import { BaseContentSearch } from './base';
|
||||
import { LinuxContentSearchImpl } from './impl/linux';
|
||||
import { MacOSContentSearchImpl } from './impl/macOS';
|
||||
import { WindowsContentSearchImpl } from './impl/windows';
|
||||
|
||||
export { BaseContentSearch } from './base';
|
||||
export { LinuxContentSearchImpl } from './impl/linux';
|
||||
export { MacOSContentSearchImpl } from './impl/macOS';
|
||||
export { UnixContentSearch } from './impl/unix';
|
||||
export { WindowsContentSearchImpl } from './impl/windows';
|
||||
|
||||
/**
|
||||
* Create platform-specific content search implementation
|
||||
* @param toolDetectorManager Optional tool detector manager
|
||||
* @returns Platform-specific content search implementation
|
||||
*/
|
||||
export function createContentSearchImpl(
|
||||
toolDetectorManager?: ToolDetectorManager,
|
||||
): BaseContentSearch {
|
||||
const platform = os.platform();
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
return new MacOSContentSearchImpl(toolDetectorManager);
|
||||
}
|
||||
case 'win32': {
|
||||
return new WindowsContentSearchImpl(toolDetectorManager);
|
||||
}
|
||||
default: {
|
||||
// Linux and other Unix-like systems
|
||||
return new LinuxContentSearchImpl(toolDetectorManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
324
apps/desktop/src/main/modules/fileSearch/__tests__/base.test.ts
Normal file
324
apps/desktop/src/main/modules/fileSearch/__tests__/base.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BaseFileSearch } from '../base';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
atime: new Date('2024-01-03'),
|
||||
birthtime: new Date('2024-01-01'),
|
||||
isDirectory: () => false,
|
||||
mtime: new Date('2024-01-02'),
|
||||
size: 1024,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Concrete implementation for testing
|
||||
*/
|
||||
class TestFileSearch extends BaseFileSearch {
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
const files = ['/test/file.ts'];
|
||||
return this.processFilePaths(files, options, 'test-engine');
|
||||
}
|
||||
|
||||
async glob(_params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return {
|
||||
engine: 'test-engine',
|
||||
files: [],
|
||||
success: true,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testDetermineContentType(ext: string): string {
|
||||
return this.determineContentType(ext);
|
||||
}
|
||||
|
||||
public testEscapeGlobPattern(pattern: string): string {
|
||||
return this.escapeGlobPattern(pattern);
|
||||
}
|
||||
|
||||
public testProcessFilePaths(
|
||||
filePaths: string[],
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
return this.processFilePaths(filePaths, options, engine);
|
||||
}
|
||||
|
||||
public testSortResults(
|
||||
results: FileResult[],
|
||||
sortBy?: 'name' | 'date' | 'size',
|
||||
direction?: 'asc' | 'desc',
|
||||
): FileResult[] {
|
||||
return this.sortResults(results, sortBy, direction);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseFileSearch', () => {
|
||||
let fileSearch: TestFileSearch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fileSearch = new TestFileSearch();
|
||||
});
|
||||
|
||||
describe('determineContentType', () => {
|
||||
it('should return archive for zip extension', () => {
|
||||
expect(fileSearch.testDetermineContentType('zip')).toBe('archive');
|
||||
expect(fileSearch.testDetermineContentType('tar')).toBe('archive');
|
||||
expect(fileSearch.testDetermineContentType('gz')).toBe('archive');
|
||||
});
|
||||
|
||||
it('should return audio for audio extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('mp3')).toBe('audio');
|
||||
expect(fileSearch.testDetermineContentType('wav')).toBe('audio');
|
||||
expect(fileSearch.testDetermineContentType('ogg')).toBe('audio');
|
||||
});
|
||||
|
||||
it('should return video for video extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('mp4')).toBe('video');
|
||||
expect(fileSearch.testDetermineContentType('avi')).toBe('video');
|
||||
expect(fileSearch.testDetermineContentType('mkv')).toBe('video');
|
||||
});
|
||||
|
||||
it('should return image for image extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('png')).toBe('image');
|
||||
expect(fileSearch.testDetermineContentType('jpg')).toBe('image');
|
||||
expect(fileSearch.testDetermineContentType('gif')).toBe('image');
|
||||
});
|
||||
|
||||
it('should return document for document extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('pdf')).toBe('document');
|
||||
expect(fileSearch.testDetermineContentType('doc')).toBe('document');
|
||||
expect(fileSearch.testDetermineContentType('docx')).toBe('document');
|
||||
});
|
||||
|
||||
it('should return code for code extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('ts')).toBe('code');
|
||||
expect(fileSearch.testDetermineContentType('js')).toBe('code');
|
||||
expect(fileSearch.testDetermineContentType('py')).toBe('code');
|
||||
});
|
||||
|
||||
it('should return unknown for unrecognized extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('xyz')).toBe('unknown');
|
||||
expect(fileSearch.testDetermineContentType('foo')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(fileSearch.testDetermineContentType('PNG')).toBe('image');
|
||||
expect(fileSearch.testDetermineContentType('MP3')).toBe('audio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeGlobPattern', () => {
|
||||
it('should escape special glob characters', () => {
|
||||
// The function escapes . as well since it's a regex special character
|
||||
expect(fileSearch.testEscapeGlobPattern('file*.ts')).toBe('file\\*\\.ts');
|
||||
expect(fileSearch.testEscapeGlobPattern('file?.ts')).toBe('file\\?\\.ts');
|
||||
expect(fileSearch.testEscapeGlobPattern('file[0-9].ts')).toBe('file\\[0-9\\]\\.ts');
|
||||
});
|
||||
|
||||
it('should escape parentheses', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('file(1).ts')).toBe('file\\(1\\)\\.ts');
|
||||
});
|
||||
|
||||
it('should escape curly braces', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('file{a,b}.ts')).toBe('file\\{a,b\\}\\.ts');
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('path\\file.ts')).toBe('path\\\\file\\.ts');
|
||||
});
|
||||
|
||||
it('should escape dots', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('normal-file.ts')).toBe('normal-file\\.ts');
|
||||
});
|
||||
|
||||
it('should return unchanged string if no special characters', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('normal-file-ts')).toBe('normal-file-ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processFilePaths', () => {
|
||||
it('should process file paths and return FileResult array', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options, 'fd');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].path).toBe('/test/file.ts');
|
||||
expect(results[0].name).toBe('file.ts');
|
||||
expect(results[0].type).toBe('ts');
|
||||
expect(results[0].engine).toBe('fd');
|
||||
});
|
||||
|
||||
it('should include engine in results', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options, 'mdfind');
|
||||
|
||||
expect(results[0].engine).toBe('mdfind');
|
||||
});
|
||||
|
||||
it('should handle undefined engine', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options);
|
||||
|
||||
expect(results[0].engine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should determine content type from extension', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options);
|
||||
|
||||
expect(results[0].contentType).toBe('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortResults', () => {
|
||||
const createMockResult = (name: string, size: number, modifiedTime: Date): FileResult => ({
|
||||
contentType: 'code',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
isDirectory: false,
|
||||
lastAccessTime: new Date('2024-01-03'),
|
||||
metadata: {},
|
||||
modifiedTime,
|
||||
name,
|
||||
path: `/test/${name}`,
|
||||
size,
|
||||
type: 'ts',
|
||||
});
|
||||
|
||||
it('should sort by name ascending', () => {
|
||||
const results = [
|
||||
createMockResult('c.ts', 100, new Date('2024-01-01')),
|
||||
createMockResult('a.ts', 200, new Date('2024-01-02')),
|
||||
createMockResult('b.ts', 150, new Date('2024-01-03')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'name', 'asc');
|
||||
|
||||
expect(sorted[0].name).toBe('a.ts');
|
||||
expect(sorted[1].name).toBe('b.ts');
|
||||
expect(sorted[2].name).toBe('c.ts');
|
||||
});
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const results = [
|
||||
createMockResult('a.ts', 100, new Date('2024-01-01')),
|
||||
createMockResult('c.ts', 200, new Date('2024-01-02')),
|
||||
createMockResult('b.ts', 150, new Date('2024-01-03')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'name', 'desc');
|
||||
|
||||
expect(sorted[0].name).toBe('c.ts');
|
||||
expect(sorted[1].name).toBe('b.ts');
|
||||
expect(sorted[2].name).toBe('a.ts');
|
||||
});
|
||||
|
||||
it('should sort by size ascending', () => {
|
||||
const results = [
|
||||
createMockResult('a.ts', 300, new Date('2024-01-01')),
|
||||
createMockResult('b.ts', 100, new Date('2024-01-02')),
|
||||
createMockResult('c.ts', 200, new Date('2024-01-03')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'size', 'asc');
|
||||
|
||||
expect(sorted[0].size).toBe(100);
|
||||
expect(sorted[1].size).toBe(200);
|
||||
expect(sorted[2].size).toBe(300);
|
||||
});
|
||||
|
||||
it('should sort by date ascending', () => {
|
||||
const results = [
|
||||
createMockResult('a.ts', 100, new Date('2024-03-01')),
|
||||
createMockResult('b.ts', 200, new Date('2024-01-01')),
|
||||
createMockResult('c.ts', 150, new Date('2024-02-01')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'date', 'asc');
|
||||
|
||||
expect(sorted[0].name).toBe('b.ts');
|
||||
expect(sorted[1].name).toBe('c.ts');
|
||||
expect(sorted[2].name).toBe('a.ts');
|
||||
});
|
||||
|
||||
it('should return original array if no sortBy specified', () => {
|
||||
const results = [
|
||||
createMockResult('c.ts', 100, new Date('2024-01-01')),
|
||||
createMockResult('a.ts', 200, new Date('2024-01-02')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results);
|
||||
|
||||
expect(sorted[0].name).toBe('c.ts');
|
||||
expect(sorted[1].name).toBe('a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToolDetectorManager', () => {
|
||||
it('should set the tool detector manager', () => {
|
||||
const mockManager = {} as any;
|
||||
|
||||
fileSearch.setToolDetectorManager(mockManager);
|
||||
|
||||
expect((fileSearch as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return results with engine', async () => {
|
||||
const results = await fileSearch.search({ keywords: 'test' });
|
||||
|
||||
expect(results[0].engine).toBe('test-engine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('glob', () => {
|
||||
it('should return GlobFilesResult with engine', async () => {
|
||||
const result = await fileSearch.glob({ pattern: '*.ts' });
|
||||
|
||||
expect(result.engine).toBe('test-engine');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSearchServiceStatus', () => {
|
||||
it('should return true', async () => {
|
||||
const status = await fileSearch.checkSearchServiceStatus();
|
||||
|
||||
expect(status).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSearchIndex', () => {
|
||||
it('should return true', async () => {
|
||||
const result = await fileSearch.updateSearchIndex();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { platform } from 'node:os';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LinuxSearchServiceImpl } from '../impl/linux';
|
||||
import { MacOSSearchServiceImpl } from '../impl/macOS';
|
||||
import { WindowsSearchServiceImpl } from '../impl/windows';
|
||||
import { createFileSearchModule } from '../index';
|
||||
|
||||
// Mock os module before imports
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: vi.fn().mockReturnValue('/home/user'),
|
||||
platform: vi.fn().mockReturnValue('linux'),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('createFileSearchModule', () => {
|
||||
it('should create MacOSSearchServiceImpl on darwin', () => {
|
||||
vi.mocked(platform).mockReturnValue('darwin');
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(MacOSSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should create WindowsSearchServiceImpl on win32', () => {
|
||||
vi.mocked(platform).mockReturnValue('win32');
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(WindowsSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxSearchServiceImpl on linux', () => {
|
||||
vi.mocked(platform).mockReturnValue('linux');
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxSearchServiceImpl on unknown platform', () => {
|
||||
vi.mocked(platform).mockReturnValue('freebsd' as any);
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should pass toolDetectorManager to implementation', () => {
|
||||
vi.mocked(platform).mockReturnValue('linux');
|
||||
const mockManager = {} as any;
|
||||
|
||||
const impl = createFileSearchModule(mockManager);
|
||||
|
||||
expect((impl as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
216
apps/desktop/src/main/modules/fileSearch/base.ts
Normal file
216
apps/desktop/src/main/modules/fileSearch/base.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
import { FileResult, SearchOptions } from './types';
|
||||
|
||||
/**
|
||||
* Content type mapping for common file extensions
|
||||
*/
|
||||
const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
// Archive
|
||||
'7z': 'archive',
|
||||
'gz': 'archive',
|
||||
'rar': 'archive',
|
||||
'tar': 'archive',
|
||||
'zip': 'archive',
|
||||
// Audio
|
||||
'aac': 'audio',
|
||||
'mp3': 'audio',
|
||||
'ogg': 'audio',
|
||||
'wav': 'audio',
|
||||
// Video
|
||||
'avi': 'video',
|
||||
'mkv': 'video',
|
||||
'mov': 'video',
|
||||
'mp4': 'video',
|
||||
// Image
|
||||
'gif': 'image',
|
||||
'heic': 'image',
|
||||
'ico': 'image',
|
||||
'jpeg': 'image',
|
||||
'jpg': 'image',
|
||||
'png': 'image',
|
||||
'svg': 'image',
|
||||
'webp': 'image',
|
||||
// Document
|
||||
'doc': 'document',
|
||||
'docx': 'document',
|
||||
'pdf': 'document',
|
||||
'rtf': 'text',
|
||||
'txt': 'text',
|
||||
// Spreadsheet
|
||||
'xls': 'spreadsheet',
|
||||
'xlsx': 'spreadsheet',
|
||||
// Presentation
|
||||
'ppt': 'presentation',
|
||||
'pptx': 'presentation',
|
||||
// Code
|
||||
'bat': 'code',
|
||||
'c': 'code',
|
||||
'cmd': 'code',
|
||||
'cpp': 'code',
|
||||
'cs': 'code',
|
||||
'css': 'code',
|
||||
'html': 'code',
|
||||
'java': 'code',
|
||||
'js': 'code',
|
||||
'json': 'code',
|
||||
'ps1': 'code',
|
||||
'py': 'code',
|
||||
'sh': 'code',
|
||||
'swift': 'code',
|
||||
'ts': 'code',
|
||||
'tsx': 'code',
|
||||
'vbs': 'code',
|
||||
// Application/Installer (platform-specific)
|
||||
'app': 'application',
|
||||
'deb': 'package',
|
||||
'dmg': 'disk-image',
|
||||
'exe': 'application',
|
||||
'iso': 'disk-image',
|
||||
'msi': 'installer',
|
||||
'rpm': 'package',
|
||||
};
|
||||
|
||||
/**
|
||||
* File Search Service Implementation Abstract Class
|
||||
* Defines the interface that different platform file search implementations need to implement
|
||||
*/
|
||||
export abstract class BaseFileSearch {
|
||||
protected toolDetectorManager?: ToolDetectorManager;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
this.toolDetectorManager = toolDetectorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tool detector manager
|
||||
* @param manager ToolDetectorManager instance
|
||||
*/
|
||||
setToolDetectorManager(manager: ToolDetectorManager): void {
|
||||
this.toolDetectorManager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine content type from file extension
|
||||
* @param extension File extension (without dot)
|
||||
* @returns Content type description
|
||||
*/
|
||||
protected determineContentType(extension: string): string {
|
||||
return CONTENT_TYPE_MAP[extension.toLowerCase()] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special glob characters in the search pattern
|
||||
* @param pattern The pattern to escape
|
||||
* @returns Escaped pattern safe for glob matching
|
||||
*/
|
||||
protected escapeGlobPattern(pattern: string): string {
|
||||
return pattern.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process file paths and return FileResult objects
|
||||
* @param filePaths Array of file path strings
|
||||
* @param options Search options
|
||||
* @param engine Optional search engine identifier
|
||||
* @returns Formatted file result list
|
||||
*/
|
||||
protected async processFilePaths(
|
||||
filePaths: string[],
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
const results: FileResult[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
||||
|
||||
results.push({
|
||||
contentType: this.determineContentType(ext),
|
||||
createdTime: stats.birthtime,
|
||||
engine,
|
||||
isDirectory: stats.isDirectory(),
|
||||
lastAccessTime: stats.atime,
|
||||
metadata: {},
|
||||
modifiedTime: stats.mtime,
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
type: ext,
|
||||
});
|
||||
} catch {
|
||||
// Skip files that can't be accessed
|
||||
}
|
||||
}
|
||||
|
||||
return this.sortResults(results, options.sortBy, options.sortDirection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort results based on options
|
||||
* @param results Result list
|
||||
* @param sortBy Sort field
|
||||
* @param direction Sort direction
|
||||
* @returns Sorted result list
|
||||
*/
|
||||
protected sortResults(
|
||||
results: FileResult[],
|
||||
sortBy?: 'name' | 'date' | 'size',
|
||||
direction: 'asc' | 'desc' = 'asc',
|
||||
): FileResult[] {
|
||||
if (!sortBy) return results;
|
||||
|
||||
return [...results].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name': {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
}
|
||||
case 'date': {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'size': {
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
abstract search(options: SearchOptions): Promise<FileResult[]>;
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
abstract glob(params: GlobFilesParams): Promise<GlobFilesResult>;
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available
|
||||
*/
|
||||
abstract checkSearchServiceStatus(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
abstract updateSearchIndex(path?: string): Promise<boolean>;
|
||||
}
|
||||
51
apps/desktop/src/main/modules/fileSearch/impl/linux.ts
Normal file
51
apps/desktop/src/main/modules/fileSearch/impl/linux.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
import { UnixFileSearch, UnixSearchTool } from './unix';
|
||||
|
||||
const logger = createLogger('module:FileSearch:linux');
|
||||
|
||||
/**
|
||||
* Linux file search implementation
|
||||
* Uses fd > find > fast-glob fallback strategy
|
||||
*/
|
||||
export class LinuxSearchServiceImpl extends UnixFileSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestUnixTool();
|
||||
logger.info(`Using file search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithUnixTool(this.currentTool as UnixSearchTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available (always true for Linux)
|
||||
*/
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
// At minimum, fast-glob is always available
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* Linux doesn't have a system-wide search index like Spotlight
|
||||
* @returns Promise indicating operation result (always false for Linux)
|
||||
*/
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
logger.warn('updateSearchIndex is not supported on Linux (no system-wide index)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,153 @@
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import { execa } from 'execa';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { FileSearchImpl } from '../type';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
import { UnixFileSearch, UnixSearchTool } from './unix';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const statPromise = promisify(fs.stat);
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('module:FileSearch:macOS');
|
||||
|
||||
export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
/**
|
||||
* Fallback tool type for macOS file search
|
||||
* Priority: mdfind > fd > find > fast-glob
|
||||
*/
|
||||
type MacOSSearchTool = 'mdfind' | UnixSearchTool;
|
||||
|
||||
export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
/**
|
||||
* Cache for Spotlight availability status
|
||||
* null = not checked, true = available, false = not available
|
||||
*/
|
||||
private spotlightAvailable: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Current tool being used (macOS specific, includes mdfind)
|
||||
*/
|
||||
private macOSCurrentTool: MacOSSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Build the command first, regardless of execution method
|
||||
// Determine the best available tool on first search
|
||||
if (this.macOSCurrentTool === null) {
|
||||
this.macOSCurrentTool = await this.determineBestTool();
|
||||
logger.info(`Using file search tool: ${this.macOSCurrentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithTool(this.macOSCurrentTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available tool based on priority
|
||||
* Priority: mdfind > fd > find > fast-glob
|
||||
*/
|
||||
private async determineBestTool(): Promise<MacOSSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('file-search');
|
||||
if (bestTool && ['mdfind', 'fd', 'find'].includes(bestTool)) {
|
||||
return bestTool as MacOSSearchTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkSpotlightStatus()) {
|
||||
return 'mdfind';
|
||||
}
|
||||
|
||||
// Fallback to Unix tool detection
|
||||
return this.determineBestUnixTool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
private async searchWithTool(
|
||||
tool: MacOSSearchTool,
|
||||
options: SearchOptions,
|
||||
): Promise<FileResult[]> {
|
||||
if (tool === 'mdfind') {
|
||||
return this.searchWithSpotlight(options);
|
||||
}
|
||||
// Use parent class Unix tool implementation
|
||||
return this.searchWithUnixTool(tool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool (macOS specific)
|
||||
*/
|
||||
private async fallbackFromMdfind(): Promise<MacOSSearchTool> {
|
||||
return this.determineBestUnixTool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using Spotlight (mdfind)
|
||||
*/
|
||||
private async searchWithSpotlight(options: SearchOptions): Promise<FileResult[]> {
|
||||
const { cmd, args, commandString } = this.buildSearchCommand(options);
|
||||
logger.debug(`Executing command: ${commandString}`);
|
||||
|
||||
// Use spawn for both live and non-live updates to handle large outputs
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(cmd, args);
|
||||
|
||||
let results: string[] = []; // Store raw file paths
|
||||
let stderrData = '';
|
||||
|
||||
// Create a readline interface to process stdout line by line
|
||||
const rl = readline.createInterface({
|
||||
crlfDelay: Infinity,
|
||||
input: childProcess.stdout, // Handle different line endings
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await execa(cmd, args, {
|
||||
reject: false,
|
||||
});
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine) {
|
||||
results.push(trimmedLine);
|
||||
|
||||
// If we have a limit and we've reached it (in non-live mode), stop processing
|
||||
if (!options.liveUpdate && options.limit && results.length >= options.limit) {
|
||||
logger.debug(`Reached limit (${options.limit}), closing readline and killing process.`);
|
||||
rl.close(); // Stop reading lines
|
||||
childProcess.kill(); // Terminate the mdfind process
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
const errorMsg = data.toString();
|
||||
stderrData += errorMsg;
|
||||
logger.warn(`Search stderr: ${errorMsg}`);
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
logger.error(`Search process error: ${error.message}`, error);
|
||||
reject(new Error(`Search process failed to start: ${error.message}`));
|
||||
});
|
||||
|
||||
childProcess.on('close', async (code) => {
|
||||
logger.debug(`Search process exited with code ${code}`);
|
||||
|
||||
// Even if the process was killed due to limit, code might be null or non-zero.
|
||||
// Process the results collected so far.
|
||||
if (code !== 0 && stderrData && results.length === 0) {
|
||||
// If exited with error code and we have stderr message and no results, reject.
|
||||
// Filter specific ignorable errors if necessary
|
||||
if (!stderrData.includes('Index is unavailable') && !stderrData.includes('kMD')) {
|
||||
// Avoid rejecting for common Spotlight query syntax errors or index issues if some results might still be valid
|
||||
reject(new Error(`Search process exited with code ${code}: ${stderrData}`));
|
||||
return;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Search process exited with code ${code} but contained potentially ignorable errors: ${stderrData}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Process the collected file paths
|
||||
// Ensure limit is applied again here in case killing the process didn't stop exactly at the limit
|
||||
const limitedResults =
|
||||
options.limit && results.length > options.limit
|
||||
? results.slice(0, options.limit)
|
||||
: results;
|
||||
|
||||
const processedResults = await this.processSearchResultsFromPaths(
|
||||
limitedResults,
|
||||
options,
|
||||
);
|
||||
resolve(processedResults);
|
||||
} catch (processingError) {
|
||||
logger.error('Error processing search results:', processingError);
|
||||
reject(new Error(`Failed to process search results: ${processingError.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle live update specific logic (if needed in the future, e.g., sending initial batch)
|
||||
if (options.liveUpdate) {
|
||||
// For live update, we might want to resolve an initial batch
|
||||
// or rely purely on events sent elsewhere.
|
||||
// Current implementation resolves when the stream closes.
|
||||
// We could add a timeout to resolve with initial results if needed.
|
||||
logger.debug('Live update enabled, results will be processed on close.');
|
||||
// Note: The previous `executeLiveSearch` logic is now integrated here.
|
||||
// If specific live update event emission is needed, it would be added here,
|
||||
// potentially calling a callback provided in options.
|
||||
if (stderr) {
|
||||
logger.warn(`Search stderr: ${stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`Search process exited with code ${exitCode}`);
|
||||
|
||||
const results = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// If exited with error code and we have stderr and no results, fallback
|
||||
if (exitCode !== 0 && stderr && results.length === 0) {
|
||||
if (!stderr.includes('Index is unavailable') && !stderr.includes('kMD')) {
|
||||
logger.warn(
|
||||
`Spotlight search failed with code ${exitCode}, falling back to next tool: ${stderr}`,
|
||||
);
|
||||
this.spotlightAvailable = false;
|
||||
this.macOSCurrentTool = await this.fallbackFromMdfind();
|
||||
logger.info(`Falling back to: ${this.macOSCurrentTool}`);
|
||||
return this.searchWithTool(this.macOSCurrentTool, options);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Search process exited with code ${exitCode} but contained potentially ignorable errors: ${stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
const limitedResults =
|
||||
options.limit && results.length > options.limit ? results.slice(0, options.limit) : results;
|
||||
|
||||
return this.processSpotlightResults(limitedResults, options, 'mdfind');
|
||||
} catch (error) {
|
||||
logger.error(`Search process error: ${(error as Error).message}`, error);
|
||||
this.spotlightAvailable = false;
|
||||
this.macOSCurrentTool = await this.fallbackFromMdfind();
|
||||
logger.warn(`Spotlight search failed, falling back to: ${this.macOSCurrentTool}`);
|
||||
return this.searchWithTool(this.macOSCurrentTool, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get macOS-specific ignore patterns including Library/Caches
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [...super.getDefaultIgnorePatterns(), '**/Library/Caches/**'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,37 +163,29 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
async updateSearchIndex(path?: string): Promise<boolean> {
|
||||
return this.updateSpotlightIndex(path);
|
||||
async updateSearchIndex(updatePath?: string): Promise<boolean> {
|
||||
return this.updateSpotlightIndex(updatePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mdfind command string
|
||||
* @param options Search options
|
||||
* @returns Command components (cmd, args array, and command string for logging)
|
||||
*/
|
||||
private buildSearchCommand(options: SearchOptions): {
|
||||
args: string[];
|
||||
cmd: string;
|
||||
commandString: string;
|
||||
} {
|
||||
// Command and arguments array
|
||||
const cmd = 'mdfind';
|
||||
const args: string[] = [];
|
||||
|
||||
// macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing
|
||||
|
||||
// Search in specific directory
|
||||
if (options.onlyIn) {
|
||||
args.push('-onlyin', options.onlyIn);
|
||||
}
|
||||
|
||||
// Live update
|
||||
if (options.liveUpdate) {
|
||||
args.push('-live');
|
||||
}
|
||||
|
||||
// Detailed metadata
|
||||
if (options.detailed) {
|
||||
args.push(
|
||||
'-attr',
|
||||
@@ -172,22 +198,16 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
);
|
||||
}
|
||||
|
||||
// Build query expression
|
||||
let queryExpression = '';
|
||||
|
||||
// Basic query
|
||||
if (options.keywords) {
|
||||
// If the query string doesn't use Spotlight query syntax (doesn't contain kMDItem properties),
|
||||
// treat it as a flexible name search rather than exact phrase match
|
||||
if (!options.keywords.includes('kMDItem')) {
|
||||
// Use kMDItemFSName for filename matching with wildcards for better flexibility
|
||||
queryExpression = `kMDItemFSName == "*${options.keywords.replaceAll('"', '\\"')}*"cd`;
|
||||
} else {
|
||||
queryExpression = options.keywords;
|
||||
}
|
||||
}
|
||||
|
||||
// File content search
|
||||
if (options.contentContains) {
|
||||
if (queryExpression) {
|
||||
queryExpression = `${queryExpression} && kMDItemTextContent == "*${options.contentContains}*"cd`;
|
||||
@@ -196,7 +216,6 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// File type filtering
|
||||
if (options.fileTypes && options.fileTypes.length > 0) {
|
||||
const typeConditions = options.fileTypes
|
||||
.map((type) => `kMDItemContentType == "${type}"`)
|
||||
@@ -208,7 +227,6 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Date filtering - Modified date
|
||||
if (options.modifiedAfter || options.modifiedBefore) {
|
||||
let dateCondition = '';
|
||||
|
||||
@@ -230,7 +248,6 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Date filtering - Creation date
|
||||
if (options.createdAfter || options.createdBefore) {
|
||||
let dateCondition = '';
|
||||
|
||||
@@ -252,46 +269,30 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Add query expression to args
|
||||
if (queryExpression) {
|
||||
args.push(queryExpression);
|
||||
}
|
||||
|
||||
// Build command string for logging
|
||||
const commandString = `${cmd} ${args.map((arg) => (arg.includes(' ') || arg.includes('*') ? `"${arg}"` : arg)).join(' ')}`;
|
||||
|
||||
return { args, cmd, commandString };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute live search, returns initial results and sets callback
|
||||
* @param command mdfind command
|
||||
* @param options Search options
|
||||
* @returns Promise of initial search results
|
||||
* @deprecated This logic is now integrated into the main search method using spawn.
|
||||
* Process Spotlight search results with optional metadata
|
||||
*/
|
||||
// private executeLiveSearch(command: string, options: SearchOptions): Promise<FileResult[]> { ... }
|
||||
// Remove or comment out the old executeLiveSearch method
|
||||
|
||||
/**
|
||||
* Process search results from a list of file paths
|
||||
* @param filePaths Array of file path strings
|
||||
* @param options Search options
|
||||
* @returns Formatted file result list
|
||||
*/
|
||||
private async processSearchResultsFromPaths(
|
||||
private async processSpotlightResults(
|
||||
filePaths: string[],
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
// Create a result object for each file path
|
||||
const resultPromises = filePaths.map(async (filePath) => {
|
||||
try {
|
||||
// Get file information
|
||||
const stats = await statPromise(filePath);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Create basic result object
|
||||
const result: FileResult = {
|
||||
createdTime: stats.birthtime,
|
||||
engine,
|
||||
isDirectory: stats.isDirectory(),
|
||||
lastAccessTime: stats.atime,
|
||||
metadata: {},
|
||||
@@ -302,21 +303,19 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
type: path.extname(filePath).toLowerCase().replace('.', ''),
|
||||
};
|
||||
|
||||
// If detailed information is needed, get additional metadata
|
||||
if (options.detailed) {
|
||||
if (options.detailed && this.spotlightAvailable) {
|
||||
result.metadata = await this.getDetailedMetadata(filePath);
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
result.contentType = this.determineContentType(result.name, result.type);
|
||||
result.contentType = this.determineContentType(result.type);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn(`Error processing file stats for ${filePath}: ${error.message}`, error);
|
||||
// Return partial information, even if unable to get complete file stats
|
||||
logger.warn(`Error processing file stats for ${filePath}: ${(error as Error).message}`);
|
||||
return {
|
||||
contentType: 'unknown',
|
||||
createdTime: new Date(),
|
||||
engine,
|
||||
isDirectory: false,
|
||||
lastAccessTime: new Date(),
|
||||
modifiedTime: new Date(),
|
||||
@@ -328,15 +327,12 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all file information processing to complete
|
||||
let results = await Promise.all(resultPromises);
|
||||
|
||||
// Sort results
|
||||
if (options.sortBy) {
|
||||
results = this.sortResults(results, options.sortBy, options.sortDirection);
|
||||
}
|
||||
|
||||
// Apply limit here as mdfind doesn't support -limit parameter
|
||||
if (options.limit && options.limit > 0 && results.length > options.limit) {
|
||||
results = results.slice(0, options.limit);
|
||||
}
|
||||
@@ -345,26 +341,12 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process search results
|
||||
* @param stdout Command output (now unused directly, processing happens line by line)
|
||||
* @param options Search options
|
||||
* @returns Formatted file result list
|
||||
* @deprecated Use processSearchResultsFromPaths instead.
|
||||
*/
|
||||
// private async processSearchResults(stdout: string, options: SearchOptions): Promise<FileResult[]> { ... }
|
||||
// Remove or comment out the old processSearchResults method
|
||||
|
||||
/**
|
||||
* Get detailed metadata for a file
|
||||
* @param filePath File path
|
||||
* @returns Metadata object
|
||||
* Get detailed metadata for a file using mdls
|
||||
*/
|
||||
private async getDetailedMetadata(filePath: string): Promise<Record<string, any>> {
|
||||
try {
|
||||
// Use mdls command to get all metadata
|
||||
const { stdout } = await execPromise(`mdls "${filePath}"`);
|
||||
const { stdout } = await execa('mdls', [filePath]);
|
||||
|
||||
// Parse mdls output
|
||||
const metadata: Record<string, any> = {};
|
||||
const lines = stdout.split('\n');
|
||||
|
||||
@@ -375,13 +357,11 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
for (const line of lines) {
|
||||
if (isMultilineValue) {
|
||||
if (line.includes(')')) {
|
||||
// Multiline value ends
|
||||
multilineValue.push(line.trim());
|
||||
metadata[currentKey] = multilineValue.join(' ');
|
||||
isMultilineValue = false;
|
||||
multilineValue = [];
|
||||
} else {
|
||||
// Continue collecting multiline value
|
||||
multilineValue.push(line.trim());
|
||||
}
|
||||
continue;
|
||||
@@ -392,12 +372,10 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
currentKey = match[1];
|
||||
const value = match[2].trim();
|
||||
|
||||
// Check for multiline value start
|
||||
if (value.includes('(') && !value.includes(')')) {
|
||||
isMultilineValue = true;
|
||||
multilineValue = [value];
|
||||
} else {
|
||||
// Process single line value
|
||||
metadata[currentKey] = this.parseMetadataValue(value);
|
||||
}
|
||||
}
|
||||
@@ -405,180 +383,80 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
logger.warn(`Error getting metadata for ${filePath}: ${error.message}`, error);
|
||||
logger.warn(`Error getting metadata for ${filePath}: ${(error as Error).message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse metadata value
|
||||
* @param value Metadata raw value string
|
||||
* @returns Parsed value
|
||||
* Parse metadata value from mdls output
|
||||
*/
|
||||
private parseMetadataValue(input: string): any {
|
||||
let value = input;
|
||||
// Remove quotes from mdls output
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
// eslint-disable-next-line unicorn/prefer-string-slice
|
||||
value = value.substring(1, value.length - 1);
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Handle special values
|
||||
if (value === '(null)') return null;
|
||||
if (value === 'Yes' || value === 'true') return true;
|
||||
if (value === 'No' || value === 'false') return false;
|
||||
|
||||
// Try to parse date (format like "2023-05-16 14:30:45 +0000")
|
||||
const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})$/);
|
||||
if (dateMatch) {
|
||||
try {
|
||||
return new Date(value);
|
||||
} catch {
|
||||
// If date parsing fails, return original string
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse number
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
// Default return string
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file content type
|
||||
* @param fileName File name
|
||||
* @param extension File extension
|
||||
* @returns Content type description
|
||||
*/
|
||||
private determineContentType(fileName: string, extension: string): string {
|
||||
// Map common file extensions to content types
|
||||
const typeMap: Record<string, string> = {
|
||||
'7z': 'archive',
|
||||
'aac': 'audio',
|
||||
// Others
|
||||
'app': 'application',
|
||||
'avi': 'video',
|
||||
'c': 'code',
|
||||
'cpp': 'code',
|
||||
'css': 'code',
|
||||
'dmg': 'disk-image',
|
||||
'doc': 'document',
|
||||
'docx': 'document',
|
||||
'gif': 'image',
|
||||
'gz': 'archive',
|
||||
'heic': 'image',
|
||||
'html': 'code',
|
||||
'iso': 'disk-image',
|
||||
'java': 'code',
|
||||
'jpeg': 'image',
|
||||
// Images
|
||||
'jpg': 'image',
|
||||
// Code
|
||||
'js': 'code',
|
||||
'json': 'code',
|
||||
'mkv': 'video',
|
||||
'mov': 'video',
|
||||
// Audio
|
||||
'mp3': 'audio',
|
||||
// Video
|
||||
'mp4': 'video',
|
||||
'ogg': 'audio',
|
||||
// Documents
|
||||
'pdf': 'document',
|
||||
'png': 'image',
|
||||
'ppt': 'presentation',
|
||||
'pptx': 'presentation',
|
||||
'py': 'code',
|
||||
'rar': 'archive',
|
||||
'rtf': 'text',
|
||||
'svg': 'image',
|
||||
'swift': 'code',
|
||||
'tar': 'archive',
|
||||
'ts': 'code',
|
||||
'txt': 'text',
|
||||
'wav': 'audio',
|
||||
'webp': 'image',
|
||||
'xls': 'spreadsheet',
|
||||
'xlsx': 'spreadsheet',
|
||||
// Archive files
|
||||
'zip': 'archive',
|
||||
};
|
||||
|
||||
// Find matching content type
|
||||
return typeMap[extension.toLowerCase()] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort results
|
||||
* @param results Result list
|
||||
* @param sortBy Sort field
|
||||
* @param direction Sort direction
|
||||
* @returns Sorted result list
|
||||
*/
|
||||
private sortResults(
|
||||
results: FileResult[],
|
||||
sortBy: 'name' | 'date' | 'size',
|
||||
direction: 'asc' | 'desc' = 'asc',
|
||||
): FileResult[] {
|
||||
const sortedResults = [...results];
|
||||
|
||||
sortedResults.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name': {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
}
|
||||
case 'date': {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'size': {
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return sortedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Spotlight service status
|
||||
* @returns Promise indicating if Spotlight is available
|
||||
*/
|
||||
private async checkSpotlightStatus(): Promise<boolean> {
|
||||
if (this.spotlightAvailable !== null) {
|
||||
return this.spotlightAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to run a simple mdfind command - macOS doesn't support -limit parameter
|
||||
await execPromise('mdfind -name test -onlyin ~ -count');
|
||||
const { stdout } = await execa(
|
||||
'mdfind',
|
||||
['-name', 'test', '-onlyin', os.homedir() || '~', '-count'],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const count = parseInt(stdout.trim(), 10);
|
||||
if (Number.isNaN(count)) {
|
||||
logger.warn('Spotlight returned invalid response');
|
||||
this.spotlightAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.spotlightAvailable = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Spotlight is not available: ${error.message}`, error);
|
||||
logger.warn(`Spotlight is not available: ${(error as Error).message}`);
|
||||
this.spotlightAvailable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Spotlight index
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
private async updateSpotlightIndex(path?: string): Promise<boolean> {
|
||||
private async updateSpotlightIndex(updatePath?: string): Promise<boolean> {
|
||||
try {
|
||||
// mdutil command is used to manage Spotlight index
|
||||
const command = path ? `mdutil -E "${path}"` : 'mdutil -E /';
|
||||
|
||||
await execPromise(command);
|
||||
await execa('mdutil', ['-E', updatePath || '/']);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update Spotlight index: ${error.message}`, error);
|
||||
logger.error(`Failed to update Spotlight index: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
519
apps/desktop/src/main/modules/fileSearch/impl/unix.ts
Normal file
519
apps/desktop/src/main/modules/fileSearch/impl/unix.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseFileSearch } from '../base';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
|
||||
const logger = createLogger('module:FileSearch:unix');
|
||||
|
||||
/**
|
||||
* Fallback tool type for Unix file search
|
||||
* Priority: fd > find > fast-glob
|
||||
*/
|
||||
export type UnixSearchTool = 'fd' | 'find' | 'fast-glob';
|
||||
|
||||
/**
|
||||
* Unix file search base class
|
||||
* Provides common search implementations for macOS and Linux
|
||||
*/
|
||||
export abstract class UnixFileSearch extends BaseFileSearch {
|
||||
/**
|
||||
* Current fallback tool being used
|
||||
*/
|
||||
protected currentTool: UnixSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'which' command
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
protected async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('which', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available Unix tool based on priority
|
||||
* Priority: fd > find > fast-glob
|
||||
* @returns The best available tool
|
||||
*/
|
||||
protected async determineBestUnixTool(): Promise<UnixSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('file-search');
|
||||
if (bestTool && ['fd', 'find'].includes(bestTool)) {
|
||||
return bestTool as UnixSearchTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('fd')) {
|
||||
return 'fd';
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('find')) {
|
||||
return 'find';
|
||||
}
|
||||
|
||||
return 'fast-glob';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
* @param currentTool Current tool that failed
|
||||
* @returns Next tool to try
|
||||
*/
|
||||
protected async fallbackToNextTool(currentTool: UnixSearchTool): Promise<UnixSearchTool> {
|
||||
const priority: UnixSearchTool[] = ['fd', 'find', 'fast-glob'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'fast-glob') {
|
||||
return 'fast-glob'; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'fast-glob';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified Unix tool
|
||||
* @param tool Tool to use for search
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithUnixTool(
|
||||
tool: UnixSearchTool,
|
||||
options: SearchOptions,
|
||||
): Promise<FileResult[]> {
|
||||
switch (tool) {
|
||||
case 'fd': {
|
||||
return this.searchWithFd(options);
|
||||
}
|
||||
case 'find': {
|
||||
return this.searchWithFind(options);
|
||||
}
|
||||
default: {
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fd (fast find alternative)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithFd(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || '/';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fd search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
const args: string[] = [];
|
||||
|
||||
// Pattern matching
|
||||
if (options.keywords) {
|
||||
args.push(options.keywords);
|
||||
} else {
|
||||
args.push('.'); // Match all files
|
||||
}
|
||||
|
||||
// Search directory and options
|
||||
args.push(searchDir, '--type', 'f', '--hidden', '--ignore-case', '--max-depth', '10');
|
||||
args.push(
|
||||
'--max-results',
|
||||
String(limit),
|
||||
'--exclude',
|
||||
'node_modules',
|
||||
'--exclude',
|
||||
'.git',
|
||||
'--exclude',
|
||||
'*cache*',
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`fd search failed with code ${exitCode}, falling back to next tool`);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
return this.searchWithUnixTool(this.currentTool, options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
logger.debug(`fd found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'fd');
|
||||
} catch (error) {
|
||||
logger.error('fd search failed:', error);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
logger.warn(`fd failed, falling back to: ${this.currentTool}`);
|
||||
return this.searchWithUnixTool(this.currentTool, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using find (Unix standard tool)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithFind(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || '/';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing find search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
const args: string[] = [searchDir];
|
||||
|
||||
// Limit depth and exclude common directories
|
||||
args.push(
|
||||
'-maxdepth',
|
||||
'10',
|
||||
'-type',
|
||||
'f',
|
||||
'(',
|
||||
'-path',
|
||||
'*/node_modules/*',
|
||||
'-o',
|
||||
'-path',
|
||||
'*/.git/*',
|
||||
'-o',
|
||||
'-path',
|
||||
'*/*cache*/*',
|
||||
')',
|
||||
'-prune',
|
||||
'-o',
|
||||
);
|
||||
|
||||
// Pattern matching
|
||||
if (options.keywords) {
|
||||
args.push('-iname', `*${options.keywords}*`);
|
||||
}
|
||||
|
||||
args.push('-print');
|
||||
|
||||
const { stdout, exitCode } = await execa('find', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`find search failed with code ${exitCode}, falling back to fast-glob`);
|
||||
this.currentTool = 'fast-glob';
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.slice(0, limit);
|
||||
|
||||
logger.debug(`find found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'find');
|
||||
} catch (error) {
|
||||
logger.error('find search failed:', error);
|
||||
this.currentTool = 'fast-glob';
|
||||
logger.warn('find failed, falling back to fast-glob');
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fast-glob (pure Node.js implementation)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithFastGlob(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || '/';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fast-glob search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
// Build glob pattern from keywords
|
||||
const pattern = options.keywords
|
||||
? `**/*${this.escapeGlobPattern(options.keywords)}*`
|
||||
: '**/*';
|
||||
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
cwd: searchDir,
|
||||
deep: 10, // Limit depth for performance
|
||||
dot: true,
|
||||
ignore: this.getDefaultIgnorePatterns(),
|
||||
onlyFiles: true,
|
||||
suppressErrors: true,
|
||||
});
|
||||
|
||||
logger.debug(`fast-glob found ${files.length} files matching pattern`);
|
||||
|
||||
const limitedFiles = files.slice(0, limit);
|
||||
return this.processFilePaths(limitedFiles, options, 'fast-glob');
|
||||
} catch (error) {
|
||||
logger.error('fast-glob search failed:', error);
|
||||
throw new Error(`File search failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default ignore patterns for fast-glob
|
||||
* Can be overridden by subclasses for platform-specific patterns
|
||||
* @returns Array of ignore patterns
|
||||
*/
|
||||
protected getDefaultIgnorePatterns(): string[] {
|
||||
return ['**/node_modules/**', '**/.git/**', '**/.*cache*/**'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* Uses fd > find > fast-glob fallback strategy
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
// Determine the best available tool
|
||||
const tool = await this.determineBestUnixTool();
|
||||
logger.info(`Using glob tool: ${tool}`);
|
||||
|
||||
return this.globWithUnixTool(tool, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using the specified Unix tool
|
||||
* @param tool Tool to use for glob
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithUnixTool(
|
||||
tool: UnixSearchTool,
|
||||
params: GlobFilesParams,
|
||||
): Promise<GlobFilesResult> {
|
||||
switch (tool) {
|
||||
case 'fd': {
|
||||
return this.globWithFd(params);
|
||||
}
|
||||
case 'find': {
|
||||
return this.globWithFind(params);
|
||||
}
|
||||
default: {
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fd
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fd: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const args: string[] = [
|
||||
'--glob',
|
||||
params.pattern,
|
||||
searchPath,
|
||||
'--absolute-path',
|
||||
'--hidden',
|
||||
'--no-ignore',
|
||||
];
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`${logPrefix} fd glob failed with code ${exitCode}, falling back to find`);
|
||||
return this.globWithFind(params);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// Get stats for sorting by mtime
|
||||
const filesWithStats = await this.getFilesWithStats(files);
|
||||
const sortedFiles = filesWithStats.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fd',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} fd glob failed:`, error);
|
||||
logger.warn(`${logPrefix} Falling back to find`);
|
||||
return this.globWithFind(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using find
|
||||
* Note: find has limited glob support, converts pattern to -name/-path
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFind(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:find: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting find glob`, { searchPath });
|
||||
|
||||
try {
|
||||
// Convert glob pattern to find -name pattern
|
||||
// find doesn't support full glob, so we do basic conversion
|
||||
const pattern = params.pattern;
|
||||
const args: string[] = [searchPath];
|
||||
|
||||
// Check if pattern contains directory separators
|
||||
if (pattern.includes('/')) {
|
||||
// Use -path for patterns with directories
|
||||
args.push('-path', pattern);
|
||||
} else {
|
||||
// Use -name for simple patterns
|
||||
args.push('-name', pattern);
|
||||
}
|
||||
|
||||
const { stdout, exitCode } = await execa('find', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(
|
||||
`${logPrefix} find glob failed with code ${exitCode}, falling back to fast-glob`,
|
||||
);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// Get stats for sorting by mtime
|
||||
const filesWithStats = await this.getFilesWithStats(files);
|
||||
const sortedFiles = filesWithStats.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'find',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} find glob failed:`, error);
|
||||
logger.warn(`${logPrefix} Falling back to fast-glob`);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fast-glob (Node.js fallback)
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(params.pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
error: (error as Error).message,
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats for sorting
|
||||
* @param files File paths
|
||||
* @returns Files with mtime
|
||||
*/
|
||||
private async getFilesWithStats(
|
||||
files: string[],
|
||||
): Promise<Array<{ mtime: number; path: string }>> {
|
||||
const results: Array<{ mtime: number; path: string }> = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
results.push({ mtime: stats.mtime.getTime(), path: filePath });
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
results.push({ mtime: 0, path: filePath });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
454
apps/desktop/src/main/modules/fileSearch/impl/windows.ts
Normal file
454
apps/desktop/src/main/modules/fileSearch/impl/windows.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseFileSearch } from '../base';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
|
||||
const logger = createLogger('module:FileSearch:windows');
|
||||
|
||||
/**
|
||||
* Fallback tool type for Windows file search
|
||||
* Priority: fd > powershell > fast-glob
|
||||
*/
|
||||
type WindowsFallbackTool = 'fd' | 'powershell' | 'fast-glob';
|
||||
|
||||
/**
|
||||
* Windows file search implementation
|
||||
* Uses fd > PowerShell > fast-glob fallback strategy
|
||||
*/
|
||||
export class WindowsSearchServiceImpl extends BaseFileSearch {
|
||||
/**
|
||||
* Current fallback tool being used
|
||||
*/
|
||||
private currentTool: WindowsFallbackTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestTool();
|
||||
logger.info(`Using file search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithTool(this.currentTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available tool based on priority
|
||||
* Priority: fd > powershell > fast-glob
|
||||
*/
|
||||
private async determineBestTool(): Promise<WindowsFallbackTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('file-search');
|
||||
if (bestTool && ['fd', 'powershell'].includes(bestTool)) {
|
||||
return bestTool as WindowsFallbackTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('fd')) {
|
||||
return 'fd';
|
||||
}
|
||||
|
||||
// PowerShell is always available on Windows
|
||||
return 'powershell';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'where' command (Windows equivalent of 'which')
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
private async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('where', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
private async searchWithTool(
|
||||
tool: WindowsFallbackTool,
|
||||
options: SearchOptions,
|
||||
): Promise<FileResult[]> {
|
||||
switch (tool) {
|
||||
case 'fd': {
|
||||
return this.searchWithFd(options);
|
||||
}
|
||||
case 'powershell': {
|
||||
return this.searchWithPowerShell(options);
|
||||
}
|
||||
default: {
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
*/
|
||||
private async fallbackToNextTool(currentTool: WindowsFallbackTool): Promise<WindowsFallbackTool> {
|
||||
const priority: WindowsFallbackTool[] = ['fd', 'powershell', 'fast-glob'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'fast-glob' || nextTool === 'powershell') {
|
||||
return nextTool; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'fast-glob';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fd (cross-platform fast find alternative)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
private async searchWithFd(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || 'C:\\';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fd search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
const args: string[] = [];
|
||||
|
||||
// Pattern matching
|
||||
if (options.keywords) {
|
||||
args.push(options.keywords);
|
||||
} else {
|
||||
args.push('.'); // Match all files
|
||||
}
|
||||
|
||||
// Search directory and options
|
||||
args.push(searchDir, '--type', 'f', '--hidden', '--ignore-case', '--max-depth', '10');
|
||||
args.push(
|
||||
'--max-results',
|
||||
String(limit),
|
||||
'--exclude',
|
||||
'node_modules',
|
||||
'--exclude',
|
||||
'.git',
|
||||
'--exclude',
|
||||
'*cache*',
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`fd search failed with code ${exitCode}, falling back to next tool`);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
return this.searchWithTool(this.currentTool, options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
logger.debug(`fd found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'fd');
|
||||
} catch (error) {
|
||||
logger.error('fd search failed:', error);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
logger.warn(`fd failed, falling back to: ${this.currentTool}`);
|
||||
return this.searchWithTool(this.currentTool, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using PowerShell Get-ChildItem
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
private async searchWithPowerShell(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || 'C:\\';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing PowerShell search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
// Build PowerShell command
|
||||
const filter = options.keywords ? `*${options.keywords}*` : '*';
|
||||
|
||||
// PowerShell command to search files
|
||||
// -Recurse: recursive search
|
||||
// -File: only files
|
||||
// -Depth: limit search depth
|
||||
// -ErrorAction SilentlyContinue: ignore permission errors
|
||||
const psCommand = `
|
||||
Get-ChildItem -Path '${searchDir}' -Filter '${filter}' -Recurse -File -Depth 10 -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.FullName -notlike '*\\node_modules\\*' -and
|
||||
$_.FullName -notlike '*\\.git\\*' -and
|
||||
$_.FullName -notlike '*\\AppData\\Local\\Temp\\*' -and
|
||||
$_.FullName -notlike '*\\$Recycle.Bin\\*'
|
||||
} |
|
||||
Select-Object -First ${limit} -ExpandProperty FullName
|
||||
`;
|
||||
|
||||
const { stdout, exitCode } = await execa(
|
||||
'powershell',
|
||||
['-NoProfile', '-Command', psCommand],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`PowerShell search failed with code ${exitCode}, falling back to fast-glob`);
|
||||
this.currentTool = 'fast-glob';
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\r\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
logger.debug(`PowerShell found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'powershell');
|
||||
} catch (error) {
|
||||
logger.error('PowerShell search failed:', error);
|
||||
this.currentTool = 'fast-glob';
|
||||
logger.warn('PowerShell failed, falling back to fast-glob');
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fast-glob (pure Node.js implementation)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
private async searchWithFastGlob(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || 'C:\\';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fast-glob search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
// Build glob pattern from keywords
|
||||
const pattern = options.keywords
|
||||
? `**/*${this.escapeGlobPattern(options.keywords)}*`
|
||||
: '**/*';
|
||||
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
cwd: searchDir,
|
||||
deep: 10,
|
||||
dot: false, // Windows hidden files use attributes, not dot prefix
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/AppData/Local/Temp/**',
|
||||
'**/AppData/Local/Microsoft/**',
|
||||
'**/$Recycle.Bin/**',
|
||||
'**/Windows/**',
|
||||
'**/Program Files/**',
|
||||
'**/Program Files (x86)/**',
|
||||
],
|
||||
onlyFiles: true,
|
||||
suppressErrors: true,
|
||||
});
|
||||
|
||||
logger.debug(`fast-glob found ${files.length} files matching pattern`);
|
||||
|
||||
const limitedFiles = files.slice(0, limit);
|
||||
return this.processFilePaths(limitedFiles, options, 'fast-glob');
|
||||
} catch (error) {
|
||||
logger.error('fast-glob search failed:', error);
|
||||
throw new Error(`File search failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available (always true)
|
||||
*/
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
// At minimum, fast-glob is always available
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* Windows Search index is managed by the OS
|
||||
* @returns Promise indicating operation result (always false)
|
||||
*/
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
logger.warn('updateSearchIndex is not supported (using fast-glob instead of Windows Search)');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* Uses fd > fast-glob fallback strategy
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
// Check if fd is available
|
||||
if (await this.checkToolAvailable('fd')) {
|
||||
logger.info('Using glob tool: fd');
|
||||
return this.globWithFd(params);
|
||||
}
|
||||
|
||||
logger.info('Using glob tool: fast-glob');
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fd
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
private async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fd: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const args: string[] = [
|
||||
'--glob',
|
||||
params.pattern,
|
||||
searchPath,
|
||||
'--absolute-path',
|
||||
'--hidden',
|
||||
'--no-ignore',
|
||||
];
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`${logPrefix} fd glob failed with code ${exitCode}, falling back to fast-glob`);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\r\n') // Windows uses \r\n
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// Get stats for sorting by mtime
|
||||
const filesWithStats = await this.getFilesWithStats(files);
|
||||
const sortedFiles = filesWithStats.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fd',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} fd glob failed:`, error);
|
||||
logger.warn(`${logPrefix} Falling back to fast-glob`);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fast-glob (Node.js fallback)
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
private async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(params.pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: false, // Windows hidden files use attributes, not dot prefix
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
error: (error as Error).message,
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats for sorting
|
||||
* @param files File paths
|
||||
* @returns Files with mtime
|
||||
*/
|
||||
private async getFilesWithStats(
|
||||
files: string[],
|
||||
): Promise<Array<{ mtime: number; path: string }>> {
|
||||
const results: Array<{ mtime: number; path: string }> = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
results.push({ mtime: stats.mtime.getTime(), path: filePath });
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
results.push({ mtime: 0, path: filePath });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import { platform } from 'node:os';
|
||||
|
||||
import { MacOSSearchServiceImpl } from './impl/macOS';
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
export const createFileSearchModule = () => {
|
||||
import { LinuxSearchServiceImpl } from './impl/linux';
|
||||
import { MacOSSearchServiceImpl } from './impl/macOS';
|
||||
import { WindowsSearchServiceImpl } from './impl/windows';
|
||||
|
||||
export { BaseFileSearch } from './base';
|
||||
export type { FileResult, SearchOptions } from './types';
|
||||
|
||||
export const createFileSearchModule = (toolDetectorManager?: ToolDetectorManager) => {
|
||||
const currentPlatform = platform();
|
||||
|
||||
switch (currentPlatform) {
|
||||
case 'darwin': {
|
||||
return new MacOSSearchServiceImpl();
|
||||
return new MacOSSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
case 'win32': {
|
||||
return new WindowsSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
case 'linux': {
|
||||
return new LinuxSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
// case 'win32':
|
||||
// return new WindowsSearchServiceImpl();
|
||||
// case 'linux':
|
||||
// return new LinuxSearchServiceImpl();
|
||||
default: {
|
||||
return new MacOSSearchServiceImpl();
|
||||
// throw new Error(`Unsupported platform: ${currentPlatform}`);
|
||||
// Fallback to Linux implementation (uses fast-glob, no external dependencies)
|
||||
console.warn(`Unsupported platform: ${currentPlatform}, using Linux fallback`);
|
||||
return new LinuxSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { FileSearchImpl } from './type';
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
|
||||
/**
|
||||
* File Search Service Implementation Abstract Class
|
||||
* Defines the interface that different platform file search implementations need to implement
|
||||
*/
|
||||
export abstract class FileSearchImpl {
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
abstract search(options: SearchOptions): Promise<FileResult[]>;
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available
|
||||
*/
|
||||
abstract checkSearchServiceStatus(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
abstract updateSearchIndex(path?: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface FileResult {
|
||||
contentType?: string;
|
||||
createdTime: Date;
|
||||
// Search engine used to find this file (e.g., 'mdfind', 'fd', 'find', 'fast-glob')
|
||||
engine?: string;
|
||||
isDirectory: boolean;
|
||||
lastAccessTime: Date;
|
||||
// Spotlight specific metadata
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IToolDetector, createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
/**
|
||||
* Content search tool detectors
|
||||
*
|
||||
* Priority order: rg (1) > ag (2) > grep (3)
|
||||
* AST search: sg (ast-grep) - separate category for AST-based code search
|
||||
*/
|
||||
|
||||
/**
|
||||
* ripgrep (rg) - Fastest grep alternative
|
||||
* https://github.com/BurntSushi/ripgrep
|
||||
*/
|
||||
export const ripgrepDetector: IToolDetector = createCommandDetector('rg', {
|
||||
description: 'ripgrep - fast grep alternative',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* ast-grep (sg) - AST-based code search tool
|
||||
* https://ast-grep.github.io/
|
||||
*/
|
||||
export const astGrepDetector: IToolDetector = createCommandDetector('sg', {
|
||||
description: 'ast-grep - AST-based code search',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* The Silver Searcher (ag) - Fast code searching tool
|
||||
* https://github.com/ggreer/the_silver_searcher
|
||||
*/
|
||||
export const agDetector: IToolDetector = createCommandDetector('ag', {
|
||||
description: 'The Silver Searcher',
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* GNU grep - Standard text search tool
|
||||
*/
|
||||
export const grepDetector: IToolDetector = createCommandDetector('grep', {
|
||||
description: 'GNU grep',
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
/**
|
||||
* All content search detectors (text-based grep tools)
|
||||
*/
|
||||
export const contentSearchDetectors: IToolDetector[] = [ripgrepDetector, agDetector, grepDetector];
|
||||
|
||||
/**
|
||||
* AST-based code search detectors
|
||||
*/
|
||||
export const astSearchDetectors: IToolDetector[] = [astGrepDetector];
|
||||
@@ -0,0 +1,84 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
IToolDetector,
|
||||
ToolStatus,
|
||||
createCommandDetector,
|
||||
} from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* File search tool detectors
|
||||
*
|
||||
* Priority order: mdfind (1, macOS) > fd (2) > find (3)
|
||||
*/
|
||||
|
||||
/**
|
||||
* mdfind - macOS Spotlight search
|
||||
* Only available on macOS, uses Spotlight index for fast searching
|
||||
*/
|
||||
export const mdfindDetector: IToolDetector = {
|
||||
description: 'macOS Spotlight search',
|
||||
async detect(): Promise<ToolStatus> {
|
||||
// Only available on macOS
|
||||
if (process.platform !== 'darwin') {
|
||||
return {
|
||||
available: false,
|
||||
error: 'mdfind is only available on macOS',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if mdfind command exists and Spotlight is working
|
||||
const { stdout } = await execPromise('mdfind -name test -onlyin ~ -count', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// If mdfind returns a number (even 0), Spotlight is available
|
||||
const count = parseInt(stdout.trim(), 10);
|
||||
if (Number.isNaN(count)) {
|
||||
return {
|
||||
available: false,
|
||||
error: 'Spotlight returned invalid response',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: '/usr/bin/mdfind',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
},
|
||||
name: 'mdfind',
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* fd - Fast alternative to find
|
||||
* https://github.com/sharkdp/fd
|
||||
*/
|
||||
export const fdDetector: IToolDetector = createCommandDetector('fd', {
|
||||
description: 'fd - fast find alternative',
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* find - Standard Unix file search
|
||||
*/
|
||||
export const findDetector: IToolDetector = createCommandDetector('find', {
|
||||
description: 'Unix find command',
|
||||
priority: 3,
|
||||
versionFlag: '--version', // GNU find supports this
|
||||
});
|
||||
|
||||
/**
|
||||
* All file search detectors
|
||||
*/
|
||||
export const fileSearchDetectors: IToolDetector[] = [mdfindDetector, fdDetector, findDetector];
|
||||
17
apps/desktop/src/main/modules/toolDetectors/index.ts
Normal file
17
apps/desktop/src/main/modules/toolDetectors/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Tool Detectors Module
|
||||
*
|
||||
* This module provides built-in tool detectors for common system tools.
|
||||
* Modules can register additional custom detectors via ToolDetectorManager.
|
||||
*/
|
||||
|
||||
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
|
||||
export { fileSearchDetectors } from './fileSearchDetectors';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
IToolDetector,
|
||||
ToolCategory,
|
||||
ToolStatus,
|
||||
} from '@/core/infrastructure/ToolDetectorManager';
|
||||
export { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
|
||||
@@ -1,8 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { FileSearchImpl } from '@/modules/fileSearch';
|
||||
import type { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import type { FileResult, SearchOptions } from '@/modules/fileSearch';
|
||||
|
||||
import FileSearchService from '../fileSearchSrv';
|
||||
|
||||
@@ -15,7 +14,7 @@ vi.mock('@/modules/fileSearch', () => {
|
||||
}));
|
||||
|
||||
return {
|
||||
FileSearchImpl: vi.fn(),
|
||||
BaseFileSearch: vi.fn(),
|
||||
createFileSearchModule: vi.fn(() => new MockFileSearchImpl()),
|
||||
};
|
||||
});
|
||||
|
||||
31
apps/desktop/src/main/services/contentSearchSrv.ts
Normal file
31
apps/desktop/src/main/services/contentSearchSrv.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { BaseContentSearch, createContentSearchImpl } from '@/modules/contentSearch';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
/**
|
||||
* Content Search Service
|
||||
* Provides content search functionality using platform-specific implementations
|
||||
*/
|
||||
export default class ContentSearchService extends ServiceModule {
|
||||
private impl: BaseContentSearch = createContentSearchImpl();
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
*/
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
// Ensure toolDetectorManager is set
|
||||
if (this.app?.toolDetectorManager) {
|
||||
this.impl.setToolDetectorManager(this.app.toolDetectorManager);
|
||||
}
|
||||
return this.impl.grep(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tool is available
|
||||
*/
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
return this.impl.checkToolAvailable(tool);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { FileSearchImpl, createFileSearchModule } from '@/modules/fileSearch';
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import {
|
||||
BaseFileSearch,
|
||||
FileResult,
|
||||
SearchOptions,
|
||||
createFileSearchModule,
|
||||
} from '@/modules/fileSearch';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
@@ -8,12 +14,15 @@ import { ServiceModule } from './index';
|
||||
* Main service class that uses platform-specific implementations internally
|
||||
*/
|
||||
export default class FileSearchService extends ServiceModule {
|
||||
private impl: FileSearchImpl = createFileSearchModule();
|
||||
private impl: BaseFileSearch = createFileSearchModule();
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
*/
|
||||
async search(query: string, options: Omit<SearchOptions, 'keywords'> = {}): Promise<FileResult[]> {
|
||||
async search(
|
||||
query: string,
|
||||
options: Omit<SearchOptions, 'keywords'> = {},
|
||||
): Promise<FileResult[]> {
|
||||
return this.impl.search({ ...options, keywords: query });
|
||||
}
|
||||
|
||||
@@ -32,4 +41,13 @@ export default class FileSearchService extends ServiceModule {
|
||||
async updateSearchIndex(path?: string): Promise<boolean> {
|
||||
return this.impl.updateSearchIndex(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return this.impl.glob(params);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user