113 lines
4.5 KiB
TypeScript
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)
|
|
})
|
|
}
|