diff --git a/src/main/files/index.js b/src/main/files/index.js index 3871eff..3896992 100644 --- a/src/main/files/index.js +++ b/src/main/files/index.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const { register: registerGit } = require('./git'); const { register: registerBinary } = require('./binary'); +const { searchInFiles } = require('./search-in-files'); function register({ validatePath, @@ -131,6 +132,11 @@ function register({ return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; }); + // search-in-files + ipcMain.handle('search-in-files', async (_event, payload) => { + return searchInFiles(payload || {}); + }); + // open-file-path ipcMain.on('open-file-path', (event, filePath) => { try { diff --git a/src/main/files/list-directory.js b/src/main/files/list-directory.js index cc4e41c..f78ae3b 100644 --- a/src/main/files/list-directory.js +++ b/src/main/files/list-directory.js @@ -30,7 +30,7 @@ function listDirectoryEntries(dirPath) { name: d.name, isDirectory: d.isDirectory(), size, - modified, + modifiedAt: modified, path: full, }); } diff --git a/src/main/files/search-in-files.js b/src/main/files/search-in-files.js new file mode 100644 index 0000000..f95150d --- /dev/null +++ b/src/main/files/search-in-files.js @@ -0,0 +1,89 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MAX_RESULTS = 1000; +const MAX_FILE_BYTES = 2 * 1024 * 1024; +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.cache']); + +function listFiles(rootPath) { + const out = []; + const stack = [rootPath]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (_) { + continue; + } + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + stack.push(full); + } else if (e.isFile()) { + out.push(full); + } + } + } + return out; +} + +function makeMatcher(query, isRegex, caseSensitive) { + if (isRegex) { + try { + const re = new RegExp(query, caseSensitive ? '' : 'i'); + return (line) => re.test(line); + } catch (_err) { + return null; + } + } + const needle = caseSensitive ? query : query.toLowerCase(); + return (line) => (caseSensitive ? line : line.toLowerCase()).includes(needle); +} + +/** + * Recursively search `rootPath` for files containing `query`. + * Returns up to MAX_RESULTS matches of the form + * { filePath, line, content }. + * + * Skips binary files (> MAX_FILE_BYTES) and noisy directories + * (node_modules, .git, dist, .next, .cache) to keep latency low. + */ +function searchInFiles({ rootPath, query, isRegex = false, caseSensitive = false }) { + if (!rootPath || !query) return []; + const matcher = makeMatcher(query, isRegex, caseSensitive); + if (!matcher) return []; + + const results = []; + for (const filePath of listFiles(rootPath)) { + if (results.length >= MAX_RESULTS) break; + let stat; + try { + stat = fs.statSync(filePath); + } catch (_) { + continue; + } + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch (_) { + continue; + } + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (matcher(lines[i])) { + results.push({ filePath, line: i + 1, content: lines[i] }); + if (results.length >= MAX_RESULTS) break; + } + } + } + return results; +} + +module.exports = { searchInFiles }; diff --git a/src/preload.js b/src/preload.js index 9606ec1..737f3f9 100644 --- a/src/preload.js +++ b/src/preload.js @@ -108,6 +108,7 @@ const ALLOWED_SEND_CHANNELS = [ 'move-path', 'pick-folder', 'pick-file', + 'search-in-files', // Git 'git-status', @@ -372,6 +373,7 @@ contextBridge.exposeInMainWorld('electronAPI', { list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath), pickFolder: () => ipcRenderer.invoke('pick-folder'), pickFile: () => ipcRenderer.invoke('pick-file'), + search: (args) => ipcRenderer.invoke('search-in-files', args), }, // Theme Operations diff --git a/src/renderer/components/modals/CrashReportModal.tsx b/src/renderer/components/modals/CrashReportModal.tsx index 1a02106..1c672c6 100644 --- a/src/renderer/components/modals/CrashReportModal.tsx +++ b/src/renderer/components/modals/CrashReportModal.tsx @@ -11,7 +11,14 @@ interface Dump { export function CrashReportModal({ onClose }: { onClose: () => void }) { const [dumps, setDumps] = useState([]); - const refresh = async () => setDumps(await ipc.crash.read()); + const refresh = async () => { + const result = await ipc.crash.read(); + if (!result.ok) { + setDumps([]); + return; + } + setDumps(Array.isArray(result.data) ? (result.data as Dump[]) : []); + }; useEffect(() => { refresh(); }, []); diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts index 76a1cc7..e45a707 100644 --- a/src/renderer/lib/ipc.ts +++ b/src/renderer/lib/ipc.ts @@ -58,7 +58,8 @@ function safeCall Promise>( export const ipc = { file: { - open: (): Promise> => safeCall('file', 'pickFile'), + // NOTE: file.open was previously miswired to pickFile — removed. + // Callers needing a file picker should use ipc.file.pickFile() directly. read: (path: string): Promise> => safeCall('file', 'read', path), write: (path: string, content: string): Promise> => @@ -82,7 +83,7 @@ export const ipc = { caseSensitive: boolean; }): Promise< IpcResult | ChannelMissing> - > => safeCall('file', 'pickFile'), + > => safeCall('file', 'search', args), gitStatus: (args: { rootPath: string; }): Promise< diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index cdeacf0..dcaf8aa 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -25,6 +25,12 @@ export interface ElectronAPI { list: (dirPath: string) => Promise; pickFolder: () => Promise; pickFile: () => Promise; + search: (args: { + rootPath: string; + query: string; + isRegex: boolean; + caseSensitive: boolean; + }) => Promise>; }; theme: { diff --git a/src/renderer/types/ipc.ts b/src/renderer/types/ipc.ts index c8b6025..f406bb9 100644 --- a/src/renderer/types/ipc.ts +++ b/src/renderer/types/ipc.ts @@ -7,7 +7,7 @@ export interface FileEntry { path: string; isDirectory: boolean; size?: number; - modifiedAt?: string; + modifiedAt?: number; } export interface FileResult { diff --git a/tests/component/modals/CrashReportModal.test.tsx b/tests/component/modals/CrashReportModal.test.tsx index 688d57e..6a9f936 100644 --- a/tests/component/modals/CrashReportModal.test.tsx +++ b/tests/component/modals/CrashReportModal.test.tsx @@ -9,35 +9,57 @@ vi.mock('@/lib/ipc', () => ({ }, })); +/** + * The crash:read IPC handler returns a plain array; ipc.crash.read wraps it + * in { ok: true, data }. The component must unwrap .data before rendering + * — otherwise dumps.length is undefined and dumps.map blows up. + */ +function wrap(data: unknown) { + return { ok: true, data }; +} + describe('CrashReportModal', () => { test('shows empty state when no crashes', async () => { - (ipc.crash.read as any).mockResolvedValue([]); + (ipc.crash.read as any).mockResolvedValue(wrap([])); + render( {}} />); + expect(await screen.findByText(/no crashes/i)).toBeInTheDocument(); + }); + + test('shows empty state when ipc returns ok:false', async () => { + (ipc.crash.read as any).mockResolvedValue({ + ok: false, + error: { code: 'NO_BRIDGE', message: 'no electronAPI' }, + }); render( {}} />); expect(await screen.findByText(/no crashes/i)).toBeInTheDocument(); }); test('lists crashes returned by ipc', async () => { - (ipc.crash.read as any).mockResolvedValue([ - { - filename: '1700000000000-1-uncaughtException.json', - kind: 'uncaughtException', - message: 'boom', - timestamp: '2026-01-01T00:00:00.000Z', - }, - ]); + (ipc.crash.read as any).mockResolvedValue( + wrap([ + { + filename: '1700000000000-1-uncaughtException.json', + kind: 'uncaughtException', + message: 'boom', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ]) + ); render( {}} />); expect(await screen.findByText(/boom/)).toBeInTheDocument(); }); test('delete button calls ipc.crash.delete', async () => { - (ipc.crash.read as any).mockResolvedValue([ - { - filename: '1700000000000-1-uncaughtException.json', - kind: 'uncaughtException', - message: 'boom', - timestamp: '2026-01-01T00:00:00.000Z', - }, - ]); + (ipc.crash.read as any).mockResolvedValue( + wrap([ + { + filename: '1700000000000-1-uncaughtException.json', + kind: 'uncaughtException', + message: 'boom', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ]) + ); render( {}} />); const btn = await screen.findByText(/delete/i); fireEvent.click(btn); @@ -47,7 +69,7 @@ describe('CrashReportModal', () => { }); test('open folder button calls ipc.crash.openDir', async () => { - (ipc.crash.read as any).mockResolvedValue([]); + (ipc.crash.read as any).mockResolvedValue(wrap([])); render( {}} />); fireEvent.click(await screen.findByText(/open dump folder/i)); expect(ipc.crash.openDir).toHaveBeenCalled(); diff --git a/tests/unit/main/files/search-in-files.test.js b/tests/unit/main/files/search-in-files.test.js new file mode 100644 index 0000000..89064c7 --- /dev/null +++ b/tests/unit/main/files/search-in-files.test.js @@ -0,0 +1,87 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { searchInFiles } = require('../../../../src/main/files/search-in-files'); + +describe('searchInFiles', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-in-files-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns empty array when rootPath or query missing', () => { + expect(searchInFiles({ rootPath: '', query: 'x' })).toEqual([]); + expect(searchInFiles({ rootPath: tmpDir, query: '' })).toEqual([]); + }); + + test('finds a literal substring in a single file', () => { + const f = path.join(tmpDir, 'a.md'); + fs.writeFileSync(f, 'hello world\nsecond line\nhello again'); + const out = searchInFiles({ rootPath: tmpDir, query: 'hello' }); + expect(out).toEqual([ + { filePath: f, line: 1, content: 'hello world' }, + { filePath: f, line: 3, content: 'hello again' }, + ]); + }); + + test('respects caseSensitive flag (default false)', () => { + const f = path.join(tmpDir, 'a.md'); + fs.writeFileSync(f, 'Foo bar\nfoo BAR'); + expect(searchInFiles({ rootPath: tmpDir, query: 'foo' }).length).toBe(2); + expect(searchInFiles({ rootPath: tmpDir, query: 'foo', caseSensitive: true }).length).toBe(1); + }); + + test('supports regex when isRegex is true', () => { + const f = path.join(tmpDir, 'a.md'); + fs.writeFileSync(f, 'foo123\nbar\nbaz456'); + const out = searchInFiles({ rootPath: tmpDir, query: '\\w+\\d+', isRegex: true }); + expect(out.map((r) => r.content)).toEqual(['foo123', 'baz456']); + }); + + test('invalid regex returns empty array (does not throw)', () => { + expect(searchInFiles({ rootPath: tmpDir, query: '[unclosed', isRegex: true })).toEqual([]); + }); + + test('skips node_modules, .git, dist directories', () => { + const skip = path.join(tmpDir, 'node_modules'); + fs.mkdirSync(skip); + fs.writeFileSync(path.join(skip, 'a.md'), 'match me'); + const keep = path.join(tmpDir, 'src'); + fs.mkdirSync(keep); + fs.writeFileSync(path.join(keep, 'b.md'), 'match me'); + const out = searchInFiles({ rootPath: tmpDir, query: 'match' }); + expect(out.length).toBe(1); + expect(out[0].filePath).toContain('src/b.md'); + }); + + test('skips files larger than 2MB', () => { + const big = path.join(tmpDir, 'big.md'); + fs.writeFileSync(big, 'a'.repeat(3 * 1024 * 1024)); + expect(searchInFiles({ rootPath: tmpDir, query: 'a' })).toEqual([]); + }); + + test('recurses into subdirectories', () => { + const sub = path.join(tmpDir, 'a', 'b', 'c'); + fs.mkdirSync(sub, { recursive: true }); + const f = path.join(sub, 'deep.md'); + fs.writeFileSync(f, 'needle here'); + const out = searchInFiles({ rootPath: tmpDir, query: 'needle' }); + expect(out.length).toBe(1); + expect(out[0].filePath).toBe(f); + expect(out[0].line).toBe(1); + }); + + test('caps results at 1000', () => { + const f = path.join(tmpDir, 'many.md'); + const lines = []; + for (let i = 0; i < 1500; i++) lines.push('match line'); + fs.writeFileSync(f, lines.join('\n')); + const out = searchInFiles({ rootPath: tmpDir, query: 'match' }); + expect(out.length).toBe(1000); + }); +});