""" RoadPad Fuzzy Finder - Quick file/command search. Features: - Fuzzy matching - File finder (Ctrl+P) - Command palette (Ctrl+Shift+P) - Buffer switcher - Recent files """ import os from dataclasses import dataclass, field from typing import List, Callable, Any import re @dataclass class FuzzyMatch: """A fuzzy match result.""" item: Any score: int matches: List[int] = field(default_factory=list) # Matched character positions display: str = "" def fuzzy_match(pattern: str, text: str) -> FuzzyMatch | None: """Fuzzy match pattern against text. Returns match with score or None.""" if not pattern: return FuzzyMatch(item=text, score=0, display=text) pattern_lower = pattern.lower() text_lower = text.lower() # Find all matching positions pattern_idx = 0 matches = [] score = 0 for i, char in enumerate(text_lower): if pattern_idx < len(pattern_lower) and char == pattern_lower[pattern_idx]: matches.append(i) pattern_idx += 1 # Scoring if i == 0: score += 10 # Start of string elif text[i - 1] in "/_-. ": score += 8 # Start of word elif text[i].isupper() and (i == 0 or text[i-1].islower()): score += 6 # CamelCase boundary else: score += 1 # Consecutive bonus if len(matches) > 1 and matches[-1] == matches[-2] + 1: score += 3 # Must match all characters if pattern_idx < len(pattern_lower): return None # Penalty for length score -= len(text) // 10 return FuzzyMatch(item=text, score=score, matches=matches, display=text) def fuzzy_sort(pattern: str, items: List[str]) -> List[FuzzyMatch]: """Sort items by fuzzy match score.""" matches = [] for item in items: match = fuzzy_match(pattern, item) if match: matches.append(match) return sorted(matches, key=lambda m: -m.score) @dataclass class FinderItem: """An item in the finder.""" text: str kind: str = "" # 'file', 'command', 'buffer', 'recent' data: Any = None icon: str = "" class FuzzyFinder: """Fuzzy finder popup.""" def __init__(self): self.items: List[FinderItem] = [] self.filtered: List[FuzzyMatch] = [] self.query: str = "" self.selected_index: int = 0 self.visible: bool = False self.title: str = "Find" self.max_results: int = 20 self.on_select: Callable[[FinderItem], None] | None = None self.on_cancel: Callable[[], None] | None = None def open(self, items: List[FinderItem], title: str = "Find") -> None: """Open finder with items.""" self.items = items self.title = title self.query = "" self.selected_index = 0 self.visible = True self._filter() def close(self) -> None: """Close finder.""" self.visible = False self.query = "" if self.on_cancel: self.on_cancel() def _filter(self) -> None: """Filter items by query.""" if not self.query: self.filtered = [ FuzzyMatch(item=item, score=0, display=item.text) for item in self.items[:self.max_results] ] else: matches = [] for item in self.items: match = fuzzy_match(self.query, item.text) if match: match.item = item matches.append(match) matches.sort(key=lambda m: -m.score) self.filtered = matches[:self.max_results] if self.selected_index >= len(self.filtered): self.selected_index = max(0, len(self.filtered) - 1) def type_char(self, char: str) -> None: """Add character to query.""" self.query += char self._filter() def backspace(self) -> None: """Remove last character.""" if self.query: self.query = self.query[:-1] self._filter() def clear_query(self) -> None: """Clear query.""" self.query = "" self._filter() def select_up(self) -> None: """Move selection up.""" if self.selected_index > 0: self.selected_index -= 1 def select_down(self) -> None: """Move selection down.""" if self.selected_index < len(self.filtered) - 1: self.selected_index += 1 def confirm(self) -> FinderItem | None: """Confirm selection.""" if not self.filtered: return None match = self.filtered[self.selected_index] item = match.item self.visible = False if self.on_select: self.on_select(item) return item def selected_item(self) -> FinderItem | None: """Get selected item.""" if 0 <= self.selected_index < len(self.filtered): return self.filtered[self.selected_index].item return None def format_popup(self, height: int = 15, width: int = 60) -> List[str]: """Format finder popup for display.""" lines = [] # Header lines.append(f"╭{'─' * (width - 2)}╮") title_line = f"│ {self.title}" title_line += " " * (width - len(title_line) - 1) + "│" lines.append(title_line) # Search input query_display = self.query or "" input_line = f"│ > {query_display}" input_line += " " * (width - len(input_line) - 1) + "│" lines.append(input_line) lines.append(f"├{'─' * (width - 2)}┤") # Results result_height = height - 5 for i, match in enumerate(self.filtered[:result_height]): item = match.item prefix = ">" if i == self.selected_index else " " icon = item.icon or "·" # Highlight matched characters display = item.text if len(display) > width - 6: display = "…" + display[-(width - 7):] line = f"│{prefix}{icon} {display}" line += " " * (width - len(line) - 1) + "│" lines.append(line) # Pad remaining space for _ in range(result_height - len(self.filtered[:result_height])): lines.append(f"│{' ' * (width - 2)}│") # Footer count = len(self.filtered) total = len(self.items) footer = f" {count}/{total} " lines.append(f"╰{'─' * ((width - len(footer) - 2) // 2)}{footer}{'─' * ((width - len(footer) - 1) // 2)}╯") return lines class FileFinder(FuzzyFinder): """File finder (Ctrl+P).""" def __init__(self, root_path: str | None = None): super().__init__() self.root_path = root_path or os.getcwd() self.title = "Find File" self.ignore_dirs = {".git", "__pycache__", "node_modules", ".venv", "dist", "build"} self.max_files = 1000 def scan(self) -> None: """Scan directory for files.""" self.items = [] count = 0 for root, dirs, files in os.walk(self.root_path): # Filter ignored directories dirs[:] = [d for d in dirs if d not in self.ignore_dirs and not d.startswith(".")] for filename in files: if filename.startswith("."): continue filepath = os.path.join(root, filename) relpath = os.path.relpath(filepath, self.root_path) # Icon by extension ext = os.path.splitext(filename)[1].lower() icon = {"py": "◇", "js": "◇", "ts": "◇", "md": "◆", "json": "○", "sh": "▪"}.get(ext[1:] if ext else "", "·") self.items.append(FinderItem( text=relpath, kind="file", data=filepath, icon=icon )) count += 1 if count >= self.max_files: return def open_finder(self) -> None: """Open file finder.""" self.scan() self.open(self.items, "Find File") class CommandFinder(FuzzyFinder): """Command palette (Ctrl+Shift+P).""" def __init__(self): super().__init__() self.title = "Command Palette" self.commands: List[FinderItem] = [] def register_command(self, name: str, handler: Callable, icon: str = ":") -> None: """Register a command.""" self.commands.append(FinderItem( text=name, kind="command", data=handler, icon=icon )) def open_palette(self) -> None: """Open command palette.""" self.open(self.commands, "Command Palette") class BufferFinder(FuzzyFinder): """Buffer switcher (Ctrl+B).""" def __init__(self): super().__init__() self.title = "Switch Buffer" def open_buffers(self, buffers: List[str]) -> None: """Open with buffer list.""" items = [ FinderItem(text=b, kind="buffer", data=b, icon="◆") for b in buffers ] self.open(items, "Switch Buffer") # Global finders _file_finder: FileFinder | None = None _command_finder: CommandFinder | None = None _buffer_finder: BufferFinder | None = None def get_file_finder() -> FileFinder: global _file_finder if _file_finder is None: _file_finder = FileFinder() return _file_finder def get_command_finder() -> CommandFinder: global _command_finder if _command_finder is None: _command_finder = CommandFinder() return _command_finder def get_buffer_finder() -> BufferFinder: global _buffer_finder if _buffer_finder is None: _buffer_finder = BufferFinder() return _buffer_finder