diff --git a/review/src/sources/local_git.rs b/review/src/sources/local_git.rs index bac12e23..329536a2 100644 --- a/review/src/sources/local_git.rs +++ b/review/src/sources/local_git.rs @@ -423,7 +423,7 @@ impl LocalGitSource { } /// Get all tracked files from git (fast, uses index) - fn get_tracked_files(&self) -> Result, LocalGitError> { + pub fn get_tracked_files(&self) -> Result, LocalGitError> { let output = self.run_git(&["ls-files"])?; Ok(output.lines().map(std::borrow::ToOwned::to_owned).collect()) } diff --git a/review/src/symbols/extractor.rs b/review/src/symbols/extractor.rs index 9e8c531c..4298608e 100644 --- a/review/src/symbols/extractor.rs +++ b/review/src/symbols/extractor.rs @@ -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}; @@ -56,6 +58,40 @@ pub fn extract_symbols(source: &str, file_path: &str) -> Option> { 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 { + 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, +) { + 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 { let mut symbols = Vec::new(); diff --git a/review/src/symbols/mod.rs b/review/src/symbols/mod.rs index 1a9e4f35..34123d11 100644 --- a/review/src/symbols/mod.rs +++ b/review/src/symbols/mod.rs @@ -36,6 +36,19 @@ pub struct Symbol { pub children: Vec, } +/// 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 { diff --git a/src-tauri/src/desktop/commands.rs b/src-tauri/src/desktop/commands.rs index d475389b..520b8e93 100644 --- a/src-tauri/src/desktop/commands.rs +++ b/src-tauri/src/desktop/commands.rs @@ -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; @@ -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, 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 = 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 = 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, diff --git a/src-tauri/src/desktop/mod.rs b/src-tauri/src/desktop/mod.rs index 3b3a6d96..07a9e6ad 100644 --- a/src-tauri/src/desktop/mod.rs +++ b/src-tauri/src/desktop/mod.rs @@ -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!()) diff --git a/src/api/client.ts b/src/api/client.ts index 38c72f11..05aa2314 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -27,6 +27,7 @@ import type { SearchMatch, FileSymbol, FileSymbolDiff, + SymbolDefinition, RemoteInfo, NarrativeInput, } from "../types"; @@ -192,6 +193,12 @@ export interface ApiClient { gitRef?: string, ): Promise; + /** Find symbol definitions across the repository */ + findSymbolDefinitions( + repoPath: string, + symbolName: string, + ): Promise; + // ----- File watcher ----- /** Start watching for file changes in the repo */ diff --git a/src/api/http-client.ts b/src/api/http-client.ts index 847c64e8..ebd89115 100644 --- a/src/api/http-client.ts +++ b/src/api/http-client.ts @@ -27,6 +27,7 @@ import type { SearchMatch, FileSymbol, FileSymbolDiff, + SymbolDefinition, RemoteInfo, } from "../types"; @@ -491,6 +492,14 @@ export class HttpClient implements ApiClient { return null; } + async findSymbolDefinitions( + _repoPath: string, + _symbolName: string, + ): Promise { + console.warn("[HttpClient] findSymbolDefinitions not implemented"); + return []; + } + // ----- File watcher ----- async startFileWatcher(_repoPath: string): Promise { diff --git a/src/api/tauri-client.ts b/src/api/tauri-client.ts index 01198cbf..76b9692d 100644 --- a/src/api/tauri-client.ts +++ b/src/api/tauri-client.ts @@ -29,6 +29,7 @@ import type { SearchMatch, FileSymbol, FileSymbolDiff, + SymbolDefinition, RemoteInfo, NarrativeInput, } from "../types"; @@ -286,6 +287,16 @@ export class TauriClient implements ApiClient { }); } + async findSymbolDefinitions( + repoPath: string, + symbolName: string, + ): Promise { + return invoke("find_symbol_definitions", { + repoPath, + symbolName, + }); + } + // ----- File watcher ----- async startFileWatcher(repoPath: string): Promise { diff --git a/src/components/CodeViewer/DefinitionPicker.tsx b/src/components/CodeViewer/DefinitionPicker.tsx new file mode 100644 index 00000000..1fad8936 --- /dev/null +++ b/src/components/CodeViewer/DefinitionPicker.tsx @@ -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 ( + !isOpen && onClose()}> + + + + {definitions.length} definition{definitions.length !== 1 && "s"}{" "} + found + + + {definitions[0]?.name ?? "symbol"} + + + +
+ {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 ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/CodeViewer/DiffMinimap.tsx b/src/components/CodeViewer/DiffMinimap.tsx new file mode 100644 index 00000000..3353c2f7 --- /dev/null +++ b/src/components/CodeViewer/DiffMinimap.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { ReviewState } from "../../types"; +import { isHunkTrusted } from "../../types"; +import { usePrefersReducedMotion } from "../../hooks"; + +// --- Public types --- + +export type HunkStatus = + | "pending" + | "trusted" + | "approved" + | "rejected" + | "classifying"; + +export interface MinimapMarker { + id: string; + topFraction: number; + heightFraction: number; + status: HunkStatus; + isFocused: boolean; +} + +interface DiffMinimapProps { + markers: MinimapMarker[]; + scrollContainer: HTMLElement | null; + onMarkerClick: (index: number) => void; +} + +// --- Helpers --- + +export function getHunkStatus( + hunkId: string, + reviewState: ReviewState | null, + trustList: string[], + classifyingHunkIds: Set, +): HunkStatus { + if (classifyingHunkIds.has(hunkId)) return "classifying"; + const hunkState = reviewState?.hunks[hunkId]; + if (!hunkState) return "pending"; + if (hunkState.status === "approved") return "approved"; + if (hunkState.status === "rejected") return "rejected"; + if (isHunkTrusted(hunkState, trustList)) return "trusted"; + return "pending"; +} + +const STATUS_COLORS: Record = { + pending: "bg-stone-500", + trusted: "bg-cyan-400", + approved: "bg-lime-400", + rejected: "bg-rose-400", + classifying: "bg-violet-400", +}; + +// --- Component --- + +export function DiffMinimap({ + markers, + scrollContainer, + onMarkerClick, +}: DiffMinimapProps) { + const viewportRef = useRef(null); + const rafId = useRef(0); + const prefersReducedMotion = usePrefersReducedMotion(); + + // Self-manage scroll tracking: attach passive scroll listener + ResizeObserver + // on the scrollContainer and directly mutate the viewport indicator div. + useEffect(() => { + if (!scrollContainer) return; + + const update = () => { + const el = viewportRef.current; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const topPercent = + scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0; + const heightPercent = + scrollHeight > 0 ? Math.min(clientHeight / scrollHeight, 1) * 100 : 100; + el.style.top = `${topPercent}%`; + el.style.height = `${heightPercent}%`; + }; + + const scheduleUpdate = () => { + cancelAnimationFrame(rafId.current); + rafId.current = requestAnimationFrame(update); + }; + + // Initial measurement + update(); + + scrollContainer.addEventListener("scroll", scheduleUpdate, { + passive: true, + }); + + const observer = new ResizeObserver(scheduleUpdate); + observer.observe(scrollContainer); + + return () => { + cancelAnimationFrame(rafId.current); + scrollContainer.removeEventListener("scroll", scheduleUpdate); + observer.disconnect(); + }; + }, [scrollContainer]); + + const handleTrackClick = useCallback( + (e: React.MouseEvent) => { + // Only respond to clicks directly on the track (not on markers) + if (e.target !== e.currentTarget) return; + if (!scrollContainer) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientY - rect.top) / rect.height; + const maxScroll = + scrollContainer.scrollHeight - scrollContainer.clientHeight; + scrollContainer.scrollTo({ + top: fraction * maxScroll, + behavior: "smooth", + }); + }, + [scrollContainer], + ); + + return ( +