Files
helder/src/main/search-service.ts
2026-06-16 06:18:42 +02:00

113 lines
4.5 KiB
TypeScript

import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { relative, sep } from 'node:path'
import { getConfig } from './config'
/** ripgrep is the single source of "what files are in the project": it powers
* content search, the file-name list, AND the Explorer tree / content index
* (via fs-service) — so gitignore + files.exclude are honored everywhere the
* same way. Substring (fixed-string), smart-case search. */
const require = createRequire(import.meta.url)
let rgPath: string | null = null
try {
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
// When packaged the binary is unpacked from the asar; rgPath still points
// inside app.asar, so redirect it. No-op in dev (path has no app.asar).
if (rgPath) rgPath = rgPath.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
} catch (e) {
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
}
export interface ContentHit { no: number; ln: string; ix: number }
export interface ContentGroup { path: string; hits: ContentHit[] }
/** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */
const BASE_IGNORE = [
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
]
const MAX_FILES = 400
const MAX_LINE = 1000
export function rgAvailable(): boolean {
return !!rgPath
}
/** Glob/ignore args derived from config (files.exclude, files.followGitignore). */
function ignoreArgs(): string[] {
const cfg = getConfig()
const args: string[] = []
for (const d of BASE_IGNORE) args.push('--glob', `!${d}`)
for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
if (!cfg.files.followGitignore) args.push('--no-ignore')
return args
}
function toRel(root: string, p: string): string {
return relative(root, p).split(sep).join('/')
}
export function searchContent(root: string, query: string): Promise<ContentGroup[]> {
return new Promise((resolve) => {
if (!rgPath || query.trim().length < 2) return resolve([])
const child = spawn(rgPath, [
'--json', '--fixed-strings', '--smart-case', '--hidden',
'--max-count', '50', '--max-columns', '2000',
...ignoreArgs(), '-e', query, '--', root,
])
const order: string[] = []
const groups = new Map<string, ContentGroup>()
let buf = ''
let done = false
const finish = (): void => { if (done) return; done = true; resolve(order.slice(0, MAX_FILES).map((p) => groups.get(p)!)) }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (!line) continue
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
try { msg = JSON.parse(line) } catch { continue }
if (msg.type !== 'match') continue
const abs = msg.data.path?.text
const text = msg.data.lines?.text
if (!abs || text == null) continue
const rel = toRel(root, abs)
let g = groups.get(rel)
if (!g) { if (groups.size >= MAX_FILES) continue; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
const ln = text.replace(/\n$/, '').slice(0, MAX_LINE)
const ix = msg.data.submatches && msg.data.submatches[0] ? msg.data.submatches[0].start : 0
g.hits.push({ no: msg.data.line_number || 0, ln, ix: Math.min(ix, ln.length) })
}
})
child.on('close', finish)
child.on('error', finish)
})
}
/** All project files (relative paths), honoring gitignore + excludes. Includes
* dotfiles (--hidden) so .env etc. show up unless ignored. */
export function listFiles(root: string): Promise<string[]> {
return new Promise((resolve) => {
if (!rgPath) return resolve([])
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root])
let buf = ''
const out: string[] = []
let done = false
const finish = (): void => { if (done) return; done = true; resolve(out) }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (line) out.push(toRel(root, line))
}
})
child.on('close', () => { if (buf.trim()) out.push(toRel(root, buf.trim())); finish() })
child.on('error', finish)
})
}