♻️ 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:
Innei
2026-02-03 17:46:54 +08:00
committed by arvinxx
parent f829bf7fdf
commit b010ab9da8
47 changed files with 4429 additions and 764 deletions

View File

@@ -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 ====================

View 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,
}));
}
}

View File

@@ -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);
});
});
});

View File

@@ -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,

View File

@@ -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

View 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,
};
}

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View 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/**'];
}
}

View 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/**'];
}
}

View 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/**',
];
}
}

View 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;
}
}
}

View 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/**',
];
}
}

View 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);
}
}
}

View 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);
});
});
});

View File

@@ -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);
});
});

View 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>;
}

View 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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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';

View File

@@ -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>;
}

View File

@@ -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

View File

@@ -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];

View File

@@ -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];

View 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';

View File

@@ -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()),
};
});

View 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);
}
}

View File

@@ -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);
}
}