Download src/server/services/searchService.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/server/services/searchService.ts
- Command line
-
hf download hf://chenbhao/codev/src/server/services/searchService.ts
-
curl -L -o searchService.ts https://huggingface.co/chenbhao/codev/resolve/main/src/server/services/searchService.ts
12.9 kB
| /** | |
| * SearchService — 工作区文件搜索 & 会话历史搜索 | |
| * | |
| * 优先使用 ripgrep (rg),不可用时降级到 grep。 | |
| */ | |
| import { spawn } from 'child_process' | |
| import * as fs from 'fs/promises' | |
| import * as path from 'path' | |
| import * as os from 'os' | |
| import { ApiError } from '../middleware/errorHandler.js' | |
| export type SearchResult = { | |
| file: string | |
| line: number | |
| text: string | |
| context?: string[] | |
| } | |
| export type SessionSearchResult = { | |
| sessionId: string | |
| title: string | |
| matchCount: number | |
| matches: Array<{ line: number; text: string }> | |
| } | |
| export class SearchService { | |
| // --------------------------------------------------------------------------- | |
| // 工作区搜索 | |
| // --------------------------------------------------------------------------- | |
| /** 使用 ripgrep 搜索工作目录 */ | |
| async searchWorkspace( | |
| query: string, | |
| options?: { | |
| cwd?: string | |
| maxResults?: number | |
| glob?: string | |
| caseSensitive?: boolean | |
| }, | |
| ): Promise<SearchResult[]> { | |
| if (!query) { | |
| throw ApiError.badRequest('Search query is required') | |
| } | |
| const cwd = options?.cwd || process.cwd() | |
| const maxResults = options?.maxResults || 200 | |
| // 尝试 rg,降级到 grep | |
| const hasRg = await this.commandExists('rg') | |
| if (hasRg) { | |
| try { | |
| return await this.searchWithRipgrep(query, cwd, maxResults, options) | |
| } catch { | |
| // rg 执行失败,降级到 grep | |
| } | |
| } | |
| const hasGrep = await this.commandExists('grep') | |
| if (hasGrep) { | |
| try { | |
| return await this.searchWithGrep(query, cwd, maxResults, options) | |
| } catch { | |
| // grep failed or is not available; fall back to a portable search. | |
| } | |
| } | |
| return this.searchWithFilesystem(query, cwd, maxResults, options) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 会话历史搜索 | |
| // --------------------------------------------------------------------------- | |
| /** 搜索会话历史文件 */ | |
| async searchSessions(query: string): Promise<SessionSearchResult[]> { | |
| if (!query) { | |
| throw ApiError.badRequest('Search query is required') | |
| } | |
| const configDir = | |
| process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') | |
| const projectsDir = path.join(configDir, 'projects') | |
| const results: SessionSearchResult[] = [] | |
| try { | |
| await fs.access(projectsDir) | |
| } catch { | |
| // 目录不存在,返回空 | |
| return results | |
| } | |
| // 遍历 projects/ 下的 JSONL 会话文件 | |
| const entries = await this.walkJsonlFiles(projectsDir) | |
| const lowerQuery = query.toLowerCase() | |
| for (const filePath of entries) { | |
| try { | |
| const raw = await fs.readFile(filePath, 'utf-8') | |
| const lines = raw.split('\n').filter(Boolean) | |
| const matches: Array<{ line: number; text: string }> = [] | |
| let title = path.basename(filePath, '.jsonl') | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i] | |
| if (line.toLowerCase().includes(lowerQuery)) { | |
| // 尝试提取可读文本 | |
| try { | |
| const obj = JSON.parse(line) as Record<string, unknown> | |
| const text = | |
| typeof obj.message === 'string' | |
| ? obj.message | |
| : typeof obj.content === 'string' | |
| ? obj.content | |
| : line.slice(0, 200) | |
| // 提取 title | |
| if (i === 0 && typeof obj.title === 'string') { | |
| title = obj.title | |
| } | |
| matches.push({ line: i + 1, text: text.slice(0, 300) }) | |
| } catch { | |
| matches.push({ line: i + 1, text: line.slice(0, 200) }) | |
| } | |
| } | |
| } | |
| if (matches.length > 0) { | |
| const sessionId = path.basename(filePath, '.jsonl') | |
| results.push({ | |
| sessionId, | |
| title, | |
| matchCount: matches.length, | |
| matches: matches.slice(0, 20), // 每个会话最多 20 条匹配 | |
| }) | |
| } | |
| } catch { | |
| // 跳过无法读取的文件 | |
| } | |
| } | |
| return results | |
| } | |
| // --------------------------------------------------------------------------- | |
| // ripgrep 搜索 | |
| // --------------------------------------------------------------------------- | |
| private async searchWithRipgrep( | |
| query: string, | |
| cwd: string, | |
| maxResults: number, | |
| options?: { | |
| glob?: string | |
| caseSensitive?: boolean | |
| }, | |
| ): Promise<SearchResult[]> { | |
| const args = ['--json', '--max-count', String(maxResults)] | |
| if (options?.caseSensitive === false) { | |
| args.push('--ignore-case') | |
| } | |
| // 添加上下文行 | |
| args.push('-C', '4') | |
| if (options?.glob) { | |
| args.push('--glob', options.glob) | |
| } | |
| args.push('--', query, cwd) | |
| const output = await this.runCommand('rg', args) | |
| return this.parseRipgrepJson(output, maxResults) | |
| } | |
| /** 解析 ripgrep JSON 输出 */ | |
| private parseRipgrepJson( | |
| output: string, | |
| maxResults: number, | |
| ): SearchResult[] { | |
| const results: SearchResult[] = [] | |
| const lines = output.split('\n').filter(Boolean) | |
| // 收集上下文:key = `${file}:${matchLine}` | |
| const contextMap = new Map< | |
| string, | |
| { file: string; line: number; text: string; context: string[] } | |
| >() | |
| for (const line of lines) { | |
| try { | |
| const obj = JSON.parse(line) as Record<string, unknown> | |
| if (obj.type === 'match') { | |
| const data = obj.data as { | |
| path?: { text?: string } | |
| line_number?: number | |
| lines?: { text?: string } | |
| submatches?: unknown[] | |
| } | |
| const file = data.path?.text || '' | |
| const lineNum = data.line_number || 0 | |
| const text = (data.lines?.text || '').replace(/\n$/, '') | |
| const key = `${file}:${lineNum}` | |
| contextMap.set(key, { file, line: lineNum, text, context: [] }) | |
| } else if (obj.type === 'context') { | |
| // 上下文行归属到最近的 match | |
| const data = obj.data as { | |
| path?: { text?: string } | |
| line_number?: number | |
| lines?: { text?: string } | |
| } | |
| const text = (data.lines?.text || '').replace(/\n$/, '') | |
| // 附加到最后一个相同文件的 match | |
| const file = data.path?.text || '' | |
| for (const [key, entry] of contextMap) { | |
| if (key.startsWith(file + ':')) { | |
| entry.context.push(text) | |
| } | |
| } | |
| } | |
| } catch { | |
| // 跳过无法解析的行 | |
| } | |
| } | |
| for (const entry of contextMap.values()) { | |
| if (results.length >= maxResults) break | |
| results.push({ | |
| file: entry.file, | |
| line: entry.line, | |
| text: entry.text, | |
| context: entry.context.length > 0 ? entry.context : undefined, | |
| }) | |
| } | |
| return results | |
| } | |
| // --------------------------------------------------------------------------- | |
| // grep 降级 | |
| // --------------------------------------------------------------------------- | |
| private async searchWithGrep( | |
| query: string, | |
| cwd: string, | |
| maxResults: number, | |
| options?: { | |
| glob?: string | |
| caseSensitive?: boolean | |
| }, | |
| ): Promise<SearchResult[]> { | |
| const args = ['-rn', '--max-count', String(maxResults)] | |
| if (options?.caseSensitive === false) { | |
| args.push('-i') | |
| } | |
| if (options?.glob) { | |
| args.push('--include', options.glob) | |
| } | |
| args.push('--', query, cwd) | |
| const output = await this.runCommand('grep', args) | |
| return this.parseGrepOutput(output, maxResults) | |
| } | |
| /** 解析 grep 输出 (file:line:text) */ | |
| private parseGrepOutput(output: string, maxResults: number): SearchResult[] { | |
| const results: SearchResult[] = [] | |
| const lines = output.split('\n').filter(Boolean) | |
| for (const line of lines) { | |
| if (results.length >= maxResults) break | |
| // grep -n 输出格式: file:line:text | |
| const match = line.match(/^(.+?):(\d+):(.*)$/) | |
| if (match) { | |
| results.push({ | |
| file: match[1], | |
| line: parseInt(match[2], 10), | |
| text: match[3], | |
| }) | |
| } | |
| } | |
| return results | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Portable filesystem fallback | |
| // --------------------------------------------------------------------------- | |
| private async searchWithFilesystem( | |
| query: string, | |
| cwd: string, | |
| maxResults: number, | |
| options?: { | |
| glob?: string | |
| caseSensitive?: boolean | |
| }, | |
| ): Promise<SearchResult[]> { | |
| const results: SearchResult[] = [] | |
| const needle = options?.caseSensitive === false ? query.toLowerCase() : query | |
| await this.searchDirectory(cwd, needle, results, maxResults, { | |
| caseSensitive: options?.caseSensitive !== false, | |
| glob: options?.glob, | |
| }) | |
| return results | |
| } | |
| private async searchDirectory( | |
| dir: string, | |
| needle: string, | |
| results: SearchResult[], | |
| maxResults: number, | |
| options: { | |
| caseSensitive: boolean | |
| glob?: string | |
| }, | |
| ): Promise<void> { | |
| if (results.length >= maxResults) return | |
| let entries: import('fs').Dirent[] | |
| try { | |
| entries = await fs.readdir(dir, { withFileTypes: true }) | |
| } catch { | |
| return | |
| } | |
| for (const entry of entries) { | |
| if (results.length >= maxResults) return | |
| if (entry.name === 'node_modules' || entry.name === '.git') continue | |
| const fullPath = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| await this.searchDirectory(fullPath, needle, results, maxResults, options) | |
| continue | |
| } | |
| if (!entry.isFile()) continue | |
| if (options.glob && !this.matchesSimpleGlob(entry.name, options.glob)) continue | |
| await this.searchFile(fullPath, needle, results, maxResults, options.caseSensitive) | |
| } | |
| } | |
| private async searchFile( | |
| filePath: string, | |
| needle: string, | |
| results: SearchResult[], | |
| maxResults: number, | |
| caseSensitive: boolean, | |
| ): Promise<void> { | |
| let content: string | |
| try { | |
| const buffer = await fs.readFile(filePath) | |
| if (buffer.includes(0)) return | |
| content = buffer.toString('utf8') | |
| } catch { | |
| return | |
| } | |
| const lines = content.split(/\r?\n/) | |
| for (let index = 0; index < lines.length && results.length < maxResults; index++) { | |
| const haystack = caseSensitive ? lines[index] : lines[index].toLowerCase() | |
| if (!haystack.includes(needle)) continue | |
| results.push({ | |
| file: filePath, | |
| line: index + 1, | |
| text: lines[index], | |
| }) | |
| } | |
| } | |
| private matchesSimpleGlob(fileName: string, glob: string): boolean { | |
| if (!glob.includes('*')) return fileName === glob | |
| const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') | |
| return new RegExp(`^${escaped}$`).test(fileName) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 工具方法 | |
| // --------------------------------------------------------------------------- | |
| /** 运行外部命令,返回 stdout */ | |
| private runCommand(cmd: string, args: string[]): Promise<string> { | |
| return new Promise((resolve, reject) => { | |
| const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }) | |
| const chunks: Buffer[] = [] | |
| proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) | |
| proc.on('close', (code) => { | |
| const output = Buffer.concat(chunks).toString('utf-8') | |
| // rg/grep 返回 1 表示无匹配,不视为错误 | |
| if (code === 0 || code === 1) { | |
| resolve(output) | |
| } else { | |
| reject( | |
| new Error(`Command "${cmd}" exited with code ${code}: ${output}`), | |
| ) | |
| } | |
| }) | |
| proc.on('error', (err) => { | |
| reject(err) | |
| }) | |
| }) | |
| } | |
| /** 检测命令是否存在 */ | |
| private commandExists(cmd: string): Promise<boolean> { | |
| return new Promise((resolve) => { | |
| const lookup = process.platform === 'win32' ? 'where' : 'which' | |
| const proc = spawn(lookup, [cmd], { stdio: 'ignore' }) | |
| proc.on('close', (code) => resolve(code === 0)) | |
| proc.on('error', () => resolve(false)) | |
| }) | |
| } | |
| /** 递归查找 .jsonl 文件 */ | |
| private async walkJsonlFiles(dir: string): Promise<string[]> { | |
| const results: string[] = [] | |
| try { | |
| const entries = await fs.readdir(dir, { withFileTypes: true }) | |
| for (const entry of entries) { | |
| const fullPath = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| const sub = await this.walkJsonlFiles(fullPath) | |
| results.push(...sub) | |
| } else if (entry.name.endsWith('.jsonl')) { | |
| results.push(fullPath) | |
| } | |
| } | |
| } catch { | |
| // 跳过不可访问的目录 | |
| } | |
| return results | |
| } | |
| } | |