Search: `/` or ⌘K focuses the box; results arrive grouped by kind with their
glyph, signature and file:line, ↑/↓/Enter walk them, Esc dismisses. A group
appears where its best result did, so flattening the groups reproduces the
ranking the keyboard walks — the panel's flat item list IS that concatenation.
A flow question ("how does X reach Y", "X -> Y") is recognised and searches
both endpoints with a note, rather than offering a row that would land on the
phase-2 Flow view.
Entry points answer "where do I start" on the empty screen and in the resting
palette, all derived from the graph: routes, files that run something at module
level (the engine records a top-level statement as an edge out of the file node,
which is what makes src/bin/codegraph.ts the root of the CLI flow — ranked by
calls x the files they reach, so a registration table calling into itself does
not outrank the CLI), and the most depended-on symbols. Tests are excluded from
both derived lists.
Trail: hops record the direction they were walked (→ into a call, ← up to a
caller), clicking one truncates back to it, Clear keeps the place instead of
throwing it away, and the whole walk travels in the URL. A shared or reloaded
trail arrives as ids, so hops learn their names back through a new batch
endpoint and a session name cache — without it, walking back across a
truncation redrew earlier hops as raw hashes. "Read as flow" stays hidden until
there is a Flow view to send it to.
New endpoints: /api/entrypoints and /api/nodes. New engine reads:
getTopCallingFiles, getFileDependentCounts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
5.2 KiB
TypeScript
192 lines
5.2 KiB
TypeScript
/**
|
|
* The search palette's live state.
|
|
*
|
|
* Everything that decides *what* is on screen lives in `search-model.ts` as
|
|
* plain functions; this module only owns the parts that need time: the debounce
|
|
* that keeps a fast typist from firing a request per keystroke, the abort that
|
|
* throws away an answer to a query nobody is asking any more, and the selection
|
|
* the ↑/↓ keys move.
|
|
*
|
|
* The entry points are fetched once and kept — they describe the index, not the
|
|
* query — so the palette has something to show the instant it opens.
|
|
*/
|
|
|
|
import { fetchEntryPoints, fetchSearch, type WireEntryPoints, type WireSearch } from './api';
|
|
import {
|
|
buildEntryPalette,
|
|
buildSearchPalette,
|
|
moveSelection,
|
|
parseFlowQuery,
|
|
type Palette,
|
|
type PaletteItem,
|
|
} from './search-model';
|
|
|
|
/** Results asked of the server for one search. */
|
|
const SEARCH_LIMIT = 40;
|
|
|
|
/**
|
|
* Entry-point rows fetched, and how many of them the palette shows.
|
|
*
|
|
* One fetch serves both readers: the palette wants a short list under the box,
|
|
* the empty screen wants the long one. Fetching the long list and slicing is a
|
|
* request saved and — more to the point — keeps the two lists in the same
|
|
* order, which they would not be if they were two answers taken at two times.
|
|
*/
|
|
const ENTRY_LIMIT = 24;
|
|
export const PALETTE_ENTRY_ROWS = 6;
|
|
|
|
/**
|
|
* Milliseconds of quiet before a query is sent.
|
|
*
|
|
* The server answers a search in single-digit milliseconds on this repo's own
|
|
* index, so this is not about protecting it — it is about not showing three
|
|
* different result sets while a word is still being typed.
|
|
*/
|
|
const DEBOUNCE_MS = 90;
|
|
|
|
let query = $state('');
|
|
let open = $state(false);
|
|
let selected = $state(0);
|
|
let loading = $state(false);
|
|
let failure = $state<string | null>(null);
|
|
let answers = $state<WireSearch[]>([]);
|
|
let entries = $state<WireEntryPoints | null>(null);
|
|
|
|
let inflight: AbortController | null = null;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
/** Guards against an older answer landing after a newer one. */
|
|
let generation = 0;
|
|
|
|
let entriesInflight: Promise<void> | null = null;
|
|
|
|
function loadEntries(): Promise<void> {
|
|
if (entriesInflight) return entriesInflight;
|
|
entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT })
|
|
.then((value) => {
|
|
entries = value;
|
|
})
|
|
.catch(() => {
|
|
// The palette still works without them; a failed "where do I start"
|
|
// should never stop someone from typing a name.
|
|
entries = null;
|
|
});
|
|
return entriesInflight;
|
|
}
|
|
|
|
function cancel(): void {
|
|
if (timer !== null) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
inflight?.abort();
|
|
inflight = null;
|
|
}
|
|
|
|
async function run(text: string, mine: number): Promise<void> {
|
|
const flow = parseFlowQuery(text);
|
|
const controller = new AbortController();
|
|
inflight = controller;
|
|
loading = true;
|
|
try {
|
|
const queries = flow ? [flow.from, flow.to] : [text];
|
|
const results = await Promise.all(
|
|
queries.map((q) => fetchSearch(q, { limit: SEARCH_LIMIT }, controller.signal))
|
|
);
|
|
if (mine !== generation) return;
|
|
answers = results;
|
|
failure = null;
|
|
} catch (cause) {
|
|
if (controller.signal.aborted || mine !== generation) return;
|
|
answers = [];
|
|
failure = cause instanceof Error ? cause.message : String(cause);
|
|
} finally {
|
|
if (mine === generation) loading = false;
|
|
}
|
|
}
|
|
|
|
function schedule(text: string): void {
|
|
cancel();
|
|
const mine = (generation += 1);
|
|
if (text.trim() === '') {
|
|
answers = [];
|
|
failure = null;
|
|
loading = false;
|
|
return;
|
|
}
|
|
timer = setTimeout(() => {
|
|
timer = null;
|
|
void run(text, mine);
|
|
}, DEBOUNCE_MS);
|
|
}
|
|
|
|
/** The palette as it should be drawn right now. */
|
|
function current(): Palette {
|
|
if (query.trim() === '') return buildEntryPalette(entries, { perSection: PALETTE_ENTRY_ROWS });
|
|
return buildSearchPalette(answers, parseFlowQuery(query));
|
|
}
|
|
|
|
export const palette = {
|
|
get query(): string {
|
|
return query;
|
|
},
|
|
set query(next: string) {
|
|
if (next === query) return;
|
|
query = next;
|
|
selected = 0;
|
|
schedule(next);
|
|
},
|
|
get open(): boolean {
|
|
return open;
|
|
},
|
|
get loading(): boolean {
|
|
return loading;
|
|
},
|
|
get failure(): string | null {
|
|
return failure;
|
|
},
|
|
get view(): Palette {
|
|
return current();
|
|
},
|
|
get selected(): number {
|
|
return selected;
|
|
},
|
|
get selectedItem(): PaletteItem | null {
|
|
const items = current().items;
|
|
return items[Math.min(selected, items.length - 1)] ?? null;
|
|
},
|
|
/** True while a typed query has no answer yet — the panel says so. */
|
|
get pending(): boolean {
|
|
return query.trim() !== '' && (loading || timer !== null);
|
|
},
|
|
|
|
show(): void {
|
|
open = true;
|
|
void loadEntries();
|
|
},
|
|
hide(): void {
|
|
open = false;
|
|
},
|
|
select(index: number): void {
|
|
selected = index;
|
|
},
|
|
move(delta: number): void {
|
|
selected = moveSelection(selected, delta, current().items.length);
|
|
},
|
|
/** Close and empty the box — what picking a result leaves behind. */
|
|
reset(): void {
|
|
cancel();
|
|
generation += 1;
|
|
query = '';
|
|
answers = [];
|
|
failure = null;
|
|
loading = false;
|
|
selected = 0;
|
|
open = false;
|
|
},
|
|
/** Load the entry points without opening the panel (the empty screen wants them). */
|
|
ensureEntries: loadEntries,
|
|
get entries(): WireEntryPoints | null {
|
|
return entries;
|
|
},
|
|
};
|