docs/internal/finder-abstraction.md
Status: Design Document Date: January 2026 Author: Claude (with user direction)
This document describes a unified Finder<T> abstraction for Fresh editor plugins that handle "find something and navigate to it" workflows. The design is inspired by VSCode's QuickPick API and Neovim's Telescope.nvim.
Currently, Fresh has multiple plugins that implement similar "find and navigate" functionality with significant code duplication:
Each plugin manually implements:
VSCode's QuickPick provides:
onDidChangeValue, onDidAccept, onDidChangeActive, onDidChangeSelectionitems, busy, activeItems, selectedItemsKey insight: Clean event model, but still requires manual wiring.
Telescope uses a pipeline architecture:
Finder → EntryMaker → Sorter → Previewer → Actionsfinder: Produces raw data (static or async)entry_maker: Transforms raw data to display entriessorter: Ranks entries by relevancepreviewer: Shows context for selected itemattach_mappings: Defines selection behaviorKey insight: Excellent separation of concerns, highly composable.
Fresh plugins use two distinct patterns:
| Pattern | UI | Lifecycle | Examples |
|---|---|---|---|
| Prompt-based | Transient prompt with suggestions | Opens → Search → Select → Closes | Live Grep, Git Grep, Git Find File |
| Panel-based | Persistent split panel | Opens → Navigate → Stays open | Find References, Diagnostics |
Both patterns share core behaviors:
┌─────────────────────────────────────────────────────────────┐
│ Data Sources │
├─────────────────┬─────────────────┬─────────────────────────┤
│ Search │ Filter │ Event/Live │
│ (per-query) │ (load + filter)│ (external push) │
├─────────────────┼─────────────────┼─────────────────────────┤
│ Live Grep │ Git Find File │ Find References (event) │
│ Git Grep │ │ Diagnostics (live) │
└─────────────────┴─────────────────┴─────────────────────────┘
┌─────────────────┐
│ Finder<T> │
│ (Core Logic) │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ finder.prompt() │ │ finder.panel() │ │finder.livePanel()│
│ (Prompt UI) │ │ (Panel UI) │ │ (Live Panel UI) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Live Grep Find References Diagnostics
Git Grep
Git Find File
// plugins/lib/finder.ts
/**
* Location in a file for preview and navigation
*/
interface FileLocation {
file: string;
line: number;
column?: number;
}
/**
* How a result should be displayed
*/
interface DisplayEntry {
label: string; // Primary text (e.g., "src/main.rs:42")
description?: string; // Secondary text (e.g., code snippet)
location?: FileLocation; // For preview and "go to"
}
/**
* Data source for search mode (external command per query)
*/
interface SearchSource<T> {
mode: "search";
search: (query: string) => Promise<T[]> | ProcessHandle;
debounceMs?: number; // Default: 150
minQueryLength?: number; // Default: 2
}
/**
* Data source for filter mode (load once, filter client-side)
*/
interface FilterSource<T> {
mode: "filter";
load: () => Promise<T[]>;
filter?: (items: T[], query: string) => T[]; // Default: fuzzy match
}
/**
* Main Finder configuration
*/
interface FinderConfig<T> {
/** Unique identifier (used for prompt_type, panel IDs) */
id: string;
/** Transform raw result to display format */
format: (item: T, index: number) => DisplayEntry;
/** Preview configuration (default: auto-enabled if format returns location) */
preview?: boolean | {
enabled: boolean;
contextLines?: number; // Default: 5
};
/** Maximum results to display (default: 100) */
maxResults?: number;
/** Custom selection handler (default: open file at location) */
onSelect?: (item: T, entry: DisplayEntry) => void;
/** Panel-specific: group results by file */
groupBy?: "file" | "none";
/** Panel-specific: sync cursor with editor */
syncWithEditor?: boolean;
}
/**
* Options for prompt-based display
*/
interface PromptOptions<T> {
title: string;
source: SearchSource<T> | FilterSource<T>;
}
/**
* Options for panel-based display (static data)
*/
interface PanelOptions<T> {
title: string;
items: T[];
ratio?: number; // Default: 0.3
}
/**
* Options for live panel display (provider-based)
*/
interface LivePanelOptions<T> {
title: string;
provider: Provider<T>;
ratio?: number;
}
class Finder<T> {
private config: FinderConfig<T>;
private editor: EditorAPI;
// Internal state
private results: T[] = [];
private entries: DisplayEntry[] = [];
private preview: SearchPreview | null = null;
private search: DebouncedSearch | null = null;
private isPromptMode: boolean = false;
private isPanelMode: boolean = false;
constructor(editor: EditorAPI, config: FinderConfig<T>) {
this.editor = editor;
this.config = config;
// Initialize preview if enabled
if (this.shouldEnablePreview()) {
this.preview = new SearchPreview(editor, `${config.id}-preview`);
}
}
/**
* Start interactive prompt mode
* Used for: Live Grep, Git Grep, Git Find File
*/
prompt(options: PromptOptions<T>): void {
this.isPromptMode = true;
this.registerPromptHandlers();
if (options.source.mode === "search") {
this.search = new DebouncedSearch(this.editor, {
debounceMs: options.source.debounceMs,
minQueryLength: options.source.minQueryLength,
});
} else {
// Filter mode: load items upfront
this.loadFilterItems(options.source);
}
this.editor.startPrompt(options.title, this.config.id);
}
/**
* Show static results in panel
* Used for: Find References
*/
panel(options: PanelOptions<T>): void {
this.isPanelMode = true;
this.results = options.items;
this.entries = this.results.map((item, i) => this.config.format(item, i));
this.showPanel(options.title, options.ratio);
}
/**
* Show live-updating results in panel
* Used for: Diagnostics
*/
livePanel(options: LivePanelOptions<T>): void {
this.isPanelMode = true;
options.provider.subscribe((items) => {
this.results = items;
this.entries = this.results.map((item, i) => this.config.format(item, i));
this.updatePanel();
});
this.showPanel(options.title, options.ratio);
}
/**
* Close the finder (prompt or panel)
*/
close(): void {
if (this.preview) {
this.preview.close();
}
if (this.search) {
this.search.cancel();
}
// ... cleanup handlers, close panel/prompt
}
get isOpen(): boolean {
return this.isPromptMode || this.isPanelMode;
}
// ... private implementation methods
}
import { Finder, parseGrepOutput } from "./lib/finder.ts";
const editor = getEditor();
const finder = new Finder(editor, {
id: "live-grep",
format: (match) => ({
label: `${match.file}:${match.line}`,
description: match.content.trim(),
location: { file: match.file, line: match.line, column: match.column },
}),
preview: true,
});
async function runRipgrep(query: string) {
const result = await editor.spawnProcess("rg", [
"--line-number", "--column", "--no-heading",
"--color=never", "--smart-case", "--max-count=100",
"-g", "!.git", "-g", "!node_modules",
"--", query,
]);
return result.exit_code === 0 ? parseGrepOutput(result.stdout) : [];
}
editor.registerCommand("Live Grep", "Search project with ripgrep", () => {
finder.prompt({
title: editor.t("prompt.live_grep"),
source: { mode: "search", search: runRipgrep, debounceMs: 150 },
});
}, "normal");
Estimated: ~40 lines (down from 423)
const finder = new Finder(editor, {
id: "git-grep",
format: grepFormatter, // Reuse from live_grep!
preview: true,
});
async function runGitGrep(query: string) {
const result = await editor.spawnProcess("git", ["grep", "-n", "--column", "-I", "--", query]);
return result.exit_code === 0 ? parseGrepOutput(result.stdout) : [];
}
editor.registerCommand("Git Grep", "Search with git grep", () => {
finder.prompt({
title: editor.t("prompt.grep"),
source: { mode: "search", search: runGitGrep, minQueryLength: 1 },
});
}, "normal");
Estimated: ~35 lines (down from 190)
const finder = new Finder(editor, {
id: "git-find-file",
format: (file) => ({
label: file,
location: { file, line: 1, column: 1 },
}),
preview: false,
});
async function loadGitFiles() {
const result = await editor.spawnProcess("git", ["ls-files"]);
return result.exit_code === 0 ? result.stdout.split("\n").filter(Boolean) : [];
}
editor.registerCommand("Find File", "Find file by name", () => {
finder.prompt({
title: editor.t("prompt.find_file"),
source: { mode: "filter", load: loadGitFiles }, // Uses built-in fuzzy filter
});
}, "normal");
Estimated: ~40 lines (down from 301)
const finder = new Finder(editor, {
id: "references",
format: (ref) => ({
label: `${ref.line}:${ref.column}`,
description: ref.content || "",
location: { file: ref.file, line: ref.line, column: ref.column },
}),
groupBy: "file",
syncWithEditor: true,
});
editor.on("lsp_references", (data) => {
if (data.locations.length === 0) {
editor.setStatus(`No references found for '${data.symbol}'`);
return;
}
finder.panel({
title: `References to '${data.symbol}': ${data.locations.length}`,
items: data.locations,
});
});
Estimated: ~30 lines (down from 213)
const finder = new Finder(editor, {
id: "diagnostics",
format: (d) => ({
label: `${severityIcon(d.severity)} ${d.message}`,
description: d.source || "",
location: {
file: d.uri.replace("file://", ""),
line: d.range.start.line + 1,
column: d.range.start.character + 1,
},
}),
groupBy: "file",
syncWithEditor: true,
});
const provider = createLiveProvider(() => editor.getAllDiagnostics());
editor.registerCommand("Show Diagnostics", "Show all diagnostics", () => {
finder.livePanel({
title: "Diagnostics",
provider,
});
}, "normal");
Estimated: ~35 lines (down from ~300)
| Plugin | Before | After | Reduction |
|---|---|---|---|
| live_grep.ts | 423 lines | ~40 lines | 91% |
| git_grep.ts | 190 lines | ~35 lines | 82% |
| git_find_file.ts | 301 lines | ~40 lines | 87% |
| find_references.ts | 213 lines | ~30 lines | 86% |
| diagnostics_panel.ts | ~300 lines | ~35 lines | 88% |
| Total | ~1,427 lines | ~180 lines | 87% |
| Responsibility | Before (Manual) | After (Finder Handles) |
|---|---|---|
| Register event handlers | Plugin code | Automatic |
Check prompt_type in handlers | Plugin code | Automatic |
| State management (results) | Plugin code | Automatic |
| Debouncing | Plugin code | Automatic |
| Process cancellation | Plugin code | Automatic |
| Preview panel lifecycle | Plugin code | Automatic |
| Focus management | Plugin code | Automatic |
| File opening on select | Plugin code | Automatic (default) |
| Status messages | Plugin code | Automatic |
| Cleanup on close | Plugin code | Automatic |
location is returnedSearchPreview and DebouncedSearch internallyformat receives correct type TThe Finder abstraction is NOT intended for:
These patterns have fundamentally different interaction models.
plugins/lib/finder.ts with core types and Finder class