chore: init from code-review-graph-main snapshot (v2.3.7)
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
import { BlastRadiusTreeProvider } from '../views/treeView';
|
||||
import { resolveNodeAtCursor } from './cursorResolver';
|
||||
|
||||
/**
|
||||
* Register the cursor-aware blast radius command.
|
||||
*
|
||||
* When invoked the command:
|
||||
* 1. Gets the active editor's file path and cursor line.
|
||||
* 2. Resolves the innermost node at cursor via the graph database.
|
||||
* 3. Falls back to the file-level node when no specific node is found.
|
||||
* 4. Runs a BFS impact radius query up to the configured depth.
|
||||
* 5. Updates the BlastRadiusTreeProvider with the results.
|
||||
* 6. Focuses the blast radius tree view.
|
||||
*/
|
||||
export function registerBlastRadiusCommand(
|
||||
context: vscode.ExtensionContext,
|
||||
getReader: () => SqliteReader | undefined,
|
||||
blastRadiusProvider: BlastRadiusTreeProvider,
|
||||
workspaceRoot: string,
|
||||
): void {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.showBlastRadius', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Active editor check ---
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('Open a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Resolve file path and cursor position ---
|
||||
const absFilePath = editor.document.uri.fsPath;
|
||||
const cursorLine = editor.selection.active.line + 1; // 1-based
|
||||
|
||||
// --- Resolve node at cursor ---
|
||||
const nodeAtCursor = reader.getNodeAtCursor(absFilePath, cursorLine);
|
||||
|
||||
// Determine the file path to feed into getImpactRadius.
|
||||
// If we found a node, use its filePath (which is the canonical path
|
||||
// stored in the database). Otherwise fall back to the editor path.
|
||||
const filePath = nodeAtCursor ? nodeAtCursor.filePath : absFilePath;
|
||||
|
||||
// --- Read depth from settings ---
|
||||
const config = vscode.workspace.getConfiguration('codeReviewGraph');
|
||||
const depth = config.get<number>('blastRadiusDepth', 2);
|
||||
|
||||
// --- Compute blast radius ---
|
||||
const impact = reader.getImpactRadius([filePath], depth);
|
||||
|
||||
// --- Update tree provider ---
|
||||
blastRadiusProvider.setResults(impact.changedNodes, impact.impactedNodes);
|
||||
|
||||
// --- Focus the blast radius view ---
|
||||
await vscode.commands.executeCommand('codeReviewGraph.blastRadius.focus');
|
||||
|
||||
// --- Summary message ---
|
||||
const impactedFileCount = new Set(impact.impactedNodes.map((n) => n.filePath)).size;
|
||||
vscode.window.showInformationMessage(
|
||||
`Blast radius: ${impact.impactedNodes.length} nodes impacted across ${impactedFileCount} files`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader, GraphNode } from '../backend/sqlite';
|
||||
|
||||
/**
|
||||
* Resolve the innermost graph node at the current cursor position.
|
||||
*
|
||||
* Returns `undefined` when there is no active editor or no node spans the
|
||||
* cursor line in the graph database.
|
||||
*/
|
||||
export function resolveNodeAtCursor(
|
||||
reader: SqliteReader,
|
||||
): GraphNode | undefined {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filePath = editor.document.uri.fsPath;
|
||||
const line = editor.selection.active.line + 1; // VS Code is 0-based, SQLite data is 1-based
|
||||
|
||||
return reader.getNodeAtCursor(filePath, line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a document and scroll to the node's start line.
|
||||
*
|
||||
* The node's `filePath` is treated as an absolute path. If `lineStart` is
|
||||
* null the file is opened at the top.
|
||||
*/
|
||||
export async function navigateToNode(node: GraphNode): Promise<void> {
|
||||
const uri = vscode.Uri.file(node.filePath);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const line = Math.max(0, (node.lineStart ?? 1) - 1);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
selection: new vscode.Range(line, 0, line, 0),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader, GraphNode } from '../backend/sqlite';
|
||||
import { resolveNodeAtCursor, navigateToNode } from './cursorResolver';
|
||||
|
||||
/**
|
||||
* Register the navigation commands: findCallers, findTests, and search.
|
||||
*/
|
||||
export function registerNavigationCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
getReader: () => SqliteReader | undefined,
|
||||
): void {
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.findCallers
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.findCallers', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve node at cursor
|
||||
const node = resolveNodeAtCursor(reader);
|
||||
if (!node) {
|
||||
vscode.window.showWarningMessage(
|
||||
'Code Graph: No graph node found at the current cursor position.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Query incoming CALLS edges
|
||||
const edges = reader.getEdgesByTarget(node.qualifiedName);
|
||||
const callerEdges = edges.filter((e) => e.kind === 'CALLS');
|
||||
|
||||
if (callerEdges.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No callers found for "${node.name}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build QuickPick items, resolving each caller to its full node
|
||||
const items: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
node: GraphNode | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const edge of callerEdges) {
|
||||
const callerNode = reader.getNode(edge.sourceQualified);
|
||||
items.push({
|
||||
label: callerNode?.name ?? edge.sourceQualified,
|
||||
description: callerNode?.filePath ?? edge.filePath,
|
||||
detail: `Line ${callerNode?.lineStart ?? edge.line}`,
|
||||
node: callerNode,
|
||||
});
|
||||
}
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Callers of ${node.name}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.findTests
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.findTests', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve node at cursor
|
||||
const node = resolveNodeAtCursor(reader);
|
||||
if (!node) {
|
||||
vscode.window.showWarningMessage(
|
||||
'Code Graph: No graph node found at the current cursor position.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Collect test qualified names from TESTED_BY edges (both directions) ---
|
||||
const incomingEdges = reader.getEdgesByTarget(node.qualifiedName);
|
||||
const incomingTestEdges = incomingEdges.filter((e) => e.kind === 'TESTED_BY');
|
||||
|
||||
const outgoingEdges = reader.getEdgesBySource(node.qualifiedName);
|
||||
const outgoingTestEdges = outgoingEdges.filter((e) => e.kind === 'TESTED_BY');
|
||||
|
||||
const testQualifiedNames = new Set<string>([
|
||||
...incomingTestEdges.map((e) => e.sourceQualified),
|
||||
...outgoingTestEdges.map((e) => e.targetQualified),
|
||||
]);
|
||||
|
||||
// --- Also search by naming convention: test_{name}, Test{name} ---
|
||||
const conventionPatterns = [`test_${node.name}`, `Test${node.name}`];
|
||||
for (const pattern of conventionPatterns) {
|
||||
const matches = reader.searchNodes(pattern, 10);
|
||||
for (const match of matches) {
|
||||
if (match.isTest || match.kind === 'Test') {
|
||||
testQualifiedNames.add(match.qualifiedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (testQualifiedNames.size === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No tests found for "${node.name}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build QuickPick items ---
|
||||
const items: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
node: GraphNode | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const tqn of testQualifiedNames) {
|
||||
const testNode = reader.getNode(tqn);
|
||||
items.push({
|
||||
label: testNode?.name ?? tqn,
|
||||
description: testNode?.filePath ?? '',
|
||||
detail: `Line ${testNode?.lineStart ?? '?'}`,
|
||||
node: testNode,
|
||||
});
|
||||
}
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Tests for ${node.name}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.search
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.search', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = await vscode.window.showInputBox({
|
||||
prompt: 'Search the code graph',
|
||||
placeHolder: 'Enter a function, class, or module name',
|
||||
});
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = reader.searchNodes(query, 30);
|
||||
|
||||
if (results.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No results found for "${query}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = results.map((r) => ({
|
||||
label: r.name,
|
||||
description: r.kind,
|
||||
detail: r.filePath
|
||||
? `${r.filePath}:${r.lineStart ?? ''}`
|
||||
: undefined,
|
||||
result: r,
|
||||
}));
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Results for "${query}"`,
|
||||
});
|
||||
|
||||
if (selected?.result) {
|
||||
await navigateToNode(selected.result);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* SCM integration for code review.
|
||||
*
|
||||
* Detects staged and unstaged changes via git, computes the blast radius
|
||||
* for those files, and populates the Blast Radius tree view so the reviewer
|
||||
* can see what is impacted before committing.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
import { BlastRadiusTreeProvider } from '../views/treeView';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Timeout for git commands (milliseconds). */
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Run a git command in the given working directory and return trimmed stdout
|
||||
* lines. Returns an empty array on any error (e.g. git not installed, not a
|
||||
* git repo, etc.).
|
||||
*/
|
||||
async function gitLines(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
});
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `codeReviewGraph.reviewChanges` command.
|
||||
*
|
||||
* The command:
|
||||
* 1. Runs `git diff --name-only HEAD` and `git diff --cached --name-only`
|
||||
* to collect changed + staged files.
|
||||
* 2. Computes the blast radius for those files.
|
||||
* 3. Updates the BlastRadiusTreeProvider with the results.
|
||||
* 4. Focuses the blast radius view.
|
||||
*/
|
||||
export function registerReviewCommand(
|
||||
context: vscode.ExtensionContext,
|
||||
reader: SqliteReader,
|
||||
blastRadiusProvider: BlastRadiusTreeProvider,
|
||||
): void {
|
||||
const disposable = vscode.commands.registerCommand(
|
||||
'codeReviewGraph.reviewChanges',
|
||||
async () => {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage('No workspace folder is open.');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = workspaceFolder.uri.fsPath;
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Code Graph: Analyzing changes...',
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
// 1. Collect changed files (unstaged + staged, deduplicated)
|
||||
const [unstaged, staged] = await Promise.all([
|
||||
gitLines(['diff', '--name-only', 'HEAD'], workspaceRoot),
|
||||
gitLines(['diff', '--cached', '--name-only'], workspaceRoot),
|
||||
]);
|
||||
|
||||
const changedFiles = [...new Set([...unstaged, ...staged])];
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No changes detected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Compute blast radius
|
||||
const config = vscode.workspace.getConfiguration('codeReviewGraph');
|
||||
const depth = config.get<number>('blastRadiusDepth', 2);
|
||||
const impact = reader.getImpactRadius(changedFiles, depth);
|
||||
|
||||
// 3. Update tree provider
|
||||
blastRadiusProvider.setResults(
|
||||
impact.changedNodes,
|
||||
impact.impactedNodes,
|
||||
);
|
||||
|
||||
// 4. Focus the blast radius view
|
||||
await vscode.commands.executeCommand(
|
||||
'codeReviewGraph.blastRadius.focus',
|
||||
);
|
||||
|
||||
// 5. Show summary
|
||||
vscode.window.showInformationMessage(
|
||||
`Review: ${changedFiles.length} changed file(s) impact ${impact.impactedNodes.length} additional file(s).`,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
context.subscriptions.push(disposable);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* SCM file decoration provider.
|
||||
*
|
||||
* Adds badges to files in the Explorer and SCM views:
|
||||
* - IMPACTED (orange) — file is in the blast radius of staged/unstaged changes
|
||||
* - TESTED (green) — changed functions in this file have test coverage
|
||||
* - UNTESTED (red) — changed functions lack test coverage
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
|
||||
export class ScmDecorationProvider
|
||||
implements vscode.FileDecorationProvider
|
||||
{
|
||||
private readonly _onDidChange = new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
|
||||
readonly onDidChangeFileDecorations = this._onDidChange.event;
|
||||
|
||||
/** Files directly changed (staged + unstaged). */
|
||||
private changedFiles = new Set<string>();
|
||||
/** Files in the blast radius but not directly changed. */
|
||||
private impactedFiles = new Set<string>();
|
||||
/** Changed files whose functions all have TESTED_BY edges. */
|
||||
private testedFiles = new Set<string>();
|
||||
/** Changed files with at least one function lacking TESTED_BY edges. */
|
||||
private untestedFiles = new Set<string>();
|
||||
|
||||
/**
|
||||
* Recompute decorations from git state and the graph database.
|
||||
*/
|
||||
async update(
|
||||
reader: SqliteReader,
|
||||
workspaceRoot: string,
|
||||
): Promise<void> {
|
||||
const { execFile } = await import('node:child_process');
|
||||
const { promisify } = await import('node:util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const path = await import('node:path');
|
||||
|
||||
// 1. Collect changed files
|
||||
let unstaged: string[] = [];
|
||||
let staged: string[] = [];
|
||||
try {
|
||||
const r1 = await execFileAsync('git', ['diff', '--name-only', 'HEAD'], {
|
||||
cwd: workspaceRoot,
|
||||
timeout: 10_000,
|
||||
});
|
||||
unstaged = r1.stdout.trim().split('\n').filter(Boolean);
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const r2 = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
|
||||
cwd: workspaceRoot,
|
||||
timeout: 10_000,
|
||||
});
|
||||
staged = r2.stdout.trim().split('\n').filter(Boolean);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const changedRelative = [...new Set([...unstaged, ...staged])];
|
||||
const changedAbsolute = changedRelative.map((f) => path.join(workspaceRoot, f));
|
||||
|
||||
// 2. Compute impact radius
|
||||
const config = vscode.workspace.getConfiguration('codeReviewGraph');
|
||||
const depth = config.get<number>('blastRadiusDepth', 2);
|
||||
const impact = reader.getImpactRadius(changedAbsolute, depth);
|
||||
|
||||
// 3. Classify files
|
||||
this.changedFiles = new Set(changedAbsolute);
|
||||
this.impactedFiles = new Set(
|
||||
impact.impactedNodes
|
||||
.map((n) => n.filePath)
|
||||
.filter((f) => !this.changedFiles.has(f)),
|
||||
);
|
||||
|
||||
// 4. Test coverage classification
|
||||
this.testedFiles = new Set<string>();
|
||||
this.untestedFiles = new Set<string>();
|
||||
|
||||
for (const filePath of this.changedFiles) {
|
||||
const nodes = reader.getNodesByFile(filePath);
|
||||
const functions = nodes.filter(
|
||||
(n) => n.kind === 'Function' && !n.isTest,
|
||||
);
|
||||
if (functions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let allTested = true;
|
||||
for (const fn of functions) {
|
||||
const edges = reader.getEdgesByTarget(fn.qualifiedName);
|
||||
const hasTest = edges.some((e) => e.kind === 'TESTED_BY');
|
||||
if (!hasTest) {
|
||||
// Also check outgoing TESTED_BY (reverse direction)
|
||||
const outEdges = reader.getEdgesBySource(fn.qualifiedName);
|
||||
const hasOutTest = outEdges.some((e) => e.kind === 'TESTED_BY');
|
||||
if (!hasOutTest) {
|
||||
allTested = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allTested) {
|
||||
this.testedFiles.add(filePath);
|
||||
} else {
|
||||
this.untestedFiles.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fire change event
|
||||
this._onDidChange.fire(undefined);
|
||||
}
|
||||
|
||||
/** Clear all decorations. */
|
||||
clear(): void {
|
||||
this.changedFiles.clear();
|
||||
this.impactedFiles.clear();
|
||||
this.testedFiles.clear();
|
||||
this.untestedFiles.clear();
|
||||
this._onDidChange.fire(undefined);
|
||||
}
|
||||
|
||||
provideFileDecoration(
|
||||
uri: vscode.Uri,
|
||||
): vscode.FileDecoration | undefined {
|
||||
const filePath = uri.fsPath;
|
||||
|
||||
if (this.untestedFiles.has(filePath)) {
|
||||
return {
|
||||
badge: '!',
|
||||
color: new vscode.ThemeColor('editorError.foreground'),
|
||||
tooltip: 'Code Graph: Changed functions lack test coverage',
|
||||
propagate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.testedFiles.has(filePath)) {
|
||||
return {
|
||||
badge: '\u2713',
|
||||
color: new vscode.ThemeColor('testing.iconPassed'),
|
||||
tooltip: 'Code Graph: All changed functions have test coverage',
|
||||
propagate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.impactedFiles.has(filePath)) {
|
||||
return {
|
||||
badge: '\u25CF',
|
||||
color: new vscode.ThemeColor('editorWarning.foreground'),
|
||||
tooltip: 'Code Graph: In blast radius of current changes',
|
||||
propagate: false,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidChange.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Quick search command with live filtering.
|
||||
*
|
||||
* Shows a QuickPick that queries the graph database as the user types,
|
||||
* then navigates to the selected node's source location.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'node:path';
|
||||
import { SqliteReader, GraphNode } from '../backend/sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kind-to-icon mapping (uses VS Code codicon identifiers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KIND_ICON: Record<string, string> = {
|
||||
Function: '$(symbol-method)',
|
||||
Class: '$(symbol-class)',
|
||||
File: '$(file)',
|
||||
Test: '$(beaker)',
|
||||
Type: '$(symbol-interface)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a QuickPickItem from a GraphNode.
|
||||
*/
|
||||
function nodeToQuickPickItem(
|
||||
node: GraphNode,
|
||||
workspaceRoot: string | undefined,
|
||||
): vscode.QuickPickItem & { node: GraphNode } {
|
||||
const icon = KIND_ICON[node.kind] ?? '$(symbol-misc)';
|
||||
const relativePath = workspaceRoot
|
||||
? path.relative(workspaceRoot, node.filePath)
|
||||
: node.filePath;
|
||||
const lineInfo = node.lineStart != null ? `:${node.lineStart}` : '';
|
||||
|
||||
return {
|
||||
label: `${icon} ${node.name}`,
|
||||
description: node.kind,
|
||||
detail: `${relativePath}${lineInfo}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a node's source location.
|
||||
*/
|
||||
async function navigateToNode(
|
||||
node: GraphNode,
|
||||
workspaceRoot: string | undefined,
|
||||
): Promise<void> {
|
||||
const filePath = workspaceRoot
|
||||
? path.join(workspaceRoot, node.filePath)
|
||||
: node.filePath;
|
||||
|
||||
const uri = vscode.Uri.file(filePath);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const line = Math.max(0, (node.lineStart ?? 1) - 1);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
selection: new vscode.Range(line, 0, line, 0),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `codeReviewGraph.search` command.
|
||||
*
|
||||
* Opens a QuickPick with live filtering:
|
||||
* - As the user types, `reader.searchNodes(value, 20)` is called.
|
||||
* - Results are displayed with kind-specific icons.
|
||||
* - On accept, the editor navigates to the selected node.
|
||||
*/
|
||||
export function registerSearchCommand(
|
||||
context: vscode.ExtensionContext,
|
||||
reader: SqliteReader,
|
||||
): void {
|
||||
const disposable = vscode.commands.registerCommand(
|
||||
'codeReviewGraph.search',
|
||||
async () => {
|
||||
const workspaceRoot =
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const quickPick = vscode.window.createQuickPick<
|
||||
vscode.QuickPickItem & { node: GraphNode }
|
||||
>();
|
||||
quickPick.placeholder = 'Search for functions, classes, files, types...';
|
||||
quickPick.matchOnDescription = true;
|
||||
quickPick.matchOnDetail = true;
|
||||
|
||||
// Debounce timer to avoid querying on every keystroke
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
quickPick.onDidChangeValue((value) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
quickPick.items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
const results = reader.searchNodes(value, 20);
|
||||
quickPick.items = results.map((node) =>
|
||||
nodeToQuickPickItem(node, workspaceRoot),
|
||||
);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
quickPick.onDidAccept(async () => {
|
||||
const selected = quickPick.selectedItems[0];
|
||||
quickPick.dispose();
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node, workspaceRoot);
|
||||
}
|
||||
});
|
||||
|
||||
quickPick.onDidHide(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
quickPick.dispose();
|
||||
});
|
||||
|
||||
quickPick.show();
|
||||
},
|
||||
);
|
||||
|
||||
context.subscriptions.push(disposable);
|
||||
}
|
||||
Reference in New Issue
Block a user