Files
roadpad/fuzzy.py

341 lines
9.7 KiB
Python

"""
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