mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
95750ab657
* feat: search-based code navigation with peek view (#694) Add IDE-like code navigation to the review UI. Cmd/Ctrl+click a token in a diff to find its definitions and references across the repo via ripgrep, displayed in a VS Code-style peek panel below the diff. Backend: bounded rg search with language-aware definition patterns (TS/JS, Python, Go, Rust), ranked results (same file > changed files > same directory), confidence labels, and graceful degradation when rg is not installed. Both Bun and Pi servers implement the endpoints. Frontend: Dockview peek panel with syntax-highlighted full-file preview on the left and grouped reference list on the right. Clicking a reference scrolls the preview; double-clicking an in-diff result navigates to the file with a gold line flash. Closes #694 * fix: resolve Pi server TypeScript errors for code-nav Add missing spawn import, type the close callback parameter, and use double-cast for parseBody → CodeNavRequest. * fix: clear loading state on cached preview hits Without this, clicking a cached file while a fetch is in-flight leaves isLoading stuck true — the spinner hides the preview. * feat: show pointer cursor on Cmd/Ctrl+hover for navigable tokens Adds pn-token-nav class with thicker underline and pointer cursor when hovering a token while holding the modifier key, signaling the token is Cmd+clickable for code navigation. * refactor: remove go-to-diff navigation from peek panel Strip the in-diff badge, double-click-to-jump, highlightDiffLine wiring, and onCodeNavGoToDiff from the peek panel. The peek view is the primary interaction — jump-to-diff adds complexity without clear value at this stage. * chore: remove dead code from code-nav cleanup Delete unused highlightDiffLine.ts, remove codeNavChangedFiles and codeNavActiveSide from context and App.tsx, drop stale extractChangedFiles import. * chore: add code-nav endpoints to AGENTS.md, remove dead activeSide state * fix: don't classify bare indented calls as definitions Change the TS/JS method pattern from zero-or-more (*) to one-or-more (+) declaration keywords, so plain calls like startServer(config) are no longer misclassified as definitions. * feat: show toast when code-nav is unavailable in platform-only PR mode Instead of opening the peek panel and showing misleading "No results", Cmd+click in non-local PR mode shows a brief toast explaining that code navigation requires a local checkout. * fix: strip hljs hardcoded background from code-nav preview The highlight.js github-dark theme sets a fixed dark background on all .hljs elements. Apply transparent override for the entire peek preview so code inherits the active theme's background.
437 lines
11 KiB
TypeScript
437 lines
11 KiB
TypeScript
/**
|
|
* Search-based code navigation — shared types and pure logic.
|
|
*
|
|
* Runtime-agnostic: both Bun and Node servers provide their own
|
|
* CodeNavRuntime implementation to run subprocess commands.
|
|
*/
|
|
|
|
function validateFilePath(filePath: string): void {
|
|
if (filePath.includes("..") || filePath.startsWith("/")) {
|
|
throw new Error("Invalid file path");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CodeNavRequest {
|
|
symbol: string;
|
|
filePath: string;
|
|
line: number;
|
|
charStart: number;
|
|
side: "old" | "new";
|
|
language?: string;
|
|
}
|
|
|
|
export interface CodeNavLocation {
|
|
kind: "definition" | "reference";
|
|
confidence: "likely" | "possible";
|
|
filePath: string;
|
|
line: number;
|
|
column: number;
|
|
snippet: string;
|
|
}
|
|
|
|
export interface CodeNavResponse {
|
|
backend: "search" | "unavailable";
|
|
complete: boolean;
|
|
definitions: CodeNavLocation[];
|
|
references: CodeNavLocation[];
|
|
stats: { elapsedMs: number; capped: boolean };
|
|
searchScope: "head";
|
|
}
|
|
|
|
export interface CodeNavRuntime {
|
|
runCommand: (
|
|
command: string,
|
|
args: string[],
|
|
options?: { cwd?: string; timeoutMs?: number },
|
|
) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CODE_NAV_IGNORED_GLOBS = [
|
|
"node_modules",
|
|
".git",
|
|
"dist",
|
|
"build",
|
|
".next",
|
|
"__pycache__",
|
|
".turbo",
|
|
".cache",
|
|
"target",
|
|
"vendor",
|
|
"coverage",
|
|
".venv",
|
|
".pytest_cache",
|
|
];
|
|
|
|
const RG_TYPE_MAP: Record<string, string> = {
|
|
typescript: "ts",
|
|
javascript: "js",
|
|
python: "py",
|
|
go: "go",
|
|
rust: "rust",
|
|
java: "java",
|
|
ruby: "ruby",
|
|
cpp: "cpp",
|
|
c: "c",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Definition patterns
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface DefinitionPatternSet {
|
|
languages: string[];
|
|
patterns: string[];
|
|
}
|
|
|
|
const DEFINITION_PATTERNS: DefinitionPatternSet[] = [
|
|
{
|
|
languages: ["typescript", "javascript"],
|
|
patterns: [
|
|
String.raw`(?:export\s+)?(?:async\s+)?function\s+SYMBOL\b`,
|
|
String.raw`(?:export\s+)?(?:const|let|var)\s+SYMBOL\s*[=:]`,
|
|
String.raw`(?:export\s+)?class\s+SYMBOL\b`,
|
|
String.raw`(?:export\s+)?(?:interface|type)\s+SYMBOL\b`,
|
|
String.raw`(?:export\s+)?enum\s+SYMBOL\b`,
|
|
String.raw`^\s+(?:(?:async|static|readonly|get|set|private|protected|public)\s+)+SYMBOL\s*[(<:]`,
|
|
],
|
|
},
|
|
{
|
|
languages: ["python"],
|
|
patterns: [
|
|
String.raw`(?:^|\s)def\s+SYMBOL\s*\(`,
|
|
String.raw`(?:^|\s)class\s+SYMBOL\b`,
|
|
String.raw`^SYMBOL\s*=`,
|
|
],
|
|
},
|
|
{
|
|
languages: ["go"],
|
|
patterns: [
|
|
String.raw`func\s+(?:\([^)]+\)\s+)?SYMBOL\s*\(`,
|
|
String.raw`type\s+SYMBOL\s`,
|
|
String.raw`var\s+SYMBOL\s`,
|
|
],
|
|
},
|
|
{
|
|
languages: ["rust"],
|
|
patterns: [
|
|
String.raw`(?:pub(?:\([^)]*\))?\s+)?fn\s+SYMBOL\b`,
|
|
String.raw`(?:pub(?:\([^)]*\))?\s+)?struct\s+SYMBOL\b`,
|
|
String.raw`(?:pub(?:\([^)]*\))?\s+)?enum\s+SYMBOL\b`,
|
|
String.raw`(?:pub(?:\([^)]*\))?\s+)?trait\s+SYMBOL\b`,
|
|
String.raw`(?:pub(?:\([^)]*\))?\s+)?type\s+SYMBOL\b`,
|
|
String.raw`(?:pub(?:\([^)]*\))?\s+)?mod\s+SYMBOL\b`,
|
|
],
|
|
},
|
|
];
|
|
|
|
const GENERIC_DEFINITION_PATTERNS: string[] = [
|
|
String.raw`(?:function|def|func|fn|class|struct|enum|trait|interface|type)\s+SYMBOL\b`,
|
|
String.raw`(?:const|let|var|val)\s+SYMBOL\s*[=:]`,
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function escapeRegex(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function sameDirectory(a: string, b: string): boolean {
|
|
const dirA = a.lastIndexOf("/");
|
|
const dirB = b.lastIndexOf("/");
|
|
if (dirA === -1 && dirB === -1) return true;
|
|
return a.slice(0, dirA) === b.slice(0, dirB);
|
|
}
|
|
|
|
function isTestFile(filePath: string): boolean {
|
|
return /(?:test|spec|__tests__|_test\.|\.test\.|\.spec\.)/i.test(filePath);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rg argument construction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function buildRgArgs(symbol: string, language?: string): string[] {
|
|
const args: string[] = [
|
|
"--json",
|
|
"--line-number",
|
|
"--column",
|
|
"--max-count",
|
|
"50",
|
|
"--max-filesize",
|
|
"1M",
|
|
"--no-messages",
|
|
];
|
|
|
|
for (const dir of CODE_NAV_IGNORED_GLOBS) {
|
|
args.push("--glob", `!${dir}`);
|
|
}
|
|
|
|
if (language) {
|
|
const rgType = RG_TYPE_MAP[language];
|
|
if (rgType) args.push("--type", rgType);
|
|
}
|
|
|
|
args.push("--word-regexp", "--", escapeRegex(symbol), ".");
|
|
|
|
return args;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rg JSON output parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RgMatchData {
|
|
path: { text: string };
|
|
lines: { text: string };
|
|
line_number: number;
|
|
submatches: Array<{ start: number; end: number }>;
|
|
}
|
|
|
|
const PARSE_CAP = 500;
|
|
|
|
export function parseRgJsonOutput(
|
|
stdout: string,
|
|
symbol: string,
|
|
language?: string,
|
|
): CodeNavLocation[] {
|
|
const locations: CodeNavLocation[] = [];
|
|
const lines = stdout.split("\n");
|
|
|
|
for (const line of lines) {
|
|
if (locations.length >= PARSE_CAP) break;
|
|
if (!line.trim()) continue;
|
|
|
|
let parsed: { type: string; data: RgMatchData };
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (parsed.type !== "match") continue;
|
|
|
|
const d = parsed.data;
|
|
const snippet = d.lines.text.trimEnd();
|
|
const column = d.submatches?.[0]?.start ?? 0;
|
|
const kind = classifyMatch(snippet, symbol, language);
|
|
const filePath = d.path.text.startsWith("./")
|
|
? d.path.text.slice(2)
|
|
: d.path.text;
|
|
|
|
locations.push({
|
|
kind,
|
|
confidence: kind === "definition" ? "likely" : "possible",
|
|
filePath,
|
|
line: d.line_number,
|
|
column,
|
|
snippet: snippet.length > 200 ? snippet.slice(0, 200) + "…" : snippet,
|
|
});
|
|
}
|
|
|
|
return locations;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Match classification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function classifyMatch(
|
|
snippet: string,
|
|
symbol: string,
|
|
language?: string,
|
|
): "definition" | "reference" {
|
|
const escaped = escapeRegex(symbol);
|
|
|
|
if (language) {
|
|
const langPatterns = DEFINITION_PATTERNS.find((p) =>
|
|
p.languages.includes(language),
|
|
);
|
|
if (langPatterns) {
|
|
for (const pattern of langPatterns.patterns) {
|
|
const re = new RegExp(pattern.replace("SYMBOL", escaped));
|
|
if (re.test(snippet)) return "definition";
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const pattern of GENERIC_DEFINITION_PATTERNS) {
|
|
const re = new RegExp(pattern.replace("SYMBOL", escaped));
|
|
if (re.test(snippet)) return "definition";
|
|
}
|
|
|
|
return "reference";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ranking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function rankLocations(
|
|
locations: CodeNavLocation[],
|
|
context: {
|
|
sourceFilePath: string;
|
|
changedFiles: string[];
|
|
isTestFile: boolean;
|
|
},
|
|
cap = 50,
|
|
): { definitions: CodeNavLocation[]; references: CodeNavLocation[]; capped: boolean } {
|
|
const capped = locations.length > cap;
|
|
const changedSet = new Set(context.changedFiles);
|
|
|
|
function score(loc: CodeNavLocation): number {
|
|
let s = 0;
|
|
|
|
if (loc.filePath === context.sourceFilePath) s += 1000;
|
|
else if (changedSet.has(loc.filePath)) s += 500;
|
|
else if (sameDirectory(loc.filePath, context.sourceFilePath)) s += 200;
|
|
|
|
if (isTestFile(loc.filePath) && !context.isTestFile) s -= 300;
|
|
|
|
if (loc.kind === "definition") s += 100;
|
|
if (loc.confidence === "likely") s += 50;
|
|
|
|
return s;
|
|
}
|
|
|
|
const sorted = [...locations].sort((a, b) => score(b) - score(a));
|
|
const truncated = sorted.slice(0, cap);
|
|
|
|
return {
|
|
definitions: truncated.filter((l) => l.kind === "definition"),
|
|
references: truncated.filter((l) => l.kind === "reference"),
|
|
capped,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Changed files extraction from unified diff patch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function extractChangedFiles(patch: string | null): string[] {
|
|
if (!patch) return [];
|
|
const set = new Set<string>();
|
|
const re = /^diff --git a\/(.+?) b\/(.+)$/gm;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(patch)) !== null) {
|
|
set.add(m[1]);
|
|
set.add(m[2]);
|
|
}
|
|
return [...set];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function validateCodeNavRequest(
|
|
body: unknown,
|
|
): string | null {
|
|
if (!body || typeof body !== "object") return "Invalid request body";
|
|
const b = body as Record<string, unknown>;
|
|
|
|
if (typeof b.symbol !== "string" || !b.symbol.trim()) {
|
|
return "Missing or empty symbol";
|
|
}
|
|
if (typeof b.filePath !== "string" || !b.filePath.trim()) {
|
|
return "Missing filePath";
|
|
}
|
|
try {
|
|
validateFilePath(b.filePath as string);
|
|
} catch {
|
|
return "Invalid filePath";
|
|
}
|
|
if (b.side !== "old" && b.side !== "new") {
|
|
return "side must be 'old' or 'new'";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let rgAvailable: boolean | null = null;
|
|
|
|
export async function resolveCodeNav(
|
|
runtime: CodeNavRuntime,
|
|
request: CodeNavRequest,
|
|
cwd: string,
|
|
changedFiles: string[],
|
|
): Promise<CodeNavResponse> {
|
|
const start = Date.now();
|
|
|
|
if (rgAvailable === null) {
|
|
const check = await runtime.runCommand("rg", ["--version"], {
|
|
cwd,
|
|
timeoutMs: 2000,
|
|
});
|
|
rgAvailable = check.exitCode === 0;
|
|
}
|
|
|
|
if (!rgAvailable) {
|
|
return {
|
|
backend: "unavailable",
|
|
complete: true,
|
|
definitions: [],
|
|
references: [],
|
|
searchScope: "head",
|
|
stats: { elapsedMs: Date.now() - start, capped: false },
|
|
};
|
|
}
|
|
|
|
const args = buildRgArgs(request.symbol, request.language);
|
|
|
|
const result = await runtime.runCommand("rg", args, {
|
|
cwd,
|
|
timeoutMs: 5000,
|
|
});
|
|
|
|
// Exit code 1 = no matches (normal), exit code 2 = error
|
|
if (result.exitCode === 2) {
|
|
return {
|
|
backend: "search",
|
|
complete: true,
|
|
definitions: [],
|
|
references: [],
|
|
searchScope: "head",
|
|
stats: { elapsedMs: Date.now() - start, capped: false },
|
|
};
|
|
}
|
|
|
|
const locations = parseRgJsonOutput(
|
|
result.stdout,
|
|
request.symbol,
|
|
request.language,
|
|
);
|
|
|
|
const ranked = rankLocations(locations, {
|
|
sourceFilePath: request.filePath,
|
|
changedFiles,
|
|
isTestFile: isTestFile(request.filePath),
|
|
});
|
|
|
|
return {
|
|
backend: "search",
|
|
complete: true,
|
|
definitions: ranked.definitions,
|
|
references: ranked.references,
|
|
searchScope: "head",
|
|
stats: { elapsedMs: Date.now() - start, capped: ranked.capped },
|
|
};
|
|
}
|
|
|
|
export function resetRgCache(): void {
|
|
rgAvailable = null;
|
|
}
|