Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion review/src/sources/local_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ impl LocalGitSource {
}

/// Get all tracked files from git (fast, uses index)
fn get_tracked_files(&self) -> Result<Vec<String>, LocalGitError> {
pub fn get_tracked_files(&self) -> Result<Vec<String>, LocalGitError> {
let output = self.run_git(&["ls-files"])?;
Ok(output.lines().map(std::borrow::ToOwned::to_owned).collect())
}
Expand Down
38 changes: 37 additions & 1 deletion review/src/symbols/extractor.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Tree-sitter based symbol extraction and hunk mapping.

use super::{FileSymbolDiff, LineRange, Symbol, SymbolChangeType, SymbolDiff, SymbolKind};
use super::{
FileSymbolDiff, LineRange, Symbol, SymbolChangeType, SymbolDefinition, SymbolDiff, SymbolKind,
};
use crate::diff::parser::DiffHunk;
use std::collections::HashMap;
use tree_sitter::{Language, Node, Parser};
Expand Down Expand Up @@ -56,6 +58,40 @@ pub fn extract_symbols(source: &str, file_path: &str) -> Option<Vec<Symbol>> {
Some(extract_symbols_from_node(root, source, &ext))
}

/// Find all symbol definitions matching the given name in a file.
///
/// Searches the extracted symbols (including children) for exact name matches.
pub fn find_definitions(source: &str, file_path: &str, symbol_name: &str) -> Vec<SymbolDefinition> {
let Some(symbols) = extract_symbols(source, file_path) else {
return Vec::new();
};

let mut results = Vec::new();
collect_matching_symbols(&symbols, file_path, symbol_name, &mut results);
results
}

/// Recursively collect symbols matching the given name.
fn collect_matching_symbols(
symbols: &[Symbol],
file_path: &str,
symbol_name: &str,
results: &mut Vec<SymbolDefinition>,
) {
for symbol in symbols {
if symbol.name == symbol_name {
results.push(SymbolDefinition {
file_path: file_path.to_owned(),
name: symbol.name.clone(),
kind: symbol.kind.clone(),
start_line: symbol.start_line,
end_line: symbol.end_line,
});
}
collect_matching_symbols(&symbol.children, file_path, symbol_name, results);
}
}

/// Recursively extract symbol definitions from a tree-sitter node.
fn extract_symbols_from_node(node: Node, source: &str, ext: &str) -> Vec<Symbol> {
let mut symbols = Vec::new();
Expand Down
13 changes: 13 additions & 0 deletions review/src/symbols/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ pub struct Symbol {
pub children: Vec<Symbol>,
}

/// A symbol definition found in the repository, with file location.
#[derive(Debug, Clone, Serialize)]
pub struct SymbolDefinition {
#[serde(rename = "filePath")]
pub file_path: String,
pub name: String,
pub kind: SymbolKind,
#[serde(rename = "startLine")]
pub start_line: u32,
#[serde(rename = "endLine")]
pub end_line: u32,
}

/// Maps symbols to hunks for a single file.
#[derive(Debug, Clone, Serialize)]
pub struct FileSymbolMap {
Expand Down
76 changes: 75 additions & 1 deletion src-tauri/src/desktop/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use review::sources::local_git::{LocalGitSource, RemoteInfo, SearchMatch};
use review::sources::traits::{
BranchList, CommitDetail, CommitEntry, Comparison, DiffSource, FileEntry, GitStatusSummary,
};
use review::symbols::{self, FileSymbolDiff, Symbol};
use review::symbols::{self, FileSymbolDiff, Symbol, SymbolDefinition};
use review::trust::patterns::TrustCategory;
use serde::Serialize;
use std::collections::hash_map::DefaultHasher;
Expand Down Expand Up @@ -1284,6 +1284,80 @@ pub async fn get_file_symbols(
.map_err(|e| e.to_string())?
}

#[tauri::command]
pub async fn find_symbol_definitions(
repo_path: String,
symbol_name: String,
) -> Result<Vec<SymbolDefinition>, String> {
debug!(
"[find_symbol_definitions] repo_path={}, symbol={}",
repo_path, symbol_name
);

tokio::task::spawn_blocking(move || {
let source = LocalGitSource::new(PathBuf::from(&repo_path)).map_err(|e| {
error!("[find_symbol_definitions] ERROR creating source: {e}");
e.to_string()
})?;

// Use git grep to quickly find candidate files containing the symbol name
let grep_output = std::process::Command::new("git")
.args(["grep", "-l", "-F", "--", &symbol_name])
.current_dir(&repo_path)
.output()
.map_err(|e| e.to_string())?;

let candidates: Vec<String> = if grep_output.status.success() {
String::from_utf8_lossy(&grep_output.stdout)
.lines()
.filter(|line| !line.is_empty())
.filter(|line| symbols::extractor::get_language_for_file(line).is_some())
.map(|s| s.to_owned())
.collect()
} else {
// No matches or error — fall back to all tracked files with grammars
source
.get_tracked_files()
.unwrap_or_default()
.into_iter()
.filter(|f| symbols::extractor::get_language_for_file(f).is_some())
.collect()
};

// Process candidate files in parallel
let results: Vec<SymbolDefinition> = std::thread::scope(|s| {
let handles: Vec<_> = candidates
.iter()
.map(|file_path| {
let repo_path = repo_path.as_str();
let symbol_name = symbol_name.as_str();
s.spawn(move || {
let full_path = PathBuf::from(repo_path).join(file_path);
let content = match std::fs::read_to_string(&full_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
symbols::extractor::find_definitions(&content, file_path, symbol_name)
})
})
.collect();
handles
.into_iter()
.filter_map(|h| h.join().ok())
.flatten()
.collect()
});

info!(
"[find_symbol_definitions] SUCCESS: {} definitions found",
results.len()
);
Ok(results)
})
.await
.map_err(|e| e.to_string())?
}

#[tauri::command]
pub fn search_file_contents(
repo_path: String,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/desktop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ pub fn run() {
commands::search_file_contents,
commands::get_file_symbol_diffs,
commands::get_file_symbols,
commands::find_symbol_definitions,
commands::generate_narrative,
])
.build(tauri::generate_context!())
Expand Down
7 changes: 7 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
SearchMatch,
FileSymbol,
FileSymbolDiff,
SymbolDefinition,
RemoteInfo,
NarrativeInput,
} from "../types";
Expand Down Expand Up @@ -192,6 +193,12 @@ export interface ApiClient {
gitRef?: string,
): Promise<FileSymbol[] | null>;

/** Find symbol definitions across the repository */
findSymbolDefinitions(
repoPath: string,
symbolName: string,
): Promise<SymbolDefinition[]>;

// ----- File watcher -----

/** Start watching for file changes in the repo */
Expand Down
9 changes: 9 additions & 0 deletions src/api/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
SearchMatch,
FileSymbol,
FileSymbolDiff,
SymbolDefinition,
RemoteInfo,
} from "../types";

Expand Down Expand Up @@ -491,6 +492,14 @@ export class HttpClient implements ApiClient {
return null;
}

async findSymbolDefinitions(
_repoPath: string,
_symbolName: string,
): Promise<SymbolDefinition[]> {
console.warn("[HttpClient] findSymbolDefinitions not implemented");
return [];
}

// ----- File watcher -----

async startFileWatcher(_repoPath: string): Promise<void> {
Expand Down
11 changes: 11 additions & 0 deletions src/api/tauri-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
SearchMatch,
FileSymbol,
FileSymbolDiff,
SymbolDefinition,
RemoteInfo,
NarrativeInput,
} from "../types";
Expand Down Expand Up @@ -286,6 +287,16 @@ export class TauriClient implements ApiClient {
});
}

async findSymbolDefinitions(
repoPath: string,
symbolName: string,
): Promise<SymbolDefinition[]> {
return invoke<SymbolDefinition[]>("find_symbol_definitions", {
repoPath,
symbolName,
});
}

// ----- File watcher -----

async startFileWatcher(repoPath: string): Promise<void> {
Expand Down
106 changes: 106 additions & 0 deletions src/components/CodeViewer/DefinitionPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../ui/dialog";
import { SymbolKindBadge } from "../symbols";
import type { SymbolDefinition } from "../../types";

interface DefinitionPickerProps {
open: boolean;
definitions: SymbolDefinition[];
onSelect: (def: SymbolDefinition) => void;
onClose: () => void;
}

export function DefinitionPicker({
open,
definitions,
onSelect,
onClose,
}: DefinitionPickerProps) {
const [selectedIndex, setSelectedIndex] = useState(0);

// Reset selection when definitions change
useEffect(() => {
setSelectedIndex(0);
}, [definitions]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, definitions.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (definitions[selectedIndex]) {
onSelect(definitions[selectedIndex]);
}
break;
}
},
[definitions, selectedIndex, onSelect],
);

return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-[480px] max-h-[400px] rounded-lg overflow-hidden"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<DialogTitle>
{definitions.length} definition{definitions.length !== 1 && "s"}{" "}
found
</DialogTitle>
<DialogDescription>
{definitions[0]?.name ?? "symbol"}
</DialogDescription>
</DialogHeader>

<div className="overflow-auto max-h-[300px] py-1">
{definitions.map((def, index) => {
const fileName = def.filePath.split("/").pop() ?? def.filePath;
const dirPath = def.filePath.includes("/")
? def.filePath.substring(0, def.filePath.lastIndexOf("/"))
: "";

return (
<button
key={`${def.filePath}:${def.startLine}`}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
index === selectedIndex
? "bg-stone-800 text-stone-100"
: "text-stone-300 hover:bg-stone-800/50"
}`}
onClick={() => onSelect(def)}
onMouseEnter={() => setSelectedIndex(index)}
>
<SymbolKindBadge kind={def.kind} />
<span className="font-mono text-xs font-medium text-stone-200">
{def.name}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-stone-500">
{dirPath && <span>{dirPath}/</span>}
<span className="text-stone-400">{fileName}</span>
</span>
<span className="flex-shrink-0 font-mono text-xxs tabular-nums text-stone-600">
:{def.startLine}
</span>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}
Loading