Skip to content

Commit dcf9f99

Browse files
committed
Add folder search with terminal-style results
- Add search input to folders sidebar section - Implement ripgrep-based folder search across all opened folders - Display results in terminal format (file:line: content) - Highlight search matches in results - Click result to open file at matching line - Support search progress updates and cancellation
1 parent 9c20a19 commit dcf9f99

7 files changed

Lines changed: 434 additions & 0 deletions

File tree

src/main/index.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,127 @@ ipcMain.handle(IPC.READ_FOLDER, async (_, folderPath: string) => {
355355
}
356356
});
357357

358+
// === Folder Search ===
359+
let folderSearchSignal: { cancelled: boolean } = { cancelled: false };
360+
361+
ipcMain.handle(IPC.FOLDER_SEARCH, async (_, folderPaths: string[], pattern: string, options: { isRegex: boolean; matchCase: boolean }) => {
362+
folderSearchSignal = { cancelled: false };
363+
364+
if (!folderPaths.length || !pattern) {
365+
return { success: false, error: 'No folders or pattern provided' };
366+
}
367+
368+
const matches: Array<{ filePath: string; fileName: string; lineNumber: number; column: number; lineText: string }> = [];
369+
const MAX_MATCHES = 1000;
370+
371+
try {
372+
// Build ripgrep arguments
373+
const args: string[] = [
374+
'--line-number',
375+
'--column',
376+
'--no-heading',
377+
'--with-filename',
378+
'--max-count', '100', // Limit matches per file
379+
];
380+
381+
if (!options.matchCase) {
382+
args.push('--ignore-case');
383+
}
384+
385+
if (options.isRegex) {
386+
args.push('--regexp', pattern);
387+
} else {
388+
args.push('--fixed-strings', pattern);
389+
}
390+
391+
// Add file type filters for text files
392+
for (const ext of TEXT_EXTENSIONS) {
393+
args.push('--glob', `*${ext}`);
394+
}
395+
args.push('--glob', '!.*'); // Exclude hidden files
396+
397+
// Add folder paths
398+
args.push(...folderPaths);
399+
400+
return new Promise((resolve) => {
401+
const proc = spawn('rg', args);
402+
let buffer = '';
403+
let lastProgressUpdate = 0;
404+
405+
proc.stdout.on('data', (data: Buffer) => {
406+
if (folderSearchSignal.cancelled) {
407+
proc.kill();
408+
return;
409+
}
410+
411+
buffer += data.toString();
412+
const lines = buffer.split('\n');
413+
buffer = lines.pop() || '';
414+
415+
for (const line of lines) {
416+
if (!line) continue;
417+
418+
// Parse ripgrep output: filename:line:column:text
419+
const colonIndex1 = line.indexOf(':');
420+
if (colonIndex1 === -1) continue;
421+
422+
const colonIndex2 = line.indexOf(':', colonIndex1 + 1);
423+
if (colonIndex2 === -1) continue;
424+
425+
const colonIndex3 = line.indexOf(':', colonIndex2 + 1);
426+
if (colonIndex3 === -1) continue;
427+
428+
const filePath = line.substring(0, colonIndex1);
429+
const lineNum = parseInt(line.substring(colonIndex1 + 1, colonIndex2), 10);
430+
const column = parseInt(line.substring(colonIndex2 + 1, colonIndex3), 10);
431+
const lineText = line.substring(colonIndex3 + 1);
432+
433+
if (isNaN(lineNum) || isNaN(column)) continue;
434+
435+
matches.push({
436+
filePath,
437+
fileName: path.basename(filePath),
438+
lineNumber: lineNum,
439+
column: column - 1,
440+
lineText: lineText.length > 500 ? lineText.substring(0, 500) + '...' : lineText,
441+
});
442+
443+
if (matches.length >= MAX_MATCHES) {
444+
proc.kill();
445+
break;
446+
}
447+
}
448+
449+
// Send progress updates
450+
const now = Date.now();
451+
if (now - lastProgressUpdate > 100) {
452+
lastProgressUpdate = now;
453+
mainWindow?.webContents.send(IPC.FOLDER_SEARCH_PROGRESS, { matchCount: matches.length });
454+
}
455+
});
456+
457+
proc.on('error', () => {
458+
resolve({ success: false, error: 'ripgrep not available. Install with: brew install ripgrep' });
459+
});
460+
461+
proc.on('close', () => {
462+
if (folderSearchSignal.cancelled) {
463+
resolve({ success: true, matches, cancelled: true });
464+
} else {
465+
resolve({ success: true, matches });
466+
}
467+
});
468+
});
469+
} catch (error) {
470+
return { success: false, error: String(error) };
471+
}
472+
});
473+
474+
ipcMain.handle(IPC.FOLDER_SEARCH_CANCEL, async () => {
475+
folderSearchSignal.cancelled = true;
476+
return { success: true };
477+
});
478+
358479
// === Column Analysis ===
359480

360481
interface ColumnInfo {

src/preload/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const IPC = {
1010
SEARCH_CANCEL: 'search-cancel',
1111
OPEN_FOLDER_DIALOG: 'open-folder-dialog',
1212
READ_FOLDER: 'read-folder',
13+
FOLDER_SEARCH: 'folder-search',
14+
FOLDER_SEARCH_PROGRESS: 'folder-search-progress',
15+
FOLDER_SEARCH_CANCEL: 'folder-search-cancel',
1316
} as const;
1417

1518
// API exposed to renderer
@@ -31,6 +34,19 @@ const api = {
3134
readFolder: (folderPath: string): Promise<{ success: boolean; files?: Array<{ name: string; path: string; isDirectory: boolean; size?: number }>; folderPath?: string; error?: string }> =>
3235
ipcRenderer.invoke(IPC.READ_FOLDER, folderPath),
3336

37+
// Folder search
38+
folderSearch: (folderPaths: string[], pattern: string, options: { isRegex: boolean; matchCase: boolean }): Promise<{ success: boolean; matches?: Array<{ filePath: string; fileName: string; lineNumber: number; column: number; lineText: string }>; cancelled?: boolean; error?: string }> =>
39+
ipcRenderer.invoke(IPC.FOLDER_SEARCH, folderPaths, pattern, options),
40+
41+
cancelFolderSearch: (): Promise<{ success: boolean }> =>
42+
ipcRenderer.invoke(IPC.FOLDER_SEARCH_CANCEL),
43+
44+
onFolderSearchProgress: (callback: (data: { matchCount: number }) => void): (() => void) => {
45+
const handler = (_: any, data: { matchCount: number }) => callback(data);
46+
ipcRenderer.on(IPC.FOLDER_SEARCH_PROGRESS, handler);
47+
return () => ipcRenderer.removeListener(IPC.FOLDER_SEARCH_PROGRESS, handler);
48+
},
49+
3450
getFileInfo: (): Promise<{ success: boolean; info?: any; error?: string }> =>
3551
ipcRenderer.invoke('get-file-info'),
3652

src/renderer/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@
6161
<button id="btn-add-folder" class="section-btn" title="Open folder">+</button>
6262
</div>
6363
<div class="section-content">
64+
<div class="folder-search-container">
65+
<input type="text" id="folder-search-input" class="folder-search-input" placeholder="Search in folders..." disabled>
66+
<button id="btn-folder-search" class="folder-search-btn" title="Search" disabled>&#128269;</button>
67+
<button id="btn-folder-search-cancel" class="folder-search-btn hidden" title="Cancel">&#10005;</button>
68+
</div>
69+
<div id="folder-search-results" class="folder-search-results hidden"></div>
6470
<div id="folders-list" class="folders-content">
6571
<p class="placeholder">No folders open</p>
6672
</div>

src/renderer/renderer.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ interface AppState {
165165
columnConfig: ColumnConfig | null;
166166
// Notes file tracking
167167
currentNotesFile: string | null;
168+
// Folder search
169+
folderSearchResults: FolderSearchMatch[];
170+
isFolderSearching: boolean;
168171
}
169172

170173
const state: AppState = {
@@ -191,6 +194,8 @@ const state: AppState = {
191194
folders: [],
192195
columnConfig: null,
193196
currentNotesFile: null,
197+
folderSearchResults: [],
198+
isFolderSearching: false,
194199
};
195200

196201
// Constants
@@ -247,6 +252,10 @@ const elements = {
247252
welcomeMessage: document.getElementById('welcome-message') as HTMLDivElement,
248253
foldersList: document.getElementById('folders-list') as HTMLDivElement,
249254
btnAddFolder: document.getElementById('btn-add-folder') as HTMLButtonElement,
255+
folderSearchInput: document.getElementById('folder-search-input') as HTMLInputElement,
256+
btnFolderSearch: document.getElementById('btn-folder-search') as HTMLButtonElement,
257+
btnFolderSearchCancel: document.getElementById('btn-folder-search-cancel') as HTMLButtonElement,
258+
folderSearchResults: document.getElementById('folder-search-results') as HTMLDivElement,
250259
fileStats: document.getElementById('file-stats') as HTMLDivElement,
251260
analysisResults: document.getElementById('analysis-results') as HTMLDivElement,
252261
analysisProgress: document.getElementById('analysis-progress') as HTMLDivElement,
@@ -1757,12 +1766,17 @@ async function openFolder(): Promise<void> {
17571766
collapsed: false,
17581767
});
17591768
renderFolderTree();
1769+
updateFolderSearchState();
17601770
}
17611771
}
17621772

17631773
function removeFolder(folderPath: string): void {
17641774
state.folders = state.folders.filter((f) => f.path !== folderPath);
17651775
renderFolderTree();
1776+
updateFolderSearchState();
1777+
if (state.folders.length === 0) {
1778+
closeFolderSearchResults();
1779+
}
17661780
}
17671781

17681782
function toggleFolder(folderPath: string): void {
@@ -1849,6 +1863,114 @@ function renderFolderTree(): void {
18491863
});
18501864
}
18511865

1866+
// === Folder Search ===
1867+
1868+
function updateFolderSearchState(): void {
1869+
const hasFolders = state.folders.length > 0;
1870+
elements.folderSearchInput.disabled = !hasFolders;
1871+
elements.btnFolderSearch.disabled = !hasFolders;
1872+
}
1873+
1874+
async function performFolderSearch(): Promise<void> {
1875+
const pattern = elements.folderSearchInput.value.trim();
1876+
if (!pattern || state.folders.length === 0) return;
1877+
1878+
state.isFolderSearching = true;
1879+
state.folderSearchResults = [];
1880+
1881+
elements.btnFolderSearch.classList.add('hidden');
1882+
elements.btnFolderSearchCancel.classList.remove('hidden');
1883+
elements.folderSearchResults.classList.remove('hidden');
1884+
elements.folderSearchResults.innerHTML = '<div class="folder-search-searching">Searching...</div>';
1885+
1886+
const unsubscribe = window.api.onFolderSearchProgress((data) => {
1887+
if (state.isFolderSearching) {
1888+
elements.folderSearchResults.innerHTML = `<div class="folder-search-searching">Searching... ${data.matchCount} matches found</div>`;
1889+
}
1890+
});
1891+
1892+
try {
1893+
const folderPaths = state.folders.map(f => f.path);
1894+
const result = await window.api.folderSearch(folderPaths, pattern, { isRegex: false, matchCase: false });
1895+
1896+
if (result.success && result.matches) {
1897+
state.folderSearchResults = result.matches;
1898+
renderFolderSearchResults(pattern, result.cancelled);
1899+
} else {
1900+
elements.folderSearchResults.innerHTML = `<div class="folder-search-searching">${result.error || 'Search failed'}</div>`;
1901+
}
1902+
} catch (error) {
1903+
elements.folderSearchResults.innerHTML = `<div class="folder-search-searching">Error: ${error}</div>`;
1904+
} finally {
1905+
unsubscribe();
1906+
state.isFolderSearching = false;
1907+
elements.btnFolderSearch.classList.remove('hidden');
1908+
elements.btnFolderSearchCancel.classList.add('hidden');
1909+
}
1910+
}
1911+
1912+
function cancelFolderSearch(): void {
1913+
if (state.isFolderSearching) {
1914+
window.api.cancelFolderSearch();
1915+
}
1916+
}
1917+
1918+
function closeFolderSearchResults(): void {
1919+
state.folderSearchResults = [];
1920+
elements.folderSearchResults.classList.add('hidden');
1921+
elements.folderSearchResults.innerHTML = '';
1922+
}
1923+
1924+
function highlightMatch(text: string, pattern: string): string {
1925+
const escaped = escapeHtml(text);
1926+
const patternEscaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1927+
const regex = new RegExp(`(${patternEscaped})`, 'gi');
1928+
return escaped.replace(regex, '<span class="folder-search-match">$1</span>');
1929+
}
1930+
1931+
function renderFolderSearchResults(pattern: string, cancelled?: boolean): void {
1932+
const matches = state.folderSearchResults;
1933+
1934+
if (matches.length === 0) {
1935+
elements.folderSearchResults.innerHTML = '<div class="folder-search-searching">No matches found</div>';
1936+
return;
1937+
}
1938+
1939+
const header = `
1940+
<div class="folder-search-header">
1941+
<span>${matches.length}${cancelled ? '+' : ''} match${matches.length !== 1 ? 'es' : ''}</span>
1942+
<button class="folder-search-close" title="Close">&times;</button>
1943+
</div>
1944+
`;
1945+
1946+
const items = matches.map((match, index) => {
1947+
const relPath = match.filePath;
1948+
const lineText = match.lineText.length > 200 ? match.lineText.substring(0, 200) + '...' : match.lineText;
1949+
1950+
return `
1951+
<div class="folder-search-item" data-index="${index}">
1952+
<span class="folder-search-file">${escapeHtml(match.fileName)}</span>:<span class="folder-search-line">${match.lineNumber}</span>: <span class="folder-search-text">${highlightMatch(lineText, pattern)}</span>
1953+
</div>
1954+
`;
1955+
}).join('');
1956+
1957+
elements.folderSearchResults.innerHTML = header + items;
1958+
1959+
// Add event listeners
1960+
elements.folderSearchResults.querySelector('.folder-search-close')?.addEventListener('click', closeFolderSearchResults);
1961+
1962+
elements.folderSearchResults.querySelectorAll('.folder-search-item').forEach((item) => {
1963+
item.addEventListener('click', async () => {
1964+
const index = parseInt((item as HTMLElement).dataset.index || '0', 10);
1965+
const match = state.folderSearchResults[index];
1966+
if (match) {
1967+
await loadFile(match.filePath);
1968+
goToLine(match.lineNumber - 1); // Convert to 0-based
1969+
}
1970+
});
1971+
});
1972+
}
1973+
18521974
async function loadFile(filePath: string, createNewTab: boolean = true): Promise<void> {
18531975
// Check if file is already open in a tab BEFORE calling backend
18541976
const existingTab = findTabByFilePath(filePath);
@@ -3374,6 +3496,17 @@ function init(): void {
33743496
// Folder operations
33753497
elements.btnAddFolder.addEventListener('click', openFolder);
33763498

3499+
// Folder search
3500+
elements.btnFolderSearch.addEventListener('click', performFolderSearch);
3501+
elements.btnFolderSearchCancel.addEventListener('click', cancelFolderSearch);
3502+
elements.folderSearchInput.addEventListener('keydown', (e) => {
3503+
if (e.key === 'Enter') {
3504+
performFolderSearch();
3505+
} else if (e.key === 'Escape') {
3506+
closeFolderSearchResults();
3507+
}
3508+
});
3509+
33773510
// Search
33783511
elements.btnSearch.addEventListener('click', performSearch);
33793512
elements.btnPrevResult.addEventListener('click', () => {

0 commit comments

Comments
 (0)