diff --git a/src/main/search-service.ts b/src/main/search-service.ts index bf5e7f9..1c21123 100644 --- a/src/main/search-service.ts +++ b/src/main/search-service.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'node:module' import { spawn } from 'node:child_process' import { relative, sep } from 'node:path' import { getConfig } from './config' @@ -6,18 +5,23 @@ 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) -} + * same way. Substring (fixed-string), smart-case search. + * + * @vscode/ripgrep ships as ESM, so load it with dynamic import() (works for + * ESM and CJS) and cache the resolved binary path. */ +const rgPathPromise: Promise = (async () => { + try { + const mod = await import('@vscode/ripgrep') + let p = (mod 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 (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked') + return p || null + } catch (e) { + console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message) + return null + } +})() export interface ContentHit { no: number; ln: string; ix: number } export interface ContentGroup { path: string; hits: ContentHit[] } @@ -31,8 +35,8 @@ const BASE_IGNORE = [ const MAX_FILES = 400 const MAX_LINE = 1000 -export function rgAvailable(): boolean { - return !!rgPath +export async function rgAvailable(): Promise { + return !!(await rgPathPromise) } /** Glob/ignore args derived from config (files.exclude, files.followGitignore). */ @@ -49,9 +53,10 @@ function toRel(root: string, p: string): string { return relative(root, p).split(sep).join('/') } -export function searchContent(root: string, query: string): Promise { +export async function searchContent(root: string, query: string): Promise { + const rgPath = await rgPathPromise + if (!rgPath || query.trim().length < 2) return [] 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', @@ -90,9 +95,10 @@ export function searchContent(root: string, query: string): Promise { +export async function listFiles(root: string): Promise { + const rgPath = await rgPathPromise + if (!rgPath) return [] return new Promise((resolve) => { - if (!rgPath) return resolve([]) const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root]) let buf = '' const out: string[] = [] diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index e38b389..9773637 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -289,9 +289,11 @@ body { /* ============ overlays ============ */ .scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); } .palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; } -.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); } -.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); } -.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); } +.palette .pi, .search-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); } +.palette .pi input, .search-modal .pi input { flex:1; min-width:0; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); } +.palette .pi input::placeholder, .search-modal .pi input::placeholder { color:var(--fg-3); } +.palette .pi svg, .search-modal .pi svg { flex:0 0 auto; } +.palette .pi .mode-chip, .search-modal .pi .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); } .palette .results { max-height:380px; overflow:auto; padding:6px; } .pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; } .pres.sel { background:var(--accent-soft); } diff --git a/test/search.test.ts b/test/search.test.ts new file mode 100644 index 0000000..b72d54a --- /dev/null +++ b/test/search.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { simpleGit } from 'simple-git' +import { listFiles, rgAvailable, searchContent } from '../src/main/search-service' + +let dir = '' +afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) }) + +describe('search-service (real ripgrep via dynamic import)', () => { + it('ripgrep is available (catches the ESM require() regression)', async () => { + expect(await rgAvailable()).toBe(true) + }) + + it('finds content matches with line number + column', async () => { + dir = await mkdtemp(join(tmpdir(), 'helder-search-')) + await simpleGit(dir).init() + await writeFile(join(dir, 'a.ts'), 'const x = 1\nconst balance = 2\n') + await writeFile(join(dir, 'b.ts'), 'nothing relevant\n') + const groups = await searchContent(dir, 'balance') + expect(groups).toHaveLength(1) + expect(groups[0].path).toBe('a.ts') + expect(groups[0].hits[0].no).toBe(2) + expect(groups[0].hits[0].ln).toContain('balance') + expect(groups[0].hits[0].ix).toBe('const '.length) + }) + + it('returns [] for queries under 2 characters', async () => { + dir = await mkdtemp(join(tmpdir(), 'helder-search-')) + expect(await searchContent(dir, 'a')).toEqual([]) + }) + + it('lists project files', async () => { + dir = await mkdtemp(join(tmpdir(), 'helder-search-')) + await simpleGit(dir).init() + await writeFile(join(dir, 'keep.ts'), 'x') + expect(await listFiles(dir)).toContain('keep.ts') + }) +})