mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
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:
@@ -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 {
|
||||
|
||||
@@ -30,7 +30,7 @@ function listDirectoryEntries(dirPath) {
|
||||
name: d.name,
|
||||
isDirectory: d.isDirectory(),
|
||||
size,
|
||||
modified,
|
||||
modifiedAt: modified,
|
||||
path: full,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,14 @@ interface Dump {
|
||||
export function CrashReportModal({ onClose }: { onClose: () => void }) {
|
||||
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(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
@@ -58,7 +58,8 @@ function safeCall<T extends (...args: any[]) => Promise<any>>(
|
||||
|
||||
export const ipc = {
|
||||
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>> =>
|
||||
safeCall('file', 'read', path),
|
||||
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||
@@ -82,7 +83,7 @@ export const ipc = {
|
||||
caseSensitive: boolean;
|
||||
}): Promise<
|
||||
IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>
|
||||
> => safeCall('file', 'pickFile'),
|
||||
> => safeCall('file', 'search', args),
|
||||
gitStatus: (args: {
|
||||
rootPath: string;
|
||||
}): Promise<
|
||||
|
||||
Vendored
+6
@@ -25,6 +25,12 @@ export interface ElectronAPI {
|
||||
list: (dirPath: string) => Promise<unknown>;
|
||||
pickFolder: () => 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: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface FileEntry {
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
modifiedAt?: number;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
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={() => {}} />);
|
||||
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(<CrashReportModal onClose={() => {}} />);
|
||||
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(<CrashReportModal onClose={() => {}} />);
|
||||
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(<CrashReportModal onClose={() => {}} />);
|
||||
fireEvent.click(await screen.findByText(/open dump folder/i));
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user