Skip to content

Feature: Enable find-in-page functionality (Cmd/Ctrl+F) for searching within the current view #31

@areibman

Description

@areibman

Feature Request

Enable the standard find-in-page functionality (Cmd/Ctrl+F) in the Electron application to allow users to search within the current page/view, as this is not enabled by default in Electron.

Description

Electron applications don't have find-in-page functionality enabled by default. Users expect to be able to use Cmd+F (macOS) or Ctrl+F (Windows/Linux) to search within the current page, especially when viewing PRs, code, or long comment threads.

Implementation Details

1. Main Process Implementation

Enable Find-in-Page in Main Process

// main/main.ts
import { app, BrowserWindow, ipcMain, dialog } from 'electron';

class FindInPage {
  private currentWindow: BrowserWindow | null = null;
  private searchVisible: boolean = false;
  
  initialize(window: BrowserWindow) {
    this.currentWindow = window;
    this.registerShortcuts();
    this.setupIPC();
  }
  
  private registerShortcuts() {
    // Register global shortcuts for the window
    this.currentWindow?.webContents.on('before-input-event', (event, input) => {
      // Cmd/Ctrl + F to open search
      if ((input.control || input.meta) && input.key === 'f' && input.type === 'keyDown') {
        event.preventDefault();
        this.toggleSearch();
      }
      
      // Escape to close search
      if (input.key === 'Escape' && input.type === 'keyDown' && this.searchVisible) {
        event.preventDefault();
        this.closeSearch();
      }
      
      // Cmd/Ctrl + G for find next
      if ((input.control || input.meta) && input.key === 'g' && input.type === 'keyDown') {
        event.preventDefault();
        if (input.shift) {
          this.findPrevious();
        } else {
          this.findNext();
        }
      }
      
      // F3 for find next (Windows/Linux)
      if (input.key === 'F3' && input.type === 'keyDown') {
        event.preventDefault();
        if (input.shift) {
          this.findPrevious();
        } else {
          this.findNext();
        }
      }
    });
  }
  
  private toggleSearch() {
    if (this.searchVisible) {
      this.closeSearch();
    } else {
      this.openSearch();
    }
  }
  
  private openSearch() {
    this.searchVisible = true;
    this.currentWindow?.webContents.send('toggle-search', true);
  }
  
  private closeSearch() {
    this.searchVisible = false;
    this.currentWindow?.webContents.stopFindInPage('clearSelection');
    this.currentWindow?.webContents.send('toggle-search', false);
  }
  
  private findNext() {
    this.currentWindow?.webContents.findInPage(this.lastSearchTerm, {
      forward: true,
      findNext: true
    });
  }
  
  private findPrevious() {
    this.currentWindow?.webContents.findInPage(this.lastSearchTerm, {
      forward: false,
      findNext: true
    });
  }
  
  private lastSearchTerm: string = '';
  
  private setupIPC() {
    // Handle search requests from renderer
    ipcMain.on('find-in-page', (event, searchTerm: string, options?: Electron.FindInPageOptions) => {
      if (!this.currentWindow) return;
      
      this.lastSearchTerm = searchTerm;
      
      if (searchTerm === '') {
        this.currentWindow.webContents.stopFindInPage('clearSelection');
      } else {
        this.currentWindow.webContents.findInPage(searchTerm, options || {});
      }
    });
    
    // Handle search result updates
    this.currentWindow?.webContents.on('found-in-page', (event, result) => {
      this.currentWindow?.webContents.send('found-in-page-result', result);
    });
    
    // Handle close search
    ipcMain.on('close-find-in-page', () => {
      this.closeSearch();
    });
  }
}

// Initialize when creating window
function createWindow() {
  const mainWindow = new BrowserWindow({
    // ... window options
  });
  
  const findInPage = new FindInPage();
  findInPage.initialize(mainWindow);
}

2. Renderer Process Search UI

Search Bar Component

// renderer/components/FindInPage.tsx
import React, { useState, useEffect, useRef } from 'react';
import { ipcRenderer } from 'electron';

interface FindInPageResult {
  requestId: number;
  activeMatchOrdinal: number;
  matches: number;
  finalUpdate: boolean;
}

export function FindInPageBar() {
  const [visible, setVisible] = useState(false);
  const [searchTerm, setSearchTerm] = useState('');
  const [result, setResult] = useState<FindInPageResult | null>(null);
  const [caseSensitive, setCaseSensitive] = useState(false);
  const [wholeWord, setWholeWord] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // Listen for toggle events from main process
    const handleToggle = (event: any, show: boolean) => {
      setVisible(show);
      if (show) {
        setTimeout(() => inputRef.current?.focus(), 0);
        // Select all text when opening
        setTimeout(() => inputRef.current?.select(), 0);
      }
    };
    
    // Listen for search results
    const handleResult = (event: any, result: FindInPageResult) => {
      setResult(result);
    };
    
    ipcRenderer.on('toggle-search', handleToggle);
    ipcRenderer.on('found-in-page-result', handleResult);
    
    return () => {
      ipcRenderer.removeListener('toggle-search', handleToggle);
      ipcRenderer.removeListener('found-in-page-result', handleResult);
    };
  }, []);
  
  const handleSearch = (term: string) => {
    setSearchTerm(term);
    
    if (term) {
      ipcRenderer.send('find-in-page', term, {
        forward: true,
        findNext: false,
        matchCase: caseSensitive,
        wordStart: wholeWord
      });
    } else {
      ipcRenderer.send('find-in-page', '');
      setResult(null);
    }
  };
  
  const findNext = () => {
    if (searchTerm) {
      ipcRenderer.send('find-in-page', searchTerm, {
        forward: true,
        findNext: true,
        matchCase: caseSensitive,
        wordStart: wholeWord
      });
    }
  };
  
  const findPrevious = () => {
    if (searchTerm) {
      ipcRenderer.send('find-in-page', searchTerm, {
        forward: false,
        findNext: true,
        matchCase: caseSensitive,
        wordStart: wholeWord
      });
    }
  };
  
  const close = () => {
    ipcRenderer.send('close-find-in-page');
    setSearchTerm('');
    setResult(null);
  };
  
  if (!visible) return null;
  
  return (
    <div className="find-in-page-bar">
      <div className="search-container">
        <input
          ref={inputRef}
          type="text"
          value={searchTerm}
          onChange={(e) => handleSearch(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              if (e.shiftKey) {
                findPrevious();
              } else {
                findNext();
              }
            } else if (e.key === 'Escape') {
              close();
            }
          }}
          placeholder="Find in page..."
          className="search-input"
        />
        
        <div className="search-results">
          {result && result.matches > 0 ? (
            <span className="result-count">
              {result.activeMatchOrdinal} of {result.matches}
            </span>
          ) : searchTerm ? (
            <span className="no-results">No results</span>
          ) : null}
        </div>
        
        <div className="search-controls">
          <button
            onClick={findPrevious}
            disabled={!searchTerm || !result?.matches}
            className="nav-button"
            title="Previous (Shift+Enter)"
          ></button>
          
          <button
            onClick={findNext}
            disabled={!searchTerm || !result?.matches}
            className="nav-button"
            title="Next (Enter)"
          ></button>
          
          <div className="search-options">
            <button
              onClick={() => setCaseSensitive(!caseSensitive)}
              className={`option-button ${caseSensitive ? 'active' : ''}`}
              title="Match Case"
            >
              Aa
            </button>
            
            <button
              onClick={() => setWholeWord(!wholeWord)}
              className={`option-button ${wholeWord ? 'active' : ''}`}
              title="Whole Word"
            >
              W
            </button>
          </div>
          
          <button
            onClick={close}
            className="close-button"
            title="Close (Esc)"
          ></button>
        </div>
      </div>
    </div>
  );
}

3. Styling

Search Bar Styles

.find-in-page-bar {
  position: fixed;
  top: 0;
  right: 20px;
  z-index: 9999;
  background: var(--bg-elevated);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  padding: 8px;
  display: flex;
  align-items: center;
  animation: slideDown 0.2s ease-out;
}

@keyframes slideDown {
  from {
    transform: translateY(-100%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

.search-container {
  display: flex;
  align-items: center;
  gap: 8px;
}

.search-input {
  width: 200px;
  padding: 4px 8px;
  border: 1px solid var(--border-color);
  border-radius: 3px;
  font-size: 13px;
  outline: none;
}

.search-input:focus {
  border-color: var(--focus-color);
  box-shadow: 0 0 0 2px var(--focus-shadow);
}

.search-results {
  color: var(--text-secondary);
  font-size: 12px;
  min-width: 60px;
}

.no-results {
  color: var(--text-danger);
}

.search-controls {
  display: flex;
  align-items: center;
  gap: 4px;
}

.nav-button,
.option-button,
.close-button {
  padding: 4px 8px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 3px;
  cursor: pointer;
  font-size: 12px;
  transition: all 0.2s;
}

.nav-button:hover:not(:disabled),
.option-button:hover,
.close-button:hover {
  background: var(--hover-bg);
}

.nav-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.option-button.active {
  background: var(--primary-color);
  color: white;
}

.search-options {
  display: flex;
  gap: 2px;
  margin: 0 4px;
  padding: 0 4px;
  border-left: 1px solid var(--border-color);
  border-right: 1px solid var(--border-color);
}

/* Highlight styles for found text */
::selection {
  background-color: var(--selection-bg);
}

.find-highlight {
  background-color: yellow !important;
  color: black !important;
}

.find-highlight-current {
  background-color: orange !important;
  color: black !important;
}

4. Menu Bar Integration

Add to Application Menu

// main/menu.ts
import { Menu, MenuItemConstructorOptions } from 'electron';

function createApplicationMenu(window: BrowserWindow): Menu {
  const template: MenuItemConstructorOptions[] = [
    // ... other menu items
    {
      label: 'Edit',
      submenu: [
        { role: 'undo' },
        { role: 'redo' },
        { type: 'separator' },
        { role: 'cut' },
        { role: 'copy' },
        { role: 'paste' },
        { type: 'separator' },
        {
          label: 'Find',
          accelerator: 'CmdOrCtrl+F',
          click: () => {
            window.webContents.send('toggle-search', true);
          }
        },
        {
          label: 'Find Next',
          accelerator: 'CmdOrCtrl+G',
          click: () => {
            window.webContents.send('find-next');
          }
        },
        {
          label: 'Find Previous',
          accelerator: 'CmdOrCtrl+Shift+G',
          click: () => {
            window.webContents.send('find-previous');
          }
        }
      ]
    }
    // ... other menu items
  ];
  
  return Menu.buildFromTemplate(template);
}

5. Advanced Features

Context-Aware Search

class ContextAwareSearch {
  // Search within specific areas
  searchInComments() {
    this.setSearchScope('.comment-body');
  }
  
  searchInCode() {
    this.setSearchScope('.code-block, .diff-view');
  }
  
  searchInFiles() {
    this.setSearchScope('.file-content');
  }
  
  // Regex support
  enableRegexSearch(pattern: string) {
    try {
      const regex = new RegExp(pattern, 'gi');
      this.performRegexSearch(regex);
    } catch (e) {
      console.error('Invalid regex:', e);
    }
  }
  
  // Search history
  private searchHistory: string[] = [];
  
  addToHistory(term: string) {
    if (!this.searchHistory.includes(term)) {
      this.searchHistory.unshift(term);
      this.searchHistory = this.searchHistory.slice(0, 20);
    }
  }
}

6. Keyboard Shortcuts Summary

const shortcuts = {
  'CmdOrCtrl+F': 'Open find bar',
  'Escape': 'Close find bar',
  'Enter': 'Find next',
  'Shift+Enter': 'Find previous',
  'CmdOrCtrl+G': 'Find next',
  'CmdOrCtrl+Shift+G': 'Find previous',
  'F3': 'Find next (Windows/Linux)',
  'Shift+F3': 'Find previous (Windows/Linux)',
  'Alt+C': 'Toggle case sensitive',
  'Alt+W': 'Toggle whole word'
};

Benefits

  • Standard UX: Users expect Cmd/Ctrl+F to work
  • Improved Navigation: Quickly find content in long pages
  • Better Accessibility: Helps users locate specific information
  • Productivity: Faster than manual scrolling
  • Familiar Interface: Similar to browser find functionality

Acceptance Criteria

  • Cmd/Ctrl+F opens find bar
  • Search highlights all matches on page
  • Current match is highlighted differently
  • Next/Previous navigation works
  • Match counter shows X of Y results
  • Escape closes find bar
  • Case sensitive option works
  • Whole word option works
  • Search persists when navigating next/previous
  • Found text is scrolled into view
  • Search bar has smooth animation
  • Keyboard shortcuts work as expected
  • Menu bar items trigger search
  • Search works across all views (PRs, issues, code)
  • Performance is good with large documents

Resources

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions