fix(ipc): crash modal reads wrapper, search wired to real handler

CrashReportModal crashed on dumps.map because ipc.crash.read returns
{ ok, data } and the consumer was assigning the wrapper to state. Now
unwraps .data and falls back to [] on error.

ipc.file.search was miswired to ipc.file.pickFile — clicking 'Search'
popped a file picker instead of searching. Add real 'search-in-files'
handler in src/main/files/search-in-files.js with regex support,
case-sensitivity toggle, .git/node_modules/dist skip, 2MB file cap, and
1000-result limit. Wire preload bridge + TS declaration + allowlist.

Also:
- Drop dead ipc.file.open wrapper that was miswired to pickFile
- Rename FileEntry field 'modified' to 'modifiedAt' (number) so the
  IPC payload matches the declared type
- Update CrashReportModal tests to use the safeCall envelope shape
This commit is contained in:
2026-07-23 09:34:57 +05:30
parent f1f8a16a79
commit 6da2ba7cc7
10 changed files with 243 additions and 23 deletions
+6
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { register: registerGit } = require('./git'); const { register: registerGit } = require('./git');
const { register: registerBinary } = require('./binary'); const { register: registerBinary } = require('./binary');
const { searchInFiles } = require('./search-in-files');
function register({ function register({
validatePath, validatePath,
@@ -131,6 +132,11 @@ function register({
return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
}); });
// search-in-files
ipcMain.handle('search-in-files', async (_event, payload) => {
return searchInFiles(payload || {});
});
// open-file-path // open-file-path
ipcMain.on('open-file-path', (event, filePath) => { ipcMain.on('open-file-path', (event, filePath) => {
try { try {
+1 -1
View File
@@ -30,7 +30,7 @@ function listDirectoryEntries(dirPath) {
name: d.name, name: d.name,
isDirectory: d.isDirectory(), isDirectory: d.isDirectory(),
size, size,
modified, modifiedAt: modified,
path: full, path: full,
}); });
} }
+89
View File
@@ -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 };
+2
View File
@@ -108,6 +108,7 @@ const ALLOWED_SEND_CHANNELS = [
'move-path', 'move-path',
'pick-folder', 'pick-folder',
'pick-file', 'pick-file',
'search-in-files',
// Git // Git
'git-status', 'git-status',
@@ -372,6 +373,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath), list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
pickFolder: () => ipcRenderer.invoke('pick-folder'), pickFolder: () => ipcRenderer.invoke('pick-folder'),
pickFile: () => ipcRenderer.invoke('pick-file'), pickFile: () => ipcRenderer.invoke('pick-file'),
search: (args) => ipcRenderer.invoke('search-in-files', args),
}, },
// Theme Operations // Theme Operations
@@ -11,7 +11,14 @@ interface Dump {
export function CrashReportModal({ onClose }: { onClose: () => void }) { export function CrashReportModal({ onClose }: { onClose: () => void }) {
const [dumps, setDumps] = useState<Dump[]>([]); const [dumps, setDumps] = useState<Dump[]>([]);
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(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
+3 -2
View File
@@ -58,7 +58,8 @@ function safeCall<T extends (...args: any[]) => Promise<any>>(
export const ipc = { export const ipc = {
file: { file: {
open: (): Promise<IpcResult<FileResult | ChannelMissing>> => 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<IpcResult<string | ChannelMissing>> => read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('file', 'read', path), safeCall('file', 'read', path),
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> => write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
@@ -82,7 +83,7 @@ export const ipc = {
caseSensitive: boolean; caseSensitive: boolean;
}): Promise< }): Promise<
IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing> IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>
> => safeCall('file', 'pickFile'), > => safeCall('file', 'search', args),
gitStatus: (args: { gitStatus: (args: {
rootPath: string; rootPath: string;
}): Promise< }): Promise<
+6
View File
@@ -25,6 +25,12 @@ export interface ElectronAPI {
list: (dirPath: string) => Promise<unknown>; list: (dirPath: string) => Promise<unknown>;
pickFolder: () => Promise<string | null>; pickFolder: () => Promise<string | null>;
pickFile: () => Promise<string | null>; pickFile: () => Promise<string | null>;
search: (args: {
rootPath: string;
query: string;
isRegex: boolean;
caseSensitive: boolean;
}) => Promise<Array<{ filePath: string; line: number; content: string }>>;
}; };
theme: { theme: {
+1 -1
View File
@@ -7,7 +7,7 @@ export interface FileEntry {
path: string; path: string;
isDirectory: boolean; isDirectory: boolean;
size?: number; size?: number;
modifiedAt?: string; modifiedAt?: number;
} }
export interface FileResult { export interface FileResult {
@@ -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', () => { describe('CrashReportModal', () => {
test('shows empty state when no crashes', async () => { test('shows empty state when no crashes', async () => {
(ipc.crash.read as any).mockResolvedValue([]); (ipc.crash.read as any).mockResolvedValue(wrap([]));
render(<CrashReportModal onClose={() => {}} />);
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(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
expect(await screen.findByText(/no crashes/i)).toBeInTheDocument(); expect(await screen.findByText(/no crashes/i)).toBeInTheDocument();
}); });
test('lists crashes returned by ipc', async () => { test('lists crashes returned by ipc', async () => {
(ipc.crash.read as any).mockResolvedValue([ (ipc.crash.read as any).mockResolvedValue(
wrap([
{ {
filename: '1700000000000-1-uncaughtException.json', filename: '1700000000000-1-uncaughtException.json',
kind: 'uncaughtException', kind: 'uncaughtException',
message: 'boom', message: 'boom',
timestamp: '2026-01-01T00:00:00.000Z', timestamp: '2026-01-01T00:00:00.000Z',
}, },
]); ])
);
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
expect(await screen.findByText(/boom/)).toBeInTheDocument(); expect(await screen.findByText(/boom/)).toBeInTheDocument();
}); });
test('delete button calls ipc.crash.delete', async () => { test('delete button calls ipc.crash.delete', async () => {
(ipc.crash.read as any).mockResolvedValue([ (ipc.crash.read as any).mockResolvedValue(
wrap([
{ {
filename: '1700000000000-1-uncaughtException.json', filename: '1700000000000-1-uncaughtException.json',
kind: 'uncaughtException', kind: 'uncaughtException',
message: 'boom', message: 'boom',
timestamp: '2026-01-01T00:00:00.000Z', timestamp: '2026-01-01T00:00:00.000Z',
}, },
]); ])
);
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
const btn = await screen.findByText(/delete/i); const btn = await screen.findByText(/delete/i);
fireEvent.click(btn); fireEvent.click(btn);
@@ -47,7 +69,7 @@ describe('CrashReportModal', () => {
}); });
test('open folder button calls ipc.crash.openDir', async () => { test('open folder button calls ipc.crash.openDir', async () => {
(ipc.crash.read as any).mockResolvedValue([]); (ipc.crash.read as any).mockResolvedValue(wrap([]));
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
fireEvent.click(await screen.findByText(/open dump folder/i)); fireEvent.click(await screen.findByText(/open dump folder/i));
expect(ipc.crash.openDir).toHaveBeenCalled(); expect(ipc.crash.openDir).toHaveBeenCalled();
@@ -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);
});
});