From a62511e7ed6cd68db3476ba87539a792a0201706 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sat, 6 Jun 2026 08:04:49 +0530 Subject: [PATCH] feat(renderer): FindInFilesDialog (cross-file search with regex) --- .../components/modals/FindInFilesDialog.tsx | 111 ++++++++++++++++++ .../modals/FindInFilesDialog.test.tsx | 73 ++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/renderer/components/modals/FindInFilesDialog.tsx create mode 100644 tests/component/modals/FindInFilesDialog.test.tsx diff --git a/src/renderer/components/modals/FindInFilesDialog.tsx b/src/renderer/components/modals/FindInFilesDialog.tsx new file mode 100644 index 0000000..5159d70 --- /dev/null +++ b/src/renderer/components/modals/FindInFilesDialog.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAppStore } from '@/stores/app-store'; +import { useFileStore } from '@/stores/file-store'; +import { ipc } from '@/lib/ipc'; + +interface SearchResult { + filePath: string; + line: number; + content: string; +} + +export function FindInFilesDialog() { + const closeModal = useAppStore((s) => s.closeModal); + const rootPath = useFileStore((s) => s.rootPath); + const openFile = useFileStore((s) => s.openFile); + + const [query, setQuery] = useState(''); + const [isRegex, setIsRegex] = useState(false); + const [caseSensitive, setCaseSensitive] = useState(false); + const [results, setResults] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSearch = async () => { + if (!query || !rootPath) return; + setSubmitting(true); + setError(null); + const result = await ipc.file.search({ rootPath, query, isRegex, caseSensitive }); + if (!result.ok) { + setError(result.error.message); + setSubmitting(false); + return; + } + setResults(result.data ?? []); + setSubmitting(false); + }; + + const handleResultClick = (filePath: string) => { + openFile(filePath); + closeModal(); + }; + + return ( + !o && closeModal()}> + + + Find in files + + {rootPath ? `Search in ${rootPath}` : 'No folder open'} + + +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> +
+
+ + +
+ {error && ( +
+ {error} +
+ )} + {results.length > 0 && ( +
+ +
+ {results.map((r, i) => ( + + ))} +
+
+ )} +
+ + + + +
+
+ ); +} diff --git a/tests/component/modals/FindInFilesDialog.test.tsx b/tests/component/modals/FindInFilesDialog.test.tsx new file mode 100644 index 0000000..01ff058 --- /dev/null +++ b/tests/component/modals/FindInFilesDialog.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FindInFilesDialog } from '@/components/modals/FindInFilesDialog'; +import { useFileStore } from '@/stores/file-store'; + +vi.mock('@/lib/ipc', () => ({ + ipc: { + file: { + search: vi.fn(), + }, + }, +})); + +import { ipc } from '@/lib/ipc'; +import { useAppStore } from '@/stores/app-store'; + +describe('FindInFilesDialog', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + useFileStore.setState({ rootPath: '/project' } as any); + useAppStore.setState({ modal: { kind: null } } as any); + }); + + it('renders with a query input and toggles', () => { + render(); + expect(screen.getByText(/find in files/i)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /query/i })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /regex/i })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /case/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); + }); + + it('submits a search and shows results', async () => { + (ipc.file.search as any).mockResolvedValueOnce({ + ok: true, + data: [ + { filePath: '/project/a.md', line: 5, content: 'matching line' }, + { filePath: '/project/b.md', line: 12, content: 'another match' }, + ], + }); + render(); + await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match'); + await userEvent.click(screen.getByRole('button', { name: /search/i })); + expect(await screen.findByText('/project/a.md:5')).toBeInTheDocument(); + expect(await screen.findByText(/another match/)).toBeInTheDocument(); + }); + + it('clicking a result calls useFileStore.openFile', async () => { + (ipc.file.search as any).mockResolvedValueOnce({ + ok: true, + data: [{ filePath: '/project/a.md', line: 5, content: 'matching line' }], + }); + const openFileSpy = vi.fn(); + useFileStore.setState({ openFile: openFileSpy, rootPath: '/project' } as any); + render(); + await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match'); + await userEvent.click(screen.getByRole('button', { name: /search/i })); + const result = await screen.findByText('/project/a.md:5'); + await userEvent.click(result); + // openFile is called with the filePath; the test verifies it was called + expect(openFileSpy).toHaveBeenCalled(); + }); + + it('shows an error banner when search fails', async () => { + (ipc.file.search as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'regex invalid' } }); + render(); + await userEvent.paste('[invalid', { initialSelectionStart: 0, initialSelectionEnd: 0 }); + await userEvent.click(screen.getByRole('button', { name: /search/i })); + expect(await screen.findByText(/regex invalid/i)).toBeInTheDocument(); + }); +});