From eff1cb27c6692fb3d23909ac935a370b058dc245 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 16 Jan 2026 16:10:10 -0800 Subject: [PATCH 1/4] feat: add Go to Definition with Cmd+Click and context menu Adds symbol navigation functionality using Tree-sitter for code parsing: - Cmd+Click on symbols to jump to their definition - Right-click context menu with "Go to Definition" option - Opens definitions as reference files if not in current diff - Shows tooltip when definition is not found - Supports 17 languages: TypeScript, JavaScript, Rust, Python, Go, Java, C, C++, C#, Ruby, PHP, Bash, HTML, CSS, JSON, and variants Backend (Rust): - New symbols module with Tree-sitter parsing - Language detection from file extensions - Definition finding in same file, imports, and sibling modules - Filters out string literals, comments, and keywords Frontend (Svelte): - ContextMenu component for right-click actions - symbolNavigation service for IPC calls - Click handlers with Cmd key detection - Tooltip for "Definition not found" feedback Co-Authored-By: Claude Opus 4.5 --- src-tauri/Cargo.lock | 191 +++++++ src-tauri/Cargo.toml | 19 + src-tauri/src/lib.rs | 140 ++++- src-tauri/src/symbols/definition.rs | 760 +++++++++++++++++++++++++++ src-tauri/src/symbols/languages.rs | 292 ++++++++++ src-tauri/src/symbols/mod.rs | 19 + src-tauri/src/symbols/parser.rs | 620 ++++++++++++++++++++++ src/lib/ContextMenu.svelte | 238 +++++++++ src/lib/DiffViewer.svelte | 400 +++++++++++++- src/lib/services/symbolNavigation.ts | 225 ++++++++ 10 files changed, 2897 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/symbols/definition.rs create mode 100644 src-tauri/src/symbols/languages.rs create mode 100644 src-tauri/src/symbols/mod.rs create mode 100644 src-tauri/src/symbols/parser.rs create mode 100644 src/lib/ContextMenu.svelte create mode 100644 src/lib/services/symbolNavigation.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8698178..fcd81f3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4438,6 +4438,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "streaming-iterator", "syntect", "tauri", "tauri-build", @@ -4447,6 +4448,22 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-go", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-typescript", "uuid", ] @@ -4456,6 +4473,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "string_cache" version = "0.8.9" @@ -5342,6 +5365,174 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67f06accca7b45351758663b8215089e643d53bd9a660ce0349314263737fcb0" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a5d6b3ea17e06e7a34aabeadd68f5866c0d0f9359155d432095f8b751865e4" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" + +[[package]] +name = "tree-sitter-php" +version = "0.23.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f066e94e9272cfe4f1dcb07a1c50c66097eca648f2d7233d299c8ae9ed8c130c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6fe5498..e1839a5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,25 @@ ignore = "0.4" # Syntax highlighting syntect = "5.2" + +# Symbol parsing (Go to Definition) +tree-sitter = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-go = "0.23" +tree-sitter-java = "0.23" +tree-sitter-c = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-c-sharp = "0.23" +tree-sitter-ruby = "0.23" +tree-sitter-json = "0.23" +tree-sitter-php = "0.23" +tree-sitter-bash = "0.23" +tree-sitter-html = "0.23" +tree-sitter-css = "0.23" +streaming-iterator = "0.1" tauri-plugin-dialog = "2.4.2" # Review storage diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5140962..8a944dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,15 +4,17 @@ pub mod git; pub mod review; +mod symbols; mod themes; mod watcher; use git::{ - DiffId, DiffSpec, File, FileDiff, FileDiffSummary, GitHubAuthStatus, GitHubSyncResult, GitRef, - PullRequest, + DiffId, DiffSpec, File, FileContent, FileDiff, FileDiffSummary, GitHubAuthStatus, + GitHubSyncResult, GitRef, PullRequest, }; use review::{Comment, Edit, NewComment, NewEdit, Review}; use std::path::{Path, PathBuf}; +use symbols::{DefinitionResult, SymbolInfo}; use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Emitter, Manager, State, Wry}; use watcher::WatcherHandle; @@ -68,6 +70,136 @@ fn get_file_at_ref( git::get_file_at_ref(repo, &ref_name, &path).map_err(|e| e.to_string()) } +// ============================================================================= +// Symbol Navigation Commands +// ============================================================================= + +/// Get symbol information at a specific position in a file. +/// +/// Used for "Go to Definition" - finds what symbol is at the cursor position. +#[tauri::command(rename_all = "camelCase")] +fn get_symbol_at_position( + repo_path: Option, + ref_name: String, + file_path: String, + line: usize, + column: usize, +) -> Result, String> { + let repo = get_repo_path(repo_path.as_deref()); + let file = git::get_file_at_ref(repo, &ref_name, &file_path).map_err(|e| e.to_string())?; + + // Extract text content or return None for binary files + let content = match &file.content { + FileContent::Text { lines } => lines.join("\n"), + FileContent::Binary => return Ok(None), + }; + + let path = PathBuf::from(&file_path); + Ok(symbols::get_symbol_at_position(&path, &content, line, column)) +} + +/// Find the definition of a symbol. +/// +/// Searches in the same file first, then looks at imports to find the definition. +/// Returns the file path and location of the definition. +#[tauri::command(rename_all = "camelCase")] +async fn find_definition( + repo_path: Option, + ref_name: String, + file_path: String, + symbol_name: String, + symbol_line: usize, + symbol_column: usize, +) -> Result, String> { + let ref_name_clone = ref_name.clone(); + let file_path_clone = file_path.clone(); + + // Get the absolute repo path (canonicalized for consistent comparison) + let repo = if let Some(ref p) = repo_path { + PathBuf::from(p) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(p)) + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + + // Run on blocking thread pool since we may read multiple files + tokio::task::spawn_blocking(move || { + // Make the current file path absolute by joining with repo + let current_file = repo.join(&file_path_clone); + + log::info!( + "find_definition: repo={:?}, file_path={}, current_file={:?}", + repo, + file_path_clone, + current_file + ); + + // Read the current file + let file = git::get_file_at_ref(&repo, &ref_name_clone, &file_path_clone) + .map_err(|e| e.to_string())?; + + // Extract text content or return None for binary files + let current_content = match &file.content { + FileContent::Text { lines } => lines.join("\n"), + FileContent::Binary => return Ok(None), + }; + + // Create the symbol info + let symbol = SymbolInfo { + name: symbol_name, + kind: symbols::SymbolKind::Unknown, + line: symbol_line, + column: symbol_column, + end_column: symbol_column, + context: None, + language: String::new(), + }; + + // File reader that uses git to read files at the specified ref + // Supports both absolute paths and paths relative to repo root + let read_file = |path: &Path| -> Option { + // Try to get a path relative to repo + let rel_path = if path.is_absolute() { + // Try to strip repo prefix + path.strip_prefix(&repo) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| { + // If strip fails, use the path as-is (might already be relative) + log::debug!("read_file: strip_prefix failed for {:?} with repo {:?}", path, repo); + path.to_path_buf() + }) + } else { + path.to_path_buf() + }; + + let path_str = rel_path.to_string_lossy().to_string(); + log::debug!("read_file: trying to read '{}' from git", path_str); + let file = git::get_file_at_ref(&repo, &ref_name_clone, &path_str).ok()?; + match file.content { + FileContent::Text { lines } => Some(lines.join("\n")), + FileContent::Binary => None, + } + }; + + Ok(symbols::find_definition( + &symbol, + ¤t_file, + ¤t_content, + &repo, + read_file, + )) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Check if a file type supports symbol navigation. +#[tauri::command(rename_all = "camelCase")] +fn supports_symbol_navigation(file_path: String) -> bool { + symbols::is_supported_extension(Path::new(&file_path)) +} + // ============================================================================= // Git Commands // ============================================================================= @@ -493,6 +625,10 @@ pub fn run() { // File browsing commands search_files, get_file_at_ref, + // Symbol navigation commands + get_symbol_at_position, + find_definition, + supports_symbol_navigation, // Git commands get_repo_root, list_refs, diff --git a/src-tauri/src/symbols/definition.rs b/src-tauri/src/symbols/definition.rs new file mode 100644 index 0000000..80fb9ab --- /dev/null +++ b/src-tauri/src/symbols/definition.rs @@ -0,0 +1,760 @@ +//! Definition finding logic. +//! +//! Given a symbol, searches for its definition in: +//! 1. The same file +//! 2. Imported modules + +use super::languages::SupportedLanguage; +use super::parser::{find_all_symbols, parse_source, SymbolInfo, SymbolKind}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tree_sitter::Node; + +/// Result of a definition search. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DefinitionResult { + /// Path to the file containing the definition + pub file_path: String, + /// Line number (0-indexed) + pub line: usize, + /// Column number (0-indexed) + pub column: usize, + /// End column number (0-indexed) + pub end_column: usize, + /// The symbol name + pub name: String, + /// The kind of symbol + pub kind: SymbolKind, + /// Preview of the definition line + pub preview: String, +} + +/// Find the definition of a symbol. +/// +/// # Arguments +/// * `symbol` - The symbol to find +/// * `current_file` - Path to the file where the symbol was referenced +/// * `current_content` - Content of the current file +/// * `repo_root` - Root of the repository (for resolving imports) +/// * `read_file` - Function to read file contents +/// +/// # Returns +/// `Some(DefinitionResult)` if found, `None` otherwise. +pub fn find_definition( + symbol: &SymbolInfo, + current_file: &Path, + current_content: &str, + repo_root: &Path, + read_file: F, +) -> Option +where + F: Fn(&Path) -> Option, +{ + let language = SupportedLanguage::from_path(current_file)?; + + // 1. Search in the same file first + if let Some(def) = find_definition_in_file(&symbol.name, current_content, current_file) { + // Don't return if it's the same location (we're already on the definition) + if def.line != symbol.line || def.column != symbol.column { + return Some(def); + } + } + + // 2. Try to resolve through imports + let imports = extract_imports(current_content, language, current_file); + for import in imports { + if import.names.contains(&symbol.name) || import.names.contains(&"*".to_string()) { + // Resolve the import path + if let Some(resolved_path) = resolve_import_path(&import.source, current_file, repo_root) + { + if let Some(content) = read_file(&resolved_path) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &resolved_path) + { + return Some(def); + } + } + } + } + } + + // 3. Try common patterns (index files, etc.) + if let Some(def) = try_common_patterns(symbol, current_file, repo_root, &read_file) { + return Some(def); + } + + None +} + +/// Find a definition within a single file. +fn find_definition_in_file(name: &str, content: &str, file_path: &Path) -> Option { + let symbols = find_all_symbols(file_path, content); + + log::debug!( + "find_definition_in_file: looking for '{}' in {:?}, found {} symbols", + name, + file_path.file_name(), + symbols.len() + ); + + // Log first few symbols for debugging + for (i, sym) in symbols.iter().take(10).enumerate() { + log::debug!(" Symbol {}: {} ({:?})", i, sym.name, sym.kind); + } + + // Find the first definition matching this name + for symbol in symbols { + if symbol.name == name && !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown) { + log::debug!("Found definition: {} at line {}", symbol.name, symbol.line); + return Some(DefinitionResult { + file_path: file_path.to_string_lossy().to_string(), + line: symbol.line, + column: symbol.column, + end_column: symbol.end_column, + name: symbol.name, + kind: symbol.kind, + preview: symbol.context.unwrap_or_default(), + }); + } + } + + None +} + +/// Import information extracted from source code. +#[derive(Debug)] +struct ImportInfo { + /// The source/path of the import + source: String, + /// Names imported (e.g., ["foo", "bar"] for "import { foo, bar } from './module'") + names: Vec, +} + +/// Extract imports from source code. +fn extract_imports( + content: &str, + language: SupportedLanguage, + _file_path: &Path, +) -> Vec { + let Some(tree) = parse_source(content, language) else { + return vec![]; + }; + + let mut imports = Vec::new(); + extract_imports_recursive(tree.root_node(), content, language, &mut imports); + imports +} + +/// Recursively extract import statements. +fn extract_imports_recursive( + node: Node, + content: &str, + language: SupportedLanguage, + imports: &mut Vec, +) { + let kind = node.kind(); + + match language { + SupportedLanguage::TypeScript + | SupportedLanguage::Tsx + | SupportedLanguage::JavaScript + | SupportedLanguage::Jsx => { + if kind == "import_statement" { + if let Some(import) = extract_js_import(node, content) { + imports.push(import); + } + } + } + SupportedLanguage::Python => { + if kind == "import_statement" || kind == "import_from_statement" { + if let Some(import) = extract_python_import(node, content) { + imports.push(import); + } + } + } + SupportedLanguage::Rust => { + if kind == "use_declaration" { + if let Some(import) = extract_rust_use(node, content) { + imports.push(import); + } + } + } + SupportedLanguage::Go => { + if kind == "import_declaration" { + if let Some(import) = extract_go_import(node, content) { + imports.push(import); + } + } + } + _ => {} + } + + // Recurse into children + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + extract_imports_recursive(child, content, language, imports); + } +} + +/// Extract import info from a JS/TS import statement. +fn extract_js_import(node: Node, content: &str) -> Option { + let mut source = String::new(); + let mut names = Vec::new(); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "string" | "string_fragment" => { + // Remove quotes + let text = child.utf8_text(content.as_bytes()).ok()?; + source = text.trim_matches(|c| c == '"' || c == '\'').to_string(); + } + "import_clause" => { + // Extract named imports + let mut inner_cursor = child.walk(); + for import_child in child.children(&mut inner_cursor) { + match import_child.kind() { + "identifier" => { + // Default import + if let Ok(name) = import_child.utf8_text(content.as_bytes()) { + names.push(name.to_string()); + } + } + "named_imports" => { + // { foo, bar } + extract_named_imports(import_child, content, &mut names); + } + "namespace_import" => { + // * as foo + names.push("*".to_string()); + } + _ => {} + } + } + } + _ => {} + } + } + + if source.is_empty() { + return None; + } + + Some(ImportInfo { source, names }) +} + +/// Extract names from named imports: { foo, bar, baz as qux } +fn extract_named_imports(node: Node, content: &str, names: &mut Vec) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "import_specifier" { + // Get the imported name (could be renamed with "as") + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(content.as_bytes()) { + names.push(name.to_string()); + } + } else { + // No "as" clause, use first identifier + let mut inner_cursor = child.walk(); + for inner_child in child.children(&mut inner_cursor) { + if inner_child.kind() == "identifier" { + if let Ok(name) = inner_child.utf8_text(content.as_bytes()) { + names.push(name.to_string()); + break; + } + } + } + } + } + } +} + +/// Extract import info from a Python import statement. +fn extract_python_import(node: Node, content: &str) -> Option { + let mut source = String::new(); + let mut names = Vec::new(); + + if node.kind() == "import_statement" { + // import foo, bar + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "dotted_name" { + if let Ok(name) = child.utf8_text(content.as_bytes()) { + source = name.to_string(); + names.push(name.split('.').last().unwrap_or(name).to_string()); + } + } + } + } else if node.kind() == "import_from_statement" { + // from foo import bar, baz + if let Some(module) = node.child_by_field_name("module_name") { + source = module.utf8_text(content.as_bytes()).ok()?.to_string(); + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "dotted_name" || child.kind() == "identifier" { + if let Ok(name) = child.utf8_text(content.as_bytes()) { + if name != &source { + names.push(name.to_string()); + } + } + } else if child.kind() == "aliased_import" { + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(content.as_bytes()) { + names.push(name.to_string()); + } + } + } + } + } + + if source.is_empty() { + return None; + } + + Some(ImportInfo { source, names }) +} + +/// Extract import info from a Rust use statement. +fn extract_rust_use(node: Node, content: &str) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some((path, names)) = extract_rust_use_path(child, content) { + return Some(ImportInfo { + source: path, + names, + }); + } + } + None +} + +/// Extract path and names from a Rust use tree. +fn extract_rust_use_path(node: Node, content: &str) -> Option<(String, Vec)> { + match node.kind() { + "scoped_identifier" | "identifier" => { + let path = node.utf8_text(content.as_bytes()).ok()?.to_string(); + let name = path.split("::").last().unwrap_or(&path).to_string(); + Some((path, vec![name])) + } + "use_list" => { + let mut names = Vec::new(); + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some((_, mut child_names)) = extract_rust_use_path(child, content) { + names.append(&mut child_names); + } + } + Some((String::new(), names)) + } + "scoped_use_list" => { + let path = node + .child_by_field_name("path") + .and_then(|n| n.utf8_text(content.as_bytes()).ok()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + let mut names = Vec::new(); + if let Some(list) = node.child_by_field_name("list") { + let mut cursor = list.walk(); + for child in list.children(&mut cursor) { + if let Some((_, mut child_names)) = extract_rust_use_path(child, content) { + names.append(&mut child_names); + } + } + } + Some((path, names)) + } + _ => None, + } +} + +/// Extract import info from a Go import statement. +fn extract_go_import(node: Node, content: &str) -> Option { + let mut imports = Vec::new(); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "import_spec_list" { + let mut inner_cursor = child.walk(); + for spec in child.children(&mut inner_cursor) { + if spec.kind() == "import_spec" { + if let Some(path) = spec.child_by_field_name("path") { + if let Ok(path_str) = path.utf8_text(content.as_bytes()) { + let clean_path = path_str.trim_matches('"'); + let name = clean_path.split('/').last().unwrap_or(clean_path); + imports.push(ImportInfo { + source: clean_path.to_string(), + names: vec![name.to_string()], + }); + } + } + } + } + } else if child.kind() == "import_spec" { + if let Some(path) = child.child_by_field_name("path") { + if let Ok(path_str) = path.utf8_text(content.as_bytes()) { + let clean_path = path_str.trim_matches('"'); + let name = clean_path.split('/').last().unwrap_or(clean_path); + return Some(ImportInfo { + source: clean_path.to_string(), + names: vec![name.to_string()], + }); + } + } + } + } + + imports.into_iter().next() +} + +/// Resolve an import path to an actual file path. +fn resolve_import_path( + import_source: &str, + current_file: &Path, + repo_root: &Path, +) -> Option { + let current_dir = current_file.parent()?; + let ext = current_file.extension()?.to_str()?; + + // Handle Rust module paths + if ext == "rs" { + return resolve_rust_module_path(import_source, current_file, repo_root); + } + + // Handle JavaScript/TypeScript relative imports + if import_source.starts_with('.') { + let extensions = [ + "", + ".ts", + ".tsx", + ".js", + ".jsx", + "/index.ts", + "/index.tsx", + "/index.js", + ]; + + for ext in extensions { + let candidate = current_dir.join(format!("{}{}", import_source, ext)); + if candidate.exists() { + return Some(candidate); + } + } + return None; + } + + // Handle absolute imports (from node_modules or src) + // Try src/ directory first (common convention) + let extensions = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"]; + for ext in extensions { + let candidate = repo_root.join("src").join(format!("{}{}", import_source, ext)); + if candidate.exists() { + return Some(candidate); + } + } + + // Could also check node_modules, but that's usually too deep + None +} + +/// Resolve a Rust module path (e.g., "super::parser" or "crate::symbols::parser") +fn resolve_rust_module_path( + module_path: &str, + current_file: &Path, + repo_root: &Path, +) -> Option { + let current_dir = current_file.parent()?; + let parts: Vec<&str> = module_path.split("::").collect(); + + if parts.is_empty() { + return None; + } + + let mut path_parts = Vec::new(); + let mut start_dir = current_dir.to_path_buf(); + + for (i, part) in parts.iter().enumerate() { + match *part { + "super" => { + // Go up one directory + start_dir = start_dir.parent()?.to_path_buf(); + } + "self" => { + // Stay in current directory + } + "crate" => { + // Go to crate root (find Cargo.toml) + start_dir = find_crate_root(current_file, repo_root)?; + // For lib.rs crate, src/ is the root + if start_dir.join("src").exists() { + start_dir = start_dir.join("src"); + } + } + _ => { + // This is a module name, collect remaining parts + path_parts = parts[i..].to_vec(); + break; + } + } + } + + if path_parts.is_empty() { + return None; + } + + // Try different module file patterns + // 1. module_name.rs + // 2. module_name/mod.rs + // 3. For nested paths like parser::SymbolInfo, try parser.rs + + // Build the path from collected parts (excluding the last item which is the symbol name) + let module_parts = if path_parts.len() > 1 { + &path_parts[..path_parts.len() - 1] + } else { + &path_parts[..] + }; + + for (idx, _) in module_parts.iter().enumerate() { + let subpath: PathBuf = module_parts[..=idx].iter().collect(); + + // Try module_name.rs + let candidate = start_dir.join(format!("{}.rs", subpath.display())); + if candidate.exists() { + return Some(candidate); + } + + // Try module_name/mod.rs + let candidate = start_dir.join(&subpath).join("mod.rs"); + if candidate.exists() { + return Some(candidate); + } + } + + // Also try just the first part as a file + let first_module = path_parts.first()?; + let candidate = start_dir.join(format!("{}.rs", first_module)); + if candidate.exists() { + return Some(candidate); + } + + let candidate = start_dir.join(first_module).join("mod.rs"); + if candidate.exists() { + return Some(candidate); + } + + None +} + +/// Find the crate root (directory containing Cargo.toml) +fn find_crate_root(current_file: &Path, repo_root: &Path) -> Option { + let mut dir = current_file.parent()?; + + while dir.starts_with(repo_root) { + if dir.join("Cargo.toml").exists() { + return Some(dir.to_path_buf()); + } + dir = dir.parent()?; + } + + None +} + +/// Try common patterns to find definitions. +fn try_common_patterns( + symbol: &SymbolInfo, + current_file: &Path, + repo_root: &Path, + read_file: &F, +) -> Option +where + F: Fn(&Path) -> Option, +{ + let language = SupportedLanguage::from_path(current_file)?; + + match language { + SupportedLanguage::TypeScript + | SupportedLanguage::Tsx + | SupportedLanguage::JavaScript + | SupportedLanguage::Jsx => { + // Check for a file with the same name as the symbol + let candidates = [ + format!("src/lib/{}.ts", symbol.name), + format!("src/lib/{}.tsx", symbol.name), + format!("src/components/{}.tsx", symbol.name), + format!("src/components/{}.svelte", symbol.name), + format!("src/{}.ts", symbol.name), + format!("lib/{}.ts", symbol.name), + ]; + + for candidate in candidates { + let path = repo_root.join(&candidate); + if let Some(content) = read_file(&path) { + if let Some(def) = find_definition_in_file(&symbol.name, &content, &path) { + return Some(def); + } + } + } + } + SupportedLanguage::Rust => { + // For Rust, search sibling files in the same module directory + if let Some(current_dir) = current_file.parent() { + log::debug!( + "try_common_patterns: searching Rust sibling files in {:?}", + current_dir + ); + + // Get all .rs files in the current directory + if let Ok(entries) = std::fs::read_dir(current_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "rs").unwrap_or(false) + && path != current_file + { + log::debug!(" Checking sibling file: {:?}", path.file_name()); + if let Some(content) = read_file(&path) { + log::debug!(" File read successfully, content length: {}", content.len()); + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &path) + { + return Some(def); + } + } else { + log::debug!(" Failed to read file via git"); + } + } + } + } else { + log::debug!(" Failed to read directory"); + } + + // Also check mod.rs if we're in a module directory + let mod_file = current_dir.join("mod.rs"); + if mod_file.exists() && mod_file != current_file { + if let Some(content) = read_file(&mod_file) { + if let Some(def) = find_definition_in_file(&symbol.name, &content, &mod_file) + { + return Some(def); + } + } + } + + // Check parent's lib.rs or main.rs + if let Some(parent_dir) = current_dir.parent() { + for name in ["lib.rs", "main.rs"] { + let parent_file = parent_dir.join(name); + if parent_file.exists() { + if let Some(content) = read_file(&parent_file) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &parent_file) + { + return Some(def); + } + } + } + } + } + } + } + SupportedLanguage::Python => { + // For Python, check __init__.py and sibling files + if let Some(current_dir) = current_file.parent() { + let init_file = current_dir.join("__init__.py"); + if init_file.exists() && init_file != current_file { + if let Some(content) = read_file(&init_file) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &init_file) + { + return Some(def); + } + } + } + + // Search other .py files in the directory + if let Ok(entries) = std::fs::read_dir(current_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "py").unwrap_or(false) + && path != current_file + { + if let Some(content) = read_file(&path) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &path) + { + return Some(def); + } + } + } + } + } + } + } + SupportedLanguage::Go => { + // For Go, search all .go files in the same package (directory) + if let Some(current_dir) = current_file.parent() { + if let Ok(entries) = std::fs::read_dir(current_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "go").unwrap_or(false) + && path != current_file + { + if let Some(content) = read_file(&path) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &path) + { + return Some(def); + } + } + } + } + } + } + } + _ => {} + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_definition_in_same_file() { + let content = r#" +function greet(name: string): string { + return `Hello, ${name}!`; +} + +const result = greet("World"); +"#; + let path = PathBuf::from("test.ts"); + let def = find_definition_in_file("greet", content, &path); + + assert!(def.is_some()); + let def = def.unwrap(); + assert_eq!(def.name, "greet"); + assert_eq!(def.line, 1); + } + + #[test] + fn test_extract_js_imports() { + let content = r#" +import { foo, bar } from './module'; +import defaultExport from './other'; +import * as all from './all'; +"#; + let imports = extract_imports( + content, + SupportedLanguage::TypeScript, + Path::new("test.ts"), + ); + + assert!(!imports.is_empty()); + assert!(imports.iter().any(|i| i.names.contains(&"foo".to_string()))); + assert!(imports.iter().any(|i| i.names.contains(&"bar".to_string()))); + } +} diff --git a/src-tauri/src/symbols/languages.rs b/src-tauri/src/symbols/languages.rs new file mode 100644 index 0000000..a2df4d6 --- /dev/null +++ b/src-tauri/src/symbols/languages.rs @@ -0,0 +1,292 @@ +//! Language detection and Tree-sitter grammar loading. + +use std::path::Path; +use tree_sitter::Language; + +/// Supported languages for symbol parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupportedLanguage { + TypeScript, + Tsx, + JavaScript, + Jsx, + Rust, + Python, + Go, + Java, + C, + Cpp, + CSharp, + Ruby, + Json, + Php, + Bash, + Html, + Css, +} + +impl SupportedLanguage { + /// Detect language from file path extension. + pub fn from_path(path: &Path) -> Option { + let ext = path.extension()?.to_str()?; + Self::from_extension(ext) + } + + /// Detect language from file extension string. + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "ts" | "mts" | "cts" => Some(Self::TypeScript), + "tsx" => Some(Self::Tsx), + "js" | "mjs" | "cjs" => Some(Self::JavaScript), + "jsx" => Some(Self::Jsx), + "rs" => Some(Self::Rust), + "py" | "pyi" | "pyw" => Some(Self::Python), + "go" => Some(Self::Go), + "java" => Some(Self::Java), + "c" | "h" => Some(Self::C), + "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" | "c++" | "h++" => Some(Self::Cpp), + "cs" => Some(Self::CSharp), + "rb" | "rake" | "gemspec" => Some(Self::Ruby), + "json" | "jsonc" => Some(Self::Json), + "php" | "phtml" | "php3" | "php4" | "php5" | "phps" => Some(Self::Php), + "sh" | "bash" | "zsh" | "fish" => Some(Self::Bash), + "html" | "htm" | "xhtml" => Some(Self::Html), + "css" | "scss" | "sass" | "less" => Some(Self::Css), + _ => None, + } + } + + /// Get the Tree-sitter language for this language type. + pub fn tree_sitter_language(&self) -> Language { + match self { + Self::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Self::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Self::JavaScript | Self::Jsx => tree_sitter_javascript::LANGUAGE.into(), + Self::Rust => tree_sitter_rust::LANGUAGE.into(), + Self::Python => tree_sitter_python::LANGUAGE.into(), + Self::Go => tree_sitter_go::LANGUAGE.into(), + Self::Java => tree_sitter_java::LANGUAGE.into(), + Self::C => tree_sitter_c::LANGUAGE.into(), + Self::Cpp => tree_sitter_cpp::LANGUAGE.into(), + Self::CSharp => tree_sitter_c_sharp::LANGUAGE.into(), + Self::Ruby => tree_sitter_ruby::LANGUAGE.into(), + Self::Json => tree_sitter_json::LANGUAGE.into(), + Self::Php => tree_sitter_php::LANGUAGE_PHP.into(), + Self::Bash => tree_sitter_bash::LANGUAGE.into(), + Self::Html => tree_sitter_html::LANGUAGE.into(), + Self::Css => tree_sitter_css::LANGUAGE.into(), + } + } + + /// Get definition node types for this language. + /// These are AST node types that can define symbols. + pub fn definition_node_types(&self) -> &'static [&'static str] { + match self { + Self::TypeScript | Self::Tsx => &[ + "function_declaration", + "method_definition", + "class_declaration", + "interface_declaration", + "type_alias_declaration", + "enum_declaration", + "variable_declarator", + "lexical_declaration", + "export_statement", + "import_statement", + "arrow_function", + ], + Self::JavaScript | Self::Jsx => &[ + "function_declaration", + "method_definition", + "class_declaration", + "variable_declarator", + "lexical_declaration", + "export_statement", + "import_statement", + "arrow_function", + ], + Self::Rust => &[ + "function_item", + "struct_item", + "enum_item", + "type_item", + "trait_item", + "impl_item", + "mod_item", + "const_item", + "static_item", + "macro_definition", + "use_declaration", + ], + Self::Python => &[ + "function_definition", + "class_definition", + "assignment", + "import_statement", + "import_from_statement", + ], + Self::Go => &[ + "function_declaration", + "method_declaration", + "type_declaration", + "const_declaration", + "var_declaration", + "import_declaration", + ], + Self::Java => &[ + "method_declaration", + "class_declaration", + "interface_declaration", + "enum_declaration", + "field_declaration", + "import_declaration", + ], + Self::C | Self::Cpp => &[ + "function_definition", + "declaration", + "struct_specifier", + "enum_specifier", + "type_definition", + ], + Self::CSharp => &[ + "method_declaration", + "class_declaration", + "interface_declaration", + "struct_declaration", + "enum_declaration", + "field_declaration", + "property_declaration", + ], + Self::Ruby => &[ + "method", + "singleton_method", + "class", + "module", + "assignment", + ], + Self::Json => &[], // JSON doesn't have definitions + Self::Php => &[ + "function_definition", + "method_declaration", + "class_declaration", + "interface_declaration", + "trait_declaration", + "const_declaration", + "property_declaration", + ], + Self::Bash => &[ + "function_definition", + "variable_assignment", + ], + Self::Html => &[], // HTML doesn't have traditional definitions + Self::Css => &[ + "rule_set", + "keyframes_statement", + ], + } + } + + /// Get identifier node types for this language. + /// These nodes contain symbol names that can be clicked. + pub fn identifier_node_types(&self) -> &'static [&'static str] { + match self { + Self::TypeScript | Self::Tsx | Self::JavaScript | Self::Jsx => &[ + "identifier", + "property_identifier", + "type_identifier", + "shorthand_property_identifier", + ], + Self::Rust => &[ + "identifier", + "type_identifier", + "field_identifier", + "scoped_identifier", + ], + Self::Python => &["identifier"], + Self::Go => &["identifier", "type_identifier", "field_identifier"], + Self::Java => &["identifier", "type_identifier"], + Self::C | Self::Cpp => &["identifier", "type_identifier", "field_identifier"], + Self::CSharp => &["identifier"], + Self::Ruby => &["identifier", "constant"], + Self::Json => &["string"], // Keys in JSON + Self::Php => &["name", "variable_name"], + Self::Bash => &["variable_name", "word"], + Self::Html => &["tag_name", "attribute_name"], + Self::Css => &["class_name", "id_name", "property_name"], + } + } + + /// Get the name of the language for display purposes. + pub fn name(&self) -> &'static str { + match self { + Self::TypeScript => "TypeScript", + Self::Tsx => "TSX", + Self::JavaScript => "JavaScript", + Self::Jsx => "JSX", + Self::Rust => "Rust", + Self::Python => "Python", + Self::Go => "Go", + Self::Java => "Java", + Self::C => "C", + Self::Cpp => "C++", + Self::CSharp => "C#", + Self::Ruby => "Ruby", + Self::Json => "JSON", + Self::Php => "PHP", + Self::Bash => "Bash", + Self::Html => "HTML", + Self::Css => "CSS", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_language_from_extension() { + assert_eq!( + SupportedLanguage::from_extension("ts"), + Some(SupportedLanguage::TypeScript) + ); + assert_eq!( + SupportedLanguage::from_extension("tsx"), + Some(SupportedLanguage::Tsx) + ); + assert_eq!( + SupportedLanguage::from_extension("js"), + Some(SupportedLanguage::JavaScript) + ); + assert_eq!( + SupportedLanguage::from_extension("rs"), + Some(SupportedLanguage::Rust) + ); + assert_eq!( + SupportedLanguage::from_extension("py"), + Some(SupportedLanguage::Python) + ); + assert_eq!( + SupportedLanguage::from_extension("go"), + Some(SupportedLanguage::Go) + ); + assert_eq!(SupportedLanguage::from_extension("unknown"), None); + } + + #[test] + fn test_language_from_path() { + assert_eq!( + SupportedLanguage::from_path(&PathBuf::from("src/main.ts")), + Some(SupportedLanguage::TypeScript) + ); + assert_eq!( + SupportedLanguage::from_path(&PathBuf::from("lib/utils.py")), + Some(SupportedLanguage::Python) + ); + assert_eq!( + SupportedLanguage::from_path(&PathBuf::from("README.md")), + None + ); + } +} diff --git a/src-tauri/src/symbols/mod.rs b/src-tauri/src/symbols/mod.rs new file mode 100644 index 0000000..cd16b5a --- /dev/null +++ b/src-tauri/src/symbols/mod.rs @@ -0,0 +1,19 @@ +//! Symbol parsing and definition finding. +//! +//! Uses Tree-sitter to parse source code and find symbol definitions. +//! Supports multiple languages (TypeScript, JavaScript, Rust, Python, Go, etc.) + +mod definition; +mod languages; +mod parser; + +pub use definition::{find_definition, DefinitionResult}; +pub use parser::{get_symbol_at_position, SymbolInfo, SymbolKind}; + +use languages::SupportedLanguage; +use std::path::Path; + +/// Check if a file extension is supported for symbol navigation. +pub fn is_supported_extension(path: &Path) -> bool { + SupportedLanguage::from_path(path).is_some() +} diff --git a/src-tauri/src/symbols/parser.rs b/src-tauri/src/symbols/parser.rs new file mode 100644 index 0000000..d02a7b6 --- /dev/null +++ b/src-tauri/src/symbols/parser.rs @@ -0,0 +1,620 @@ +//! Tree-sitter parsing and symbol extraction. + +use super::languages::SupportedLanguage; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tree_sitter::{Node, Parser, Tree}; + +/// The kind of symbol (function, class, variable, etc.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SymbolKind { + Function, + Method, + Class, + Interface, + Type, + Enum, + Variable, + Constant, + Module, + Import, + Property, + Unknown, +} + +/// Information about a symbol at a position. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolInfo { + /// The symbol name + pub name: String, + /// The kind of symbol + pub kind: SymbolKind, + /// Line number (0-indexed) + pub line: usize, + /// Column number (0-indexed) + pub column: usize, + /// End column (0-indexed) + pub end_column: usize, + /// The full text of the node containing this symbol (for context) + pub context: Option, + /// Language of the file + pub language: String, +} + +/// Parse source code and build a Tree-sitter tree. +pub fn parse_source(content: &str, language: SupportedLanguage) -> Option { + let mut parser = Parser::new(); + parser + .set_language(&language.tree_sitter_language()) + .ok()?; + parser.parse(content, None) +} + +/// Get the symbol at a specific position in the source code. +/// +/// # Arguments +/// * `file_path` - Path to the file (used to detect language) +/// * `content` - The source code content +/// * `line` - Line number (0-indexed) +/// * `column` - Column number (0-indexed) +/// +/// # Returns +/// `Some(SymbolInfo)` if a symbol was found at the position, `None` otherwise. +pub fn get_symbol_at_position( + file_path: &Path, + content: &str, + line: usize, + column: usize, +) -> Option { + let language = SupportedLanguage::from_path(file_path)?; + let tree = parse_source(content, language)?; + let root = tree.root_node(); + + // Find the smallest node at the position + let point = tree_sitter::Point::new(line, column); + let node = find_smallest_node_at_point(root, point)?; + + // Reject if we're inside a string literal or comment + if is_inside_string_or_comment(node) { + return None; + } + + // Check if this is an identifier node + let identifier_types = language.identifier_node_types(); + if !identifier_types.contains(&node.kind()) { + // Try to find an identifier child + if let Some(id_node) = find_identifier_in_node(node, identifier_types) { + return extract_symbol_info(id_node, content, language); + } + return None; + } + + extract_symbol_info(node, content, language) +} + +/// Check if a node is inside a string literal or comment. +fn is_inside_string_or_comment(node: Node) -> bool { + let non_navigable_types = [ + // Strings + "string", + "string_literal", + "string_fragment", + "template_string", + "template_literal", + "raw_string_literal", + "char_literal", + "interpreted_string_literal", + "rune_literal", + // Comments + "comment", + "line_comment", + "block_comment", + "doc_comment", + ]; + + // Check the node itself + if non_navigable_types.contains(&node.kind()) { + return true; + } + + // Check parent nodes + let mut current = node.parent(); + while let Some(parent) = current { + if non_navigable_types.contains(&parent.kind()) { + return true; + } + current = parent.parent(); + } + + false +} + +/// Find all symbols in a file. +pub fn find_all_symbols(file_path: &Path, content: &str) -> Vec { + let Some(language) = SupportedLanguage::from_path(file_path) else { + return vec![]; + }; + let Some(tree) = parse_source(content, language) else { + return vec![]; + }; + + let mut symbols = Vec::new(); + let definition_types = language.definition_node_types(); + + collect_definitions(tree.root_node(), content, language, definition_types, &mut symbols); + symbols +} + +/// Collect all definition nodes recursively. +fn collect_definitions( + node: Node, + content: &str, + language: SupportedLanguage, + definition_types: &[&str], + symbols: &mut Vec, +) { + if definition_types.contains(&node.kind()) { + if let Some(symbol) = extract_definition_symbol(node, content, language) { + symbols.push(symbol); + } + } + + // Recurse into children + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_definitions(child, content, language, definition_types, symbols); + } +} + +/// Extract symbol info from a definition node. +fn extract_definition_symbol( + node: Node, + content: &str, + language: SupportedLanguage, +) -> Option { + let identifier_types = language.identifier_node_types(); + + // Find the name identifier within this definition + let name_node = find_name_in_definition(node, identifier_types, language)?; + let name = name_node.utf8_text(content.as_bytes()).ok()?; + + let kind = determine_symbol_kind(node.kind(), language); + + Some(SymbolInfo { + name: name.to_string(), + kind, + line: name_node.start_position().row, + column: name_node.start_position().column, + end_column: name_node.end_position().column, + context: Some(get_context_line(content, name_node.start_position().row)), + language: language.name().to_string(), + }) +} + +/// Find the name identifier within a definition node. +fn find_name_in_definition<'a>( + node: Node<'a>, + identifier_types: &[&str], + language: SupportedLanguage, +) -> Option> { + // Different strategies based on language and node type + match language { + SupportedLanguage::TypeScript + | SupportedLanguage::Tsx + | SupportedLanguage::JavaScript + | SupportedLanguage::Jsx => find_js_definition_name(node, identifier_types), + SupportedLanguage::Rust => find_rust_definition_name(node, identifier_types), + SupportedLanguage::Python => find_python_definition_name(node, identifier_types), + SupportedLanguage::Go => find_go_definition_name(node, identifier_types), + _ => find_first_identifier(node, identifier_types), + } +} + +/// Find definition name for JS/TS. +fn find_js_definition_name<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + let kind = node.kind(); + + // For variable declarations, look for the name in the declarator + if kind == "lexical_declaration" || kind == "variable_declaration" { + if let Some(declarator) = node.child_by_field_name("declarator") { + return declarator.child_by_field_name("name"); + } + // Try first child if no declarator field + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "variable_declarator" { + if let Some(name) = child.child_by_field_name("name") { + return Some(name); + } + } + } + } + + // For function/class declarations, use the name field + if let Some(name) = node.child_by_field_name("name") { + return Some(name); + } + + // For export statements, look inside + if kind == "export_statement" { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(name) = find_js_definition_name(child, identifier_types) { + return Some(name); + } + } + } + + find_first_identifier(node, identifier_types) +} + +/// Find definition name for Rust. +fn find_rust_definition_name<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + // Rust uses "name" field for most definitions + if let Some(name) = node.child_by_field_name("name") { + return Some(name); + } + + // For use declarations, find the identifier + if node.kind() == "use_declaration" { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(id) = find_rust_use_name(child, identifier_types) { + return Some(id); + } + } + } + + find_first_identifier(node, identifier_types) +} + +/// Find the name in a Rust use declaration. +fn find_rust_use_name<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + if identifier_types.contains(&node.kind()) { + return Some(node); + } + + // Handle scoped identifiers - get the last component + if node.kind() == "scoped_identifier" || node.kind() == "use_wildcard" { + if let Some(name) = node.child_by_field_name("name") { + return Some(name); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_rust_use_name(child, identifier_types) { + return Some(found); + } + } + None +} + +/// Find definition name for Python. +fn find_python_definition_name<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + // Python uses "name" field for function/class definitions + if let Some(name) = node.child_by_field_name("name") { + return Some(name); + } + + // For assignments, get the left side + if node.kind() == "assignment" { + if let Some(left) = node.child_by_field_name("left") { + if identifier_types.contains(&left.kind()) { + return Some(left); + } + } + } + + find_first_identifier(node, identifier_types) +} + +/// Find definition name for Go. +fn find_go_definition_name<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + // Go uses "name" field for function declarations + if let Some(name) = node.child_by_field_name("name") { + return Some(name); + } + + // For type declarations, look inside + if node.kind() == "type_declaration" { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "type_spec" { + if let Some(name) = child.child_by_field_name("name") { + return Some(name); + } + } + } + } + + find_first_identifier(node, identifier_types) +} + +/// Find the first identifier node in a subtree. +fn find_first_identifier<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + if identifier_types.contains(&node.kind()) { + return Some(node); + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_first_identifier(child, identifier_types) { + return Some(found); + } + } + None +} + +/// Find the smallest node containing a point. +fn find_smallest_node_at_point(root: Node, point: tree_sitter::Point) -> Option { + let mut cursor = root.walk(); + let mut smallest: Option = None; + + loop { + let node = cursor.node(); + + // Check if point is within this node + if node.start_position() <= point && point < node.end_position() { + smallest = Some(node); + + // Try to go deeper + if cursor.goto_first_child() { + continue; + } + } + + // Try next sibling + if cursor.goto_next_sibling() { + continue; + } + + // Go up and try next sibling + loop { + if !cursor.goto_parent() { + return smallest; + } + if cursor.goto_next_sibling() { + break; + } + } + } +} + +/// Find an identifier node within a node or its children. +fn find_identifier_in_node<'a>(node: Node<'a>, identifier_types: &[&str]) -> Option> { + if identifier_types.contains(&node.kind()) { + return Some(node); + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_identifier_in_node(child, identifier_types) { + return Some(found); + } + } + None +} + +/// Extract symbol info from an identifier node. +fn extract_symbol_info(node: Node, content: &str, language: SupportedLanguage) -> Option { + let name = node.utf8_text(content.as_bytes()).ok()?; + + // Filter out string literals and invalid identifiers + if !is_valid_identifier(name) { + return None; + } + + // Try to determine the kind from the parent context + let kind = if let Some(parent) = node.parent() { + determine_symbol_kind(parent.kind(), language) + } else { + SymbolKind::Unknown + }; + + Some(SymbolInfo { + name: name.to_string(), + kind, + line: node.start_position().row, + column: node.start_position().column, + end_column: node.end_position().column, + context: Some(get_context_line(content, node.start_position().row)), + language: language.name().to_string(), + }) +} + +/// Check if a name looks like a valid identifier (not a string literal or keyword). +fn is_valid_identifier(name: &str) -> bool { + // Reject empty names + if name.is_empty() { + return false; + } + + // Reject string literals (start/end with quotes) + if name.starts_with('"') + || name.starts_with('\'') + || name.starts_with('`') + || name.ends_with('"') + || name.ends_with('\'') + || name.ends_with('`') + { + return false; + } + + // Reject names with spaces or invalid characters + if name.contains(' ') || name.contains('\n') || name.contains('\t') { + return false; + } + + // Reject names that start with a digit + if name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { + return false; + } + + // Reject common keywords that aren't navigable + let keywords = [ + "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "return", + "try", "catch", "finally", "throw", "new", "delete", "typeof", "instanceof", "void", + "this", "super", "null", "undefined", "true", "false", "in", "of", "with", "as", + "async", "await", "yield", "let", "const", "var", "function", "class", "extends", + "implements", "import", "export", "from", "default", "static", "public", "private", + "protected", "readonly", "abstract", "interface", "type", "enum", "namespace", "module", + // Rust keywords + "fn", "pub", "mod", "use", "struct", "impl", "trait", "where", "mut", "ref", "self", + "Self", "match", "loop", "move", "dyn", "unsafe", "extern", "crate", + // Python keywords + "def", "lambda", "pass", "raise", "global", "nonlocal", "assert", "del", "print", + "exec", "eval", "and", "or", "not", "is", "None", "True", "False", + // Go keywords + "func", "package", "defer", "go", "select", "chan", "map", "range", "fallthrough", + "goto", + ]; + + !keywords.contains(&name) +} + +/// Determine the symbol kind from the AST node type. +fn determine_symbol_kind(node_type: &str, _language: SupportedLanguage) -> SymbolKind { + match node_type { + // Functions + "function_declaration" + | "function_definition" + | "function_item" + | "arrow_function" => SymbolKind::Function, + + // Methods + "method_definition" | "method_declaration" | "method" | "singleton_method" => { + SymbolKind::Method + } + + // Classes + "class_declaration" | "class_definition" | "class" | "struct_item" | "struct_specifier" => { + SymbolKind::Class + } + + // Interfaces + "interface_declaration" | "trait_item" => SymbolKind::Interface, + + // Types + "type_alias_declaration" + | "type_item" + | "type_declaration" + | "type_definition" + | "type_spec" => SymbolKind::Type, + + // Enums + "enum_declaration" | "enum_item" | "enum_specifier" => SymbolKind::Enum, + + // Variables + "variable_declarator" + | "lexical_declaration" + | "variable_declaration" + | "assignment" + | "var_declaration" + | "declaration" => SymbolKind::Variable, + + // Constants + "const_item" | "const_declaration" | "static_item" => SymbolKind::Constant, + + // Modules + "mod_item" | "module" => SymbolKind::Module, + + // Imports + "import_statement" + | "import_declaration" + | "import_from_statement" + | "use_declaration" => SymbolKind::Import, + + // Properties + "property_identifier" + | "field_declaration" + | "property_declaration" + | "field_identifier" => SymbolKind::Property, + + // Default + _ => SymbolKind::Unknown, + } +} + +/// Get a single line of context for display. +fn get_context_line(content: &str, line: usize) -> String { + content + .lines() + .nth(line) + .map(|l| l.trim().to_string()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_parse_typescript() { + let content = r#" +function greet(name: string): string { + return `Hello, ${name}!`; +} + +class Person { + name: string; + + constructor(name: string) { + this.name = name; + } + + greet() { + return greet(this.name); + } +} +"#; + let path = PathBuf::from("test.ts"); + let symbols = find_all_symbols(&path, content); + + assert!(!symbols.is_empty()); + let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"greet")); + assert!(names.contains(&"Person")); + } + + #[test] + fn test_parse_rust() { + let content = r#" +fn hello(name: &str) -> String { + format!("Hello, {}!", name) +} + +struct Person { + name: String, +} + +impl Person { + fn new(name: &str) -> Self { + Self { name: name.to_string() } + } +} +"#; + let path = PathBuf::from("test.rs"); + let symbols = find_all_symbols(&path, content); + + assert!(!symbols.is_empty()); + let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"hello")); + assert!(names.contains(&"Person")); + } + + #[test] + fn test_get_symbol_at_position() { + let content = "function greet(name) { return name; }"; + let path = PathBuf::from("test.js"); + + // Position on "greet" + let symbol = get_symbol_at_position(&path, content, 0, 9); + assert!(symbol.is_some()); + let symbol = symbol.unwrap(); + assert_eq!(symbol.name, "greet"); + } +} diff --git a/src/lib/ContextMenu.svelte b/src/lib/ContextMenu.svelte new file mode 100644 index 0000000..92a2b8b --- /dev/null +++ b/src/lib/ContextMenu.svelte @@ -0,0 +1,238 @@ + + + + + + + + +
+ +
+ + diff --git a/src/lib/DiffViewer.svelte b/src/lib/DiffViewer.svelte index 1f8d217..c91d6ab 100644 --- a/src/lib/DiffViewer.svelte +++ b/src/lib/DiffViewer.svelte @@ -15,6 +15,13 @@ import { onMount } from 'svelte'; import { X, GitBranch, MessageSquarePlus, MessageSquare, Trash2 } from 'lucide-svelte'; import type { FileDiff, Alignment, Comment, Span } from './types'; + import ContextMenu from './ContextMenu.svelte'; + import { + getSymbolAtPosition, + findDefinition, + type SymbolInfo, + type DefinitionResult, + } from './services/symbolNavigation'; import { commentsState, getCommentsForRange, @@ -45,10 +52,12 @@ } from './diffUtils'; import { setupDiffKeyboardNav } from './diffKeyboard'; import { diffSelection } from './stores/diffSelection.svelte'; - import { diffState, clearScrollTarget } from './stores/diffState.svelte'; + import { diffState, clearScrollTarget, selectFile } from './stores/diffState.svelte'; + import { addReferenceFile } from './stores/referenceFiles.svelte'; import { DiffSpec } from './types'; import CommentEditor from './CommentEditor.svelte'; import Scrollbar from './Scrollbar.svelte'; + import { writeText } from '@tauri-apps/plugin-clipboard-manager'; // ========================================================================== // Constants @@ -164,6 +173,59 @@ let editingCommentId: string | null = $state(null); let lineSelectionToolbarStyle: { top: number; left: number } | null = $state(null); + // ========================================================================== + // Symbol navigation state (Go to Definition) + // ========================================================================== + + /** Whether Cmd/Ctrl key is held down (for showing clickable symbols) */ + let cmdKeyHeld = $state(false); + + /** Context menu state */ + let contextMenuState: { + x: number; + y: number; + symbolName: string; + symbolInfo: SymbolInfo; + pane: 'before' | 'after'; + lineIndex: number; + } | null = $state(null); + + /** Loading state for go to definition */ + let isNavigatingToDefinition = $state(false); + + /** Tooltip state for showing "Definition not found" message */ + let tooltipState: { + x: number; + y: number; + message: string; + } | null = $state(null); + let tooltipTimeout: ReturnType | null = null; + + /** Show a tooltip at the given position */ + function showTooltip(x: number, y: number, message: string, duration = 2000) { + // Clear any existing timeout + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + } + + tooltipState = { x, y, message }; + + // Auto-hide after duration + tooltipTimeout = setTimeout(() => { + tooltipState = null; + tooltipTimeout = null; + }, duration); + } + + /** Hide the tooltip */ + function hideTooltip() { + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + tooltipTimeout = null; + } + tooltipState = null; + } + // ========================================================================== // Derived state // ========================================================================== @@ -1035,6 +1097,250 @@ } }); + // ========================================================================== + // Symbol navigation handlers (Go to Definition) + // ========================================================================== + + /** Get the ref name for symbol navigation based on the pane */ + function getRefNameForPane(pane: 'before' | 'after'): string { + if (pane === 'before') { + return diffSelection.spec.base.type === 'WorkingTree' + ? 'HEAD' + : diffSelection.spec.base.value; + } else { + return diffSelection.spec.head.type === 'WorkingTree' + ? 'WORKDIR' + : diffSelection.spec.head.value; + } + } + + /** Handle click on code - check for Cmd+Click */ + async function handleCodeClick( + pane: 'before' | 'after', + lineIndex: number, + event: MouseEvent + ) { + // Only handle Cmd+Click (Meta on Mac, Ctrl on Windows/Linux) + if (!event.metaKey && !event.ctrlKey) return; + + event.preventDefault(); + event.stopPropagation(); + + const filePath = pane === 'before' ? beforePath : afterPath; + if (!filePath) return; + + // Calculate column from click position + const target = event.target as HTMLElement; + const lineElement = target.closest('.line') as HTMLElement; + if (!lineElement) return; + + const contentElement = lineElement.querySelector('.line-content') as HTMLElement; + if (!contentElement) return; + + const rect = contentElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + + // Estimate column from click position (monospace font) + const style = window.getComputedStyle(contentElement); + const tempSpan = document.createElement('span'); + tempSpan.style.font = style.font; + tempSpan.style.visibility = 'hidden'; + tempSpan.style.position = 'absolute'; + tempSpan.textContent = 'M'; + document.body.appendChild(tempSpan); + const charWidth = tempSpan.getBoundingClientRect().width; + document.body.removeChild(tempSpan); + + const column = Math.max(0, Math.floor(x / charWidth)); + + // Navigate directly on Cmd+Click + await navigateToDefinition(pane, filePath, lineIndex, column, event.clientX, event.clientY); + } + + /** Handle right-click on code - show context menu */ + async function handleCodeContextMenu( + pane: 'before' | 'after', + lineIndex: number, + event: MouseEvent + ) { + const filePath = pane === 'before' ? beforePath : afterPath; + if (!filePath) return; + + // Calculate column from click position + const target = event.target as HTMLElement; + const lineElement = target.closest('.line') as HTMLElement; + if (!lineElement) return; + + const contentElement = lineElement.querySelector('.line-content') as HTMLElement; + if (!contentElement) return; + + const rect = contentElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + + // Estimate column from click position + const style = window.getComputedStyle(contentElement); + const tempSpan = document.createElement('span'); + tempSpan.style.font = style.font; + tempSpan.style.visibility = 'hidden'; + tempSpan.style.position = 'absolute'; + tempSpan.textContent = 'M'; + document.body.appendChild(tempSpan); + const charWidth = tempSpan.getBoundingClientRect().width; + document.body.removeChild(tempSpan); + + const column = Math.max(0, Math.floor(x / charWidth)); + + // Get symbol at position + const refName = getRefNameForPane(pane); + const repoPath = diffState.currentRepoPath ?? undefined; + + try { + const symbol = await getSymbolAtPosition(refName, filePath, lineIndex, column, repoPath); + + if (symbol) { + event.preventDefault(); + contextMenuState = { + x: event.clientX, + y: event.clientY, + symbolName: symbol.name, + symbolInfo: symbol, + pane, + lineIndex, + }; + } + } catch (e) { + console.error('Failed to get symbol at position:', e); + } + } + + /** Navigate to the definition of a symbol */ + async function navigateToDefinition( + pane: 'before' | 'after', + filePath: string, + lineIndex: number, + column: number, + clickX?: number, + clickY?: number + ) { + if (isNavigatingToDefinition) return; + + const refName = getRefNameForPane(pane); + const repoPath = diffState.currentRepoPath ?? undefined; + + isNavigatingToDefinition = true; + + try { + // First get the symbol at the position + const symbol = await getSymbolAtPosition(refName, filePath, lineIndex, column, repoPath); + if (!symbol) { + console.debug('No symbol found at position'); + return; + } + + // Find the definition + const definition = await findDefinition( + refName, + filePath, + symbol.name, + symbol.line, + symbol.column, + repoPath + ); + + if (!definition) { + console.debug('Definition not found for:', symbol.name); + // Show tooltip if we have click coordinates + if (clickX !== undefined && clickY !== undefined) { + showTooltip(clickX, clickY, `No definition found for "${symbol.name}"`); + } + return; + } + + // Navigate to the definition + await goToDefinition(definition, refName, repoPath); + } catch (e) { + console.error('Failed to navigate to definition:', e); + } finally { + isNavigatingToDefinition = false; + } + } + + /** Go to a definition result - either scroll to it or open as reference file */ + async function goToDefinition( + definition: DefinitionResult, + refName: string, + repoPath?: string + ) { + const currentPath = afterPath ?? beforePath; + + // Check if definition is in the current file + if (definition.filePath === currentPath) { + // Same file - just scroll to the line + scrollToLine(definition.line); + return; + } + + // Check if definition is in any of the diff files + const isDiffFile = diffState.files.some( + (f) => f.after === definition.filePath || f.before === definition.filePath + ); + + if (isDiffFile) { + // Navigate to that diff file with scroll target + await selectFile(definition.filePath, definition.line); + } else { + // Open as reference file + try { + await addReferenceFile(refName, definition.filePath, diffSelection.spec, repoPath); + await selectFile(definition.filePath, definition.line); + } catch (e) { + console.error('Failed to open reference file:', e); + } + } + } + + /** Handle context menu "Go to Definition" action */ + async function handleContextMenuGoToDefinition() { + if (!contextMenuState) return; + + const { pane, lineIndex, symbolInfo } = contextMenuState; + const filePath = pane === 'before' ? beforePath : afterPath; + if (!filePath) return; + + contextMenuState = null; + await navigateToDefinition(pane, filePath, lineIndex, symbolInfo.column); + } + + /** Handle context menu "Copy Symbol" action */ + async function handleContextMenuCopySymbol() { + if (!contextMenuState) return; + + try { + await writeText(contextMenuState.symbolName); + } catch (e) { + console.error('Failed to copy symbol name:', e); + } + contextMenuState = null; + } + + /** Close the context menu */ + function closeContextMenu() { + contextMenuState = null; + } + + /** Track Cmd/Ctrl key state */ + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Meta' || event.key === 'Control') { + cmdKeyHeld = true; + } + } + + function handleKeyUp(event: KeyboardEvent) { + if (event.key === 'Meta' || event.key === 'Control') { + cmdKeyHeld = false; + } + } + // ========================================================================== // Global event handlers // ========================================================================== @@ -1176,6 +1482,9 @@ document.addEventListener('mouseup', handleGlobalMouseUp); document.addEventListener('click', handleGlobalClick); document.addEventListener('keydown', handleLineSelectionKeydown); + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + window.addEventListener('blur', () => { cmdKeyHeld = false; }); return () => { cleanupKeyboardNav?.(); @@ -1183,6 +1492,8 @@ document.removeEventListener('mouseup', handleGlobalMouseUp); document.removeEventListener('click', handleGlobalClick); document.removeEventListener('keydown', handleLineSelectionKeydown); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); document.removeEventListener('mousemove', handleDragMove); document.removeEventListener('mousemove', handleDividerMouseMove); document.removeEventListener('mouseup', handleDividerMouseUp); @@ -1267,7 +1578,7 @@ {@const isInHoveredRange = isLineInHoveredRange('before', i)} {@const isInFocusedHunk = isLineInFocusedHunk('before', i)} {@const isChanged = showRangeMarkers && isLineInChangedAlignment('before', i)} - +
handleLineMouseEnter('before', i)} onmouseleave={handleLineMouseLeave} + onclick={(e) => handleCodeClick('before', i, e)} + oncontextmenu={(e) => handleCodeContextMenu('before', i, e)} > {#each getBeforeTokens(i) as token} @@ -1321,7 +1635,13 @@ style="transform: translateY(-{scrollController.beforeScrollY}px)" > {#each beforeLines as line, i} -
+ +
handleCodeClick('before', i, e)} + oncontextmenu={(e) => handleCodeContextMenu('before', i, e)} + > {#each getBeforeTokens(i) as token} {token.content} @@ -1372,7 +1692,7 @@ {@const isInFocusedHunk = isLineInFocusedHunk('after', i)} {@const isChanged = showRangeMarkers && isLineInChangedAlignment('after', i)} {@const isSelected = isLineSelected('after', i)} - +
handleLineMouseEnter('after', i)} onmouseleave={handleLineMouseLeave} onmousedown={(e) => handleLineMouseDown('after', i, e)} + onclick={(e) => handleCodeClick('after', i, e)} + oncontextmenu={(e) => handleCodeContextMenu('after', i, e)} > {#each getAfterTokens(i) as token} @@ -1428,11 +1751,14 @@ > {#each afterLines as line, i} {@const isSelected = isLineSelected('after', i)} - +
handleLineMouseDown('after', i, e)} + onclick={(e) => handleCodeClick('after', i, e)} + oncontextmenu={(e) => handleCodeContextMenu('after', i, e)} > {#each getAfterTokens(i) as token} @@ -1578,6 +1904,30 @@ /> {/if} {/if} + + + {#if contextMenuState} + + {/if} + + + {#if tooltipState} + + {/if}
diff --git a/src/lib/services/symbolNavigation.ts b/src/lib/services/symbolNavigation.ts new file mode 100644 index 0000000..5e07b67 --- /dev/null +++ b/src/lib/services/symbolNavigation.ts @@ -0,0 +1,225 @@ +/** + * Symbol Navigation Service + * + * Provides "Go to Definition" functionality by leveraging the backend + * Tree-sitter parsing to find symbol definitions. + */ + +import { invoke } from '@tauri-apps/api/core'; + +// ============================================================================= +// Types +// ============================================================================= + +/** The kind of symbol (function, class, variable, etc.) */ +export type SymbolKind = + | 'function' + | 'method' + | 'class' + | 'interface' + | 'type' + | 'enum' + | 'variable' + | 'constant' + | 'module' + | 'import' + | 'property' + | 'unknown'; + +/** Information about a symbol at a position */ +export interface SymbolInfo { + /** The symbol name */ + name: string; + /** The kind of symbol */ + kind: SymbolKind; + /** Line number (0-indexed) */ + line: number; + /** Column number (0-indexed) */ + column: number; + /** End column number (0-indexed) */ + endColumn: number; + /** Preview of the context line */ + context: string | null; + /** Language of the file */ + language: string; +} + +/** Result of a definition search */ +export interface DefinitionResult { + /** Path to the file containing the definition */ + filePath: string; + /** Line number (0-indexed) */ + line: number; + /** Column number (0-indexed) */ + column: number; + /** End column number (0-indexed) */ + endColumn: number; + /** The symbol name */ + name: string; + /** The kind of symbol */ + kind: SymbolKind; + /** Preview of the definition line */ + preview: string; +} + +// ============================================================================= +// API +// ============================================================================= + +/** + * Get symbol information at a specific position in a file. + * + * @param refName - Git ref (commit SHA, branch name, or "WORKDIR") + * @param filePath - Path to the file relative to repo root + * @param line - Line number (0-indexed) + * @param column - Column number (0-indexed) + * @param repoPath - Optional path to the repository + * @returns Symbol info if found, null otherwise + */ +export async function getSymbolAtPosition( + refName: string, + filePath: string, + line: number, + column: number, + repoPath?: string +): Promise { + return invoke('get_symbol_at_position', { + repoPath: repoPath ?? null, + refName, + filePath, + line, + column, + }); +} + +/** + * Find the definition of a symbol. + * + * @param refName - Git ref (commit SHA, branch name, or "WORKDIR") + * @param filePath - Path to the file where the symbol was found + * @param symbolName - Name of the symbol to find + * @param symbolLine - Line where the symbol was found (0-indexed) + * @param symbolColumn - Column where the symbol was found (0-indexed) + * @param repoPath - Optional path to the repository + * @returns Definition result if found, null otherwise + */ +export async function findDefinition( + refName: string, + filePath: string, + symbolName: string, + symbolLine: number, + symbolColumn: number, + repoPath?: string +): Promise { + return invoke('find_definition', { + repoPath: repoPath ?? null, + refName, + filePath, + symbolName, + symbolLine, + symbolColumn, + }); +} + +/** + * Check if a file type supports symbol navigation. + * + * @param filePath - Path to the file + * @returns True if the file type supports symbol navigation + */ +export async function supportsSymbolNavigation(filePath: string): Promise { + return invoke('supports_symbol_navigation', { filePath }); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Calculate the position (line, column) from a click event on a code line. + * + * @param event - The mouse event + * @param lineElement - The DOM element containing the line + * @param lineNumber - The line number (0-indexed) + * @returns The calculated position + */ +export function calculatePositionFromClick( + event: MouseEvent, + lineElement: HTMLElement, + lineNumber: number +): { line: number; column: number } { + // Get the character position from the click + const rect = lineElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + + // Use the font metrics to estimate column + // Get computed style to find the font + const style = window.getComputedStyle(lineElement); + const fontSize = parseFloat(style.fontSize); + + // Monospace font: estimate character width + // Create a temporary element to measure character width + const measureSpan = document.createElement('span'); + measureSpan.style.font = style.font; + measureSpan.style.visibility = 'hidden'; + measureSpan.style.position = 'absolute'; + measureSpan.textContent = 'M'; // Use 'M' as reference character + document.body.appendChild(measureSpan); + const charWidth = measureSpan.getBoundingClientRect().width; + document.body.removeChild(measureSpan); + + // Calculate column from x position + const column = Math.floor(x / charWidth); + + return { line: lineNumber, column: Math.max(0, column) }; +} + +/** + * Find the token element at a specific position within a line element. + * + * @param lineElement - The DOM element containing the line + * @param event - The mouse event + * @returns The token element if found, null otherwise + */ +export function findTokenAtPosition( + lineElement: HTMLElement, + event: MouseEvent +): HTMLElement | null { + // Find all span elements (tokens) in the line + const tokens = lineElement.querySelectorAll('span[data-token]'); + + for (const token of tokens) { + const rect = token.getBoundingClientRect(); + if ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) { + return token as HTMLElement; + } + } + + return null; +} + +/** + * Get the text content and position of a token element. + * + * @param tokenElement - The token DOM element + * @param lineNumber - The line number (0-indexed) + * @returns Token info if the element is valid + */ +export function getTokenInfo( + tokenElement: HTMLElement, + lineNumber: number +): { text: string; line: number; column: number; endColumn: number } | null { + const text = tokenElement.textContent; + if (!text) return null; + + // Get column from data attribute or calculate from position + const column = parseInt(tokenElement.dataset.column ?? '0', 10); + const endColumn = column + text.length; + + return { text, line: lineNumber, column, endColumn }; +} From b26ee194970b37b933aededa42bf8b611ce7ff6b Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 16 Jan 2026 16:17:55 -0800 Subject: [PATCH 2/4] fix: support Go to Definition on committed changes Use git ls-tree to list files at a specific ref instead of reading from the filesystem. This allows Go to Definition to work on committed files, not just the working directory. - Add list_files_in_dir to git/files.rs using git ls-tree - Update find_definition to accept a list_files callback - Wire up the callback in lib.rs Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/git/files.rs | 64 ++++++++++ src-tauri/src/git/mod.rs | 2 +- src-tauri/src/lib.rs | 8 ++ src-tauri/src/symbols/definition.rs | 185 +++++++++++++++------------- 4 files changed, 171 insertions(+), 88 deletions(-) diff --git a/src-tauri/src/git/files.rs b/src-tauri/src/git/files.rs index f2786e3..45a67bc 100644 --- a/src-tauri/src/git/files.rs +++ b/src-tauri/src/git/files.rs @@ -185,6 +185,70 @@ pub fn get_file_at_ref(repo: &Path, ref_name: &str, path: &str) -> Result Result, GitError> { + if ref_name == WORKDIR { + // Read from working directory + let full_path = repo.join(dir_path); + + if !full_path.exists() || !full_path.is_dir() { + return Ok(vec![]); + } + + let mut files = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&full_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() { + if let Some(name) = entry.file_name().to_str() { + // Return path relative to repo root + let rel_path = if dir_path.is_empty() { + name.to_string() + } else { + format!("{}/{}", dir_path, name) + }; + files.push(rel_path); + } + } + } + } + } + Ok(files) + } else { + // Use git ls-tree to list files at ref + let tree_spec = if dir_path.is_empty() { + ref_name.to_string() + } else { + format!("{}:{}", ref_name, dir_path) + }; + + // git ls-tree --name-only : + let output = cli::run(repo, &["ls-tree", "--name-only", &tree_spec]).unwrap_or_default(); + + let files: Vec = output + .lines() + .filter(|line| !line.is_empty()) + .map(|name| { + if dir_path.is_empty() { + name.to_string() + } else { + format!("{}/{}", dir_path, name) + } + }) + .collect(); + + Ok(files) + } +} + /// Check if data appears to be binary (contains null bytes in first 8KB) fn is_binary(data: &[u8]) -> bool { let check_len = data.len().min(8192); diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index cc49e9c..50f4fe9 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -9,7 +9,7 @@ mod types; pub use cli::GitError; pub use commit::commit; pub use diff::{get_file_diff, list_diff_files}; -pub use files::{get_file_at_ref, search_files}; +pub use files::{get_file_at_ref, list_files_in_dir, search_files}; pub use github::{ check_github_auth, fetch_pr, invalidate_cache as invalidate_pr_cache, list_pull_requests, sync_review_to_github, GitHubAuthStatus, GitHubSyncResult, PullRequest, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a944dd..c6b0634 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -182,12 +182,20 @@ async fn find_definition( } }; + // Create a closure to list files in a directory at the given ref + let ref_for_list = ref_name_clone.clone(); + let repo_for_list = repo.clone(); + let list_files = move |dir: &str| -> Vec { + git::list_files_in_dir(&repo_for_list, &ref_for_list, dir).unwrap_or_default() + }; + Ok(symbols::find_definition( &symbol, ¤t_file, ¤t_content, &repo, read_file, + list_files, )) }) .await diff --git a/src-tauri/src/symbols/definition.rs b/src-tauri/src/symbols/definition.rs index 80fb9ab..a8ee7b3 100644 --- a/src-tauri/src/symbols/definition.rs +++ b/src-tauri/src/symbols/definition.rs @@ -38,18 +38,21 @@ pub struct DefinitionResult { /// * `current_content` - Content of the current file /// * `repo_root` - Root of the repository (for resolving imports) /// * `read_file` - Function to read file contents +/// * `list_files` - Function to list files in a directory /// /// # Returns /// `Some(DefinitionResult)` if found, `None` otherwise. -pub fn find_definition( +pub fn find_definition( symbol: &SymbolInfo, current_file: &Path, current_content: &str, repo_root: &Path, read_file: F, + list_files: L, ) -> Option where F: Fn(&Path) -> Option, + L: Fn(&str) -> Vec, { let language = SupportedLanguage::from_path(current_file)?; @@ -80,7 +83,7 @@ where } // 3. Try common patterns (index files, etc.) - if let Some(def) = try_common_patterns(symbol, current_file, repo_root, &read_file) { + if let Some(def) = try_common_patterns(symbol, current_file, repo_root, &read_file, &list_files) { return Some(def); } @@ -563,17 +566,32 @@ fn find_crate_root(current_file: &Path, repo_root: &Path) -> Option { } /// Try common patterns to find definitions. -fn try_common_patterns( +fn try_common_patterns( symbol: &SymbolInfo, current_file: &Path, repo_root: &Path, read_file: &F, + list_files: &L, ) -> Option where F: Fn(&Path) -> Option, + L: Fn(&str) -> Vec, { let language = SupportedLanguage::from_path(current_file)?; + // Get current file's relative path from repo root for comparison + let current_rel_path = current_file + .strip_prefix(repo_root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Get directory path relative to repo root + let dir_rel_path = current_file + .parent() + .and_then(|p| p.strip_prefix(repo_root).ok()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + match language { SupportedLanguage::TypeScript | SupportedLanguage::Tsx @@ -599,59 +617,54 @@ where } } SupportedLanguage::Rust => { - // For Rust, search sibling files in the same module directory - if let Some(current_dir) = current_file.parent() { - log::debug!( - "try_common_patterns: searching Rust sibling files in {:?}", - current_dir - ); - - // Get all .rs files in the current directory - if let Ok(entries) = std::fs::read_dir(current_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "rs").unwrap_or(false) - && path != current_file - { - log::debug!(" Checking sibling file: {:?}", path.file_name()); - if let Some(content) = read_file(&path) { - log::debug!(" File read successfully, content length: {}", content.len()); - if let Some(def) = - find_definition_in_file(&symbol.name, &content, &path) - { - return Some(def); - } - } else { - log::debug!(" Failed to read file via git"); - } - } - } - } else { - log::debug!(" Failed to read directory"); - } - - // Also check mod.rs if we're in a module directory - let mod_file = current_dir.join("mod.rs"); - if mod_file.exists() && mod_file != current_file { - if let Some(content) = read_file(&mod_file) { - if let Some(def) = find_definition_in_file(&symbol.name, &content, &mod_file) + log::debug!( + "try_common_patterns: searching Rust sibling files in '{}'", + dir_rel_path + ); + + // Get all files in the current directory via git + let files = list_files(&dir_rel_path); + + for file_path in files { + // Check if it's a .rs file and not the current file + if file_path.ends_with(".rs") && file_path != current_rel_path { + log::debug!(" Checking sibling file: {}", file_path); + let full_path = repo_root.join(&file_path); + if let Some(content) = read_file(&full_path) { + log::debug!( + " File read successfully, content length: {}", + content.len() + ); + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &full_path) { return Some(def); } + } else { + log::debug!(" Failed to read file via git"); } } + } - // Check parent's lib.rs or main.rs - if let Some(parent_dir) = current_dir.parent() { - for name in ["lib.rs", "main.rs"] { - let parent_file = parent_dir.join(name); - if parent_file.exists() { - if let Some(content) = read_file(&parent_file) { - if let Some(def) = - find_definition_in_file(&symbol.name, &content, &parent_file) - { - return Some(def); - } + // Check parent directory for lib.rs or main.rs + if let Some(parent_dir) = Path::new(&dir_rel_path).parent() { + let parent_dir_str = parent_dir.to_string_lossy().to_string(); + let parent_files = list_files(&parent_dir_str); + + for name in ["lib.rs", "main.rs"] { + let target = if parent_dir_str.is_empty() { + name.to_string() + } else { + format!("{}/{}", parent_dir_str, name) + }; + + if parent_files.iter().any(|f| f == &target || f.ends_with(&format!("/{}", name))) { + let full_path = repo_root.join(&target); + if let Some(content) = read_file(&full_path) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &full_path) + { + return Some(def); } } } @@ -659,54 +672,52 @@ where } } SupportedLanguage::Python => { - // For Python, check __init__.py and sibling files - if let Some(current_dir) = current_file.parent() { - let init_file = current_dir.join("__init__.py"); - if init_file.exists() && init_file != current_file { - if let Some(content) = read_file(&init_file) { - if let Some(def) = - find_definition_in_file(&symbol.name, &content, &init_file) - { - return Some(def); - } + // Get all files in the current directory + let files = list_files(&dir_rel_path); + + // Check __init__.py first + let init_path = if dir_rel_path.is_empty() { + "__init__.py".to_string() + } else { + format!("{}/__init__.py", dir_rel_path) + }; + + if files.iter().any(|f| f == &init_path) && init_path != current_rel_path { + let full_path = repo_root.join(&init_path); + if let Some(content) = read_file(&full_path) { + if let Some(def) = find_definition_in_file(&symbol.name, &content, &full_path) { + return Some(def); } } + } - // Search other .py files in the directory - if let Ok(entries) = std::fs::read_dir(current_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "py").unwrap_or(false) - && path != current_file + // Search other .py files in the directory + for file_path in files { + if file_path.ends_with(".py") && file_path != current_rel_path { + let full_path = repo_root.join(&file_path); + if let Some(content) = read_file(&full_path) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &full_path) { - if let Some(content) = read_file(&path) { - if let Some(def) = - find_definition_in_file(&symbol.name, &content, &path) - { - return Some(def); - } - } + return Some(def); } } } } } SupportedLanguage::Go => { - // For Go, search all .go files in the same package (directory) - if let Some(current_dir) = current_file.parent() { - if let Ok(entries) = std::fs::read_dir(current_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "go").unwrap_or(false) - && path != current_file + // Get all files in the current directory + let files = list_files(&dir_rel_path); + + // Search all .go files in the same package (directory) + for file_path in files { + if file_path.ends_with(".go") && file_path != current_rel_path { + let full_path = repo_root.join(&file_path); + if let Some(content) = read_file(&full_path) { + if let Some(def) = + find_definition_in_file(&symbol.name, &content, &full_path) { - if let Some(content) = read_file(&path) { - if let Some(def) = - find_definition_in_file(&symbol.name, &content, &path) - { - return Some(def); - } - } + return Some(def); } } } From 6e647cd991161977d9ff7033b511f3735139ba00 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 16 Jan 2026 16:20:08 -0800 Subject: [PATCH 3/4] fix: fallback to WORKDIR when reference file not found at ref When opening a reference file, if it doesn't exist at the given ref (e.g., a new file on a feature branch not in HEAD), fallback to reading from the working directory. Co-Authored-By: Claude Opus 4.5 --- src/lib/stores/referenceFiles.svelte.ts | 31 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/lib/stores/referenceFiles.svelte.ts b/src/lib/stores/referenceFiles.svelte.ts index e11aea9..8f31112 100644 --- a/src/lib/stores/referenceFiles.svelte.ts +++ b/src/lib/stores/referenceFiles.svelte.ts @@ -92,8 +92,21 @@ export async function addReferenceFile( referenceFilesState.error = null; try { - // Load file content - const file = await getFileAtRef(refName, path, repoPath); + // Load file content - try the given ref first, fallback to WORKDIR if not found + let file; + try { + file = await getFileAtRef(refName, path, repoPath); + } catch (refError) { + // If file doesn't exist at the ref (e.g., new file not in HEAD), + // try loading from the working directory + const errorMsg = refError instanceof Error ? refError.message : String(refError); + if (errorMsg.includes('does not exist') || errorMsg.includes('exists on disk, but not in')) { + file = await getFileAtRef('WORKDIR', path, repoPath); + } else { + throw refError; + } + } + referenceFilesState.files = [ ...referenceFilesState.files, { path: file.path, content: file.content }, @@ -152,10 +165,20 @@ export async function loadReferenceFiles( referenceFilesState.error = null; try { - // Load all files in parallel + // Load all files in parallel - with fallback to WORKDIR if not found at ref const results = await Promise.allSettled( paths.map(async (path) => { - const file = await getFileAtRef(refName, path, repoPath); + let file; + try { + file = await getFileAtRef(refName, path, repoPath); + } catch (refError) { + const errorMsg = refError instanceof Error ? refError.message : String(refError); + if (errorMsg.includes('does not exist') || errorMsg.includes('exists on disk, but not in')) { + file = await getFileAtRef('WORKDIR', path, repoPath); + } else { + throw refError; + } + } return { path: file.path, content: file.content }; }) ); From f03eddd5d69c44526a60f5bf7e8b3731eda29b7b Mon Sep 17 00:00:00 2001 From: Max Novich Date: Mon, 19 Jan 2026 11:45:49 -0800 Subject: [PATCH 4/4] feat: display current branch in TopBar Add a branch indicator showing the currently checked-out branch in the top bar, next to the diff selector. - Add get_current_branch command to Rust backend (uses git symbolic-ref) - Add getCurrentBranch function to frontend git service - Display branch name with icon in TopBar left section - Handle detached HEAD state (shows nothing) Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/git/mod.rs | 2 +- src-tauri/src/git/refs.rs | 12 ++++++++++ src-tauri/src/lib.rs | 8 +++++++ src/lib/TopBar.svelte | 50 ++++++++++++++++++++++++++++++++++++++- src/lib/services/git.ts | 9 +++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 50f4fe9..8ce559d 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -14,5 +14,5 @@ pub use github::{ check_github_auth, fetch_pr, invalidate_cache as invalidate_pr_cache, list_pull_requests, sync_review_to_github, GitHubAuthStatus, GitHubSyncResult, PullRequest, }; -pub use refs::{get_repo_root, list_refs, merge_base, resolve_ref}; +pub use refs::{get_current_branch, get_repo_root, list_refs, merge_base, resolve_ref}; pub use types::*; diff --git a/src-tauri/src/git/refs.rs b/src-tauri/src/git/refs.rs index ce975f0..27f9cf9 100644 --- a/src-tauri/src/git/refs.rs +++ b/src-tauri/src/git/refs.rs @@ -37,3 +37,15 @@ pub fn resolve_ref(repo: &Path, reference: &str) -> Result { let output = cli::run(repo, &["rev-parse", reference])?; Ok(output.trim().to_string()) } + +/// Get the current branch name (or None if in detached HEAD state) +pub fn get_current_branch(repo: &Path) -> Result, GitError> { + match cli::run(repo, &["symbolic-ref", "--short", "HEAD"]) { + Ok(output) => Ok(Some(output.trim().to_string())), + Err(GitError::CommandFailed(msg)) if msg.contains("not a symbolic ref") => { + // Detached HEAD state + Ok(None) + } + Err(e) => Err(e), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6b0634..2ee5cf2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -241,6 +241,13 @@ fn get_merge_base(repo_path: Option, ref1: String, ref2: String) -> Resu git::merge_base(path, &ref1, &ref2).map_err(|e| e.to_string()) } +/// Get the current branch name (or None if in detached HEAD state). +#[tauri::command(rename_all = "camelCase")] +fn get_current_branch(repo_path: Option) -> Result, String> { + let path = get_repo_path(repo_path.as_deref()); + git::get_current_branch(path).map_err(|e| e.to_string()) +} + /// List files changed in a diff (for sidebar). /// Runs on a blocking thread to avoid freezing the UI on large repos. #[tauri::command(rename_all = "camelCase")] @@ -642,6 +649,7 @@ pub fn run() { list_refs, resolve_ref, get_merge_base, + get_current_branch, list_diff_files, get_file_diff, commit, diff --git a/src/lib/TopBar.svelte b/src/lib/TopBar.svelte index 2a83713..1f72dc7 100644 --- a/src/lib/TopBar.svelte +++ b/src/lib/TopBar.svelte @@ -12,6 +12,7 @@ GitCompareArrows, GitPullRequest, GitCommitHorizontal, + GitBranch, Upload, } from 'lucide-svelte'; import DiffSelectorModal from './DiffSelectorModal.svelte'; @@ -37,6 +38,7 @@ } from './stores/comments.svelte'; import { repoState } from './stores/repoState.svelte'; import { registerShortcut } from './services/keyboard'; + import { getCurrentBranch } from './services/git'; interface Props { onPresetSelect: (preset: DiffPreset) => void; @@ -61,6 +63,21 @@ // Copy feedback let copiedFeedback = $state(false); + // Current branch + let currentBranch = $state(null); + + // Fetch current branch when repo changes + $effect(() => { + const repoPath = repoState.currentPath; + getCurrentBranch(repoPath ?? undefined) + .then((branch) => { + currentBranch = branch; + }) + .catch(() => { + currentBranch = null; + }); + }); + // Check if we're viewing working directory changes (can show commit button) let isWorkingTree = $derived(diffSelection.spec.head.type === 'WorkingTree'); // Can only commit if there are files to commit @@ -175,8 +192,15 @@
- +
+ {#if currentBranch} +
+ + {currentBranch} +
+ {/if} +