From 5fbdb8133028005dc4cde0a2156bf5ccf8ee1f88 Mon Sep 17 00:00:00 2001 From: Jeff Pitchell Date: Mon, 1 Jun 2026 12:21:50 -0700 Subject: [PATCH] Resolve ambiguous CLI-agent file links via repo index + Files palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CLI agent (Claude Code, Codex, etc.) prints a bare filename or shorthand path that doesn't resolve relative to its working directory, fall back to the repo file index instead of producing no link: - 0 matches -> no link - 1 match -> linkify; click opens it directly - 2+ matches -> linkify ("Open file…"); click opens the command-palette Files mode pre-filled so the user disambiguates Matching is exact-basename + ordered directory-component subsequence, so `warp_features/lib.rs` resolves to `crates/warp_features/src/lib.rs` (tolerating omitted intermediates like `crates/` and `src/`). The repo index is snapshotted on the main thread (the background scan thread has no AppContext) and indexed by basename per scan for O(1) candidate lookup, keeping hover detection fast even with hundreds of candidates. Gated by the existing CliAgentFileLinks flag + general.cli_agent_file_links setting; no new flag. Adds a GridHighlightedLink::AmbiguousFile variant and threads an optional pre-fill query through the OpenFilesPalette event. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/pane_group/mod.rs | 2 + app/src/pane_group/pane/terminal_pane.rs | 10 +- app/src/server/telemetry/events.rs | 6 +- app/src/terminal/view.rs | 44 ++++- app/src/terminal/view/link_detection.rs | 239 +++++++++++++++++++++-- app/src/terminal/view_tests.rs | 210 +++++++++++++++++++- app/src/util/file.rs | 16 ++ app/src/workspace/view.rs | 12 +- 8 files changed, 510 insertions(+), 29 deletions(-) diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 1219a8e7c8..7f8425ca78 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -685,6 +685,8 @@ pub enum Event { OpenEnvironmentManagementPane, OpenFilesPalette { source: PaletteSource, + /// When set, opens the Files palette pre-filled with this query. + initial_query: Option, }, ToggleLeftPanel { target_view: LeftPanelTargetView, diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index dc33a7c3bf..ea650df636 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1375,9 +1375,13 @@ fn handle_terminal_view_event( Event::OpenMCPSettingsPage { page } => { ctx.emit(pane_group::Event::OpenMCPSettingsPage { page: *page }); } - Event::OpenFilesPalette { source } => { - ctx.emit(pane_group::Event::OpenFilesPalette { source: *source }) - } + Event::OpenFilesPalette { + source, + initial_query, + } => ctx.emit(pane_group::Event::OpenFilesPalette { + source: *source, + initial_query: initial_query.clone(), + }), Event::OpenAddRulePane => { ctx.emit(crate::pane_group::Event::OpenAddRulePane); } diff --git a/app/src/server/telemetry/events.rs b/app/src/server/telemetry/events.rs index 31971ec5ad..43b6913b97 100644 --- a/app/src/server/telemetry/events.rs +++ b/app/src/server/telemetry/events.rs @@ -435,7 +435,9 @@ pub enum CommandXRayTrigger { pub enum PaletteSource { PrefixChange, Keybinding, - CtrlTab { shift_pressed_initially: bool }, + CtrlTab { + shift_pressed_initially: bool, + }, WarpDrive, QuitModal, LogOutModal, @@ -445,6 +447,8 @@ pub enum PaletteSource { PaneHeader, AgentTip, TitleBarSearchBar, + /// Opened by clicking an ambiguous file link (multiple repo matches) in CLI agent output. + FileLink, } #[derive(Clone, Copy, Debug, Serialize, Deserialize)] diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 35608d2b73..c46e50c3a4 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -1910,6 +1910,9 @@ pub enum Event { OpenEnvironmentManagementPane, OpenFilesPalette { source: PaletteSource, + /// When set, opens the Files palette pre-filled with this query (used by ambiguous + /// CLI-agent file links so the user can pick among multiple matches). + initial_query: Option, }, #[cfg(feature = "local_fs")] OpenFileWithTarget { @@ -16288,6 +16291,14 @@ impl TerminalView { }) .unwrap_or_default() } + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(_) => { + vec![MenuItemFields::new("Open file…") + .with_on_select_action(TerminalAction::OpenGridLink( + highlighted_link.clone(), + )) + .into_item()] + } } } ( @@ -18010,6 +18021,22 @@ impl TerminalView { }); } + /// Opens the command-palette Files mode pre-filled with `query`. Used when a CLI-agent file + /// reference matched multiple repo files, so the user disambiguates rather than us guessing. + #[cfg(feature = "local_fs")] + fn open_ambiguous_file_link(&mut self, query: String, ctx: &mut ViewContext) { + // Dismiss the grid tooltip explicitly: unlike opening a single file (which switches away + // from the terminal), opening the palette leaves the terminal on screen, so a lingering + // tooltip would re-render detached from its now-cleared link anchor. The tooltip-click path + // already dismisses; this also covers the Cmd+Click path. + self.dismiss_tooltips(ctx); + ctx.notify(); + ctx.emit(Event::OpenFilesPalette { + source: PaletteSource::FileLink, + initial_query: Some(query), + }); + } + #[cfg(feature = "local_fs")] fn open_file_path_with_target( &mut self, @@ -18051,6 +18078,11 @@ impl TerminalView { self.open_file_path(path, link.line_and_column_num, ctx); } } + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(link) if link.contains(position) => { + let query = link.get_inner().query.clone(); + self.open_ambiguous_file_link(query, ctx); + } GridHighlightedLink::Url(url) if url.contains(position) => { let model = self.model.lock(); ctx.notify(); @@ -21199,9 +21231,10 @@ impl TerminalView { InputEvent::OpenEnvironmentManagementPane => { self.open_environment_management_pane(ctx); } - InputEvent::OpenFilesPalette { source } => { - ctx.emit(Event::OpenFilesPalette { source: *source }) - } + InputEvent::OpenFilesPalette { source } => ctx.emit(Event::OpenFilesPalette { + source: *source, + initial_query: None, + }), InputEvent::TryHandlePassiveCodeDiff(action) => { self.resolve_prompt_suggestion_diff(action.clone(), ctx); } @@ -26692,7 +26725,10 @@ impl TypedActionView for TerminalView { PickRepoToOpen => { ctx.dispatch_typed_action(&WorkspaceAction::OpenRepository { path: None }); } - OpenFilesPalette { source } => ctx.emit(Event::OpenFilesPalette { source: *source }), + OpenFilesPalette { source } => ctx.emit(Event::OpenFilesPalette { + source: *source, + initial_query: None, + }), DismissCodeToolbeltTooltip => { CodeSettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings diff --git a/app/src/terminal/view/link_detection.rs b/app/src/terminal/view/link_detection.rs index c6681db451..42ef984c6f 100644 --- a/app/src/terminal/view/link_detection.rs +++ b/app/src/terminal/view/link_detection.rs @@ -17,10 +17,14 @@ cfg_if::cfg_if! { use crate::{ terminal::model::grid::grid_handler, terminal::ShellLaunchData, - util::file::{FileLink, absolute_path_if_valid, ShellPathType}, + util::file::{AmbiguousFileLink, FileLink, absolute_path_if_valid, ShellPathType}, util::openable_file_type::FileTarget, }; + use crate::search::files::model::FileSearchModel; + use crate::search::files::search_item::FileSearchResult; + use std::collections::HashMap; use std::path::PathBuf; + use std::sync::Arc; use warp_util::path::CleanPathResult; use warp_util::path::LineAndColumnArg; use warpui::{AppContext, SingletonEntity}; @@ -40,12 +44,38 @@ const PREFIXES_TO_REMOVE: [&str; 2] = ["a/", "b/"]; #[cfg(feature = "local_fs")] const SUFFIXES_TO_REMOVE: [&str; 1] = ["@"]; +/// Returns the final path component of `path`, splitting on either separator so it works for both +/// repo-relative paths (platform separator) and agent-printed tokens (often forward slashes). +#[cfg(feature = "local_fs")] +fn file_basename(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +/// Wraps `inner` in the same model location (alt screen vs. block list) as `possible_path`, so a +/// link inherits the position of the candidate path it was derived from. +#[cfg(feature = "local_fs")] +fn wrap_within_model( + inner: T, + possible_path: &WithinModel, +) -> WithinModel { + match possible_path { + WithinModel::AltScreen(_) => WithinModel::AltScreen(inner), + WithinModel::BlockList(block) => { + WithinModel::BlockList(WithinBlock::new(inner, block.block_index, block.grid)) + } + } +} + /// Highlighted link within a terminal model grid. #[derive(Debug, Clone)] pub enum GridHighlightedLink { Url(WithinModel), #[cfg(feature = "local_fs")] File(WithinModel), + /// A file reference that matched multiple repo files (see [`AmbiguousFileLink`]); clicking it + /// opens the Files palette pre-filled so the user disambiguates. + #[cfg(feature = "local_fs")] + AmbiguousFile(WithinModel), } impl GridHighlightedLink { @@ -54,6 +84,8 @@ impl GridHighlightedLink { GridHighlightedLink::Url(url) => url.contains(position), #[cfg(feature = "local_fs")] GridHighlightedLink::File(file_link) => file_link.contains(position), + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(link) => link.contains(position), } } @@ -71,6 +103,8 @@ impl GridHighlightedLink { } #[cfg(feature = "local_fs")] GridHighlightedLink::File(_) => "Open file", + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(_) => "Open file…", GridHighlightedLink::Url(_) => "Open link", } } @@ -89,6 +123,10 @@ impl Serialize for GridHighlightedLink { GridHighlightedLink::File(_) => { serializer.serialize_unit_variant("HighlightedLink", 1, "File") } + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(_) => { + serializer.serialize_unit_variant("HighlightedLink", 2, "AmbiguousFile") + } } } } @@ -101,6 +139,10 @@ impl TryFrom for Link { GridHighlightedLink::Url(WithinModel::AltScreen(url)) => Ok(url), #[cfg(feature = "local_fs")] GridHighlightedLink::File(WithinModel::AltScreen(file_link)) => Ok(file_link.link), + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(WithinModel::AltScreen(ambiguous)) => { + Ok(ambiguous.link) + } _ => Err(anyhow::anyhow!( "HighlightedLink is not within the alt screen" )), @@ -118,6 +160,10 @@ impl TryFrom for WithinBlock { GridHighlightedLink::File(WithinModel::BlockList(file_link)) => { Ok(file_link.map(|file_link| file_link.link)) } + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(WithinModel::BlockList(ambiguous)) => { + Ok(ambiguous.map(|ambiguous| ambiguous.link)) + } _ => Err(anyhow::anyhow!( "HighlightedLink is not within the block list" )), @@ -208,6 +254,24 @@ impl HighlightedLinkOption { .set_smart_select_override(file_link.link.range.clone()); } }, + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(within_model) => match within_model { + WithinModel::BlockList(within_block) => { + let point_range = WithinBlock::new( + within_block.inner.link.range.clone(), + within_block.block_index, + within_block.grid, + ); + model + .block_list_mut() + .set_smart_select_override(point_range); + } + WithinModel::AltScreen(ambiguous_link) => { + model + .alt_screen_mut() + .set_smart_select_override(ambiguous_link.link.range.clone()); + } + }, } self.inner = Some(link); } @@ -385,6 +449,11 @@ impl super::TerminalView { self.open_file_path(path.clone(), link.line_and_column_num, ctx); } } + #[cfg(feature = "local_fs")] + GridHighlightedLink::AmbiguousFile(link) => { + let query = link.get_inner().query.clone(); + self.open_ambiguous_file_link(query, ctx); + } GridHighlightedLink::Url(url) => { let model = self.model.lock(); ctx.open_url(&model.link_at_range(url, RespectObfuscatedSecrets::No)); @@ -467,6 +536,14 @@ impl super::TerminalView { .and_then(|active_session_id| self.sessions.as_ref(ctx).get(active_session_id)) .and_then(|active_session| active_session.launch_data().cloned()); + // For CLI agent TUIs, snapshot the repo file index on the main thread (the + // background scan thread has no AppContext) so we can fall back to a repo-wide + // filename lookup when a reference doesn't resolve relative to the cwd. `None` in + // normal terminal mode keeps behavior — and cost — exactly as before. + let cli_agent_repo_files = self + .should_detect_cli_agent_file_links(ctx) + .then(|| FileSearchModel::as_ref(ctx).get_repo_contents(ctx)); + // Using the thread builder instead of ctx.spawn here so that the previous // scanning job will be dropped once there is a new scanning job created. let (tx, rx) = futures::channel::oneshot::channel(); @@ -478,6 +555,7 @@ impl super::TerminalView { possible_paths, max_columns, shell_launch_data, + cli_agent_repo_files, ); let _ = tx.send(paths); }) @@ -515,9 +593,13 @@ impl super::TerminalView { possible_paths: impl Iterator>, max_columns: usize, shell_launch_data: Option, + cli_agent_repo_files: Option>>, ) -> Option { + // Collect up front so the candidates can be reused by the CLI-agent repo-index fallback + // below if none of them resolve relative to the working directory. + let possible_paths: Vec<_> = possible_paths.collect(); let mut link = None; - 'path_loop: for within_model_possible_path in possible_paths { + 'path_loop: for within_model_possible_path in &possible_paths { let possible_path = within_model_possible_path.get_inner(); // We want to check if the clean path result is a valid path and get the canonical // absolute path back. @@ -532,7 +614,7 @@ impl super::TerminalView { absolute_path, possible_path.path.line_and_column_num, possible_path.range.clone(), - &within_model_possible_path, + within_model_possible_path, )); break; } @@ -560,7 +642,7 @@ impl super::TerminalView { absolute_path, new_possible_cleaned_path.line_and_column_num, new_start_point..=*possible_path.range.end(), - &within_model_possible_path, + within_model_possible_path, )); // break outer_loop @@ -592,7 +674,7 @@ impl super::TerminalView { absolute_path, new_possible_cleaned_path.line_and_column_num, *possible_path.range.start()..=new_end_point, - &within_model_possible_path, + within_model_possible_path, )); // break outer_loop @@ -602,7 +684,144 @@ impl super::TerminalView { } } - link.map(GridHighlightedLink::File) + if let Some(link) = link { + return Some(GridHighlightedLink::File(link)); + } + + // No path resolved relative to the working directory. For CLI agent TUIs, fall back to the + // repo file index: a bare filename (or partial path) the agent printed often lives + // elsewhere in the repo. One match opens directly; several open the Files palette pre-filled + // so the user disambiguates. + if let Some(repo_files) = cli_agent_repo_files { + return Self::compute_repo_index_link(&possible_paths, &repo_files); + } + + None + } + + /// Fallback used for CLI agent TUIs: match each candidate token against the repo file index by + /// filename / path-suffix. Returns the first candidate that matches at least one repo file — + /// a [`GridHighlightedLink::File`] for a unique match, or a + /// [`GridHighlightedLink::AmbiguousFile`] (palette picker) for several. + pub(crate) fn compute_repo_index_link( + possible_paths: &[WithinModel], + repo_files: &[FileSearchResult], + ) -> Option { + if possible_paths.is_empty() { + return None; + } + + // A single hover can yield hundreds of candidate tokens, so index the (non-directory) repo + // files by basename once and do an O(1) lookup per candidate instead of sweeping all files + // for every candidate. The candidate-aligned suffix check is then applied only to the small + // set sharing the basename. + let mut files_by_basename: HashMap<&str, Vec<&FileSearchResult>> = HashMap::new(); + for file in repo_files { + if file.is_directory { + continue; + } + files_by_basename + .entry(file_basename(&file.path)) + .or_default() + .push(file); + } + + for within_model_possible_path in possible_paths { + let possible_path = within_model_possible_path.get_inner(); + let token = possible_path.path.path.as_str(); + if token.is_empty() { + continue; + } + + let Some(candidate_files) = files_by_basename.get(file_basename(token)) else { + continue; + }; + + // Distinct absolute paths whose repo-relative path matches the token. + let mut matches: Vec = Vec::new(); + for file in candidate_files { + if !Self::repo_path_matches_token(&file.path, token) { + continue; + } + let absolute_path = PathBuf::from(&file.project_directory).join(&file.path); + if !matches.contains(&absolute_path) { + matches.push(absolute_path); + } + } + + match matches.len() { + 0 => continue, + 1 => { + let absolute_path = matches.into_iter().next().expect("len checked"); + return Some(GridHighlightedLink::File(Self::create_valid_link( + absolute_path, + possible_path.path.line_and_column_num, + possible_path.range.clone(), + within_model_possible_path, + ))); + } + _ => { + return Some(Self::create_ambiguous_link( + token.to_string(), + possible_path.range.clone(), + within_model_possible_path, + )); + } + } + } + + None + } + + /// True when a repo-relative path (e.g. `crates/warp_features/src/lib.rs`) is referred to by + /// `token`. The file name must match exactly, and any leading directory components in the token + /// must appear, in order, as a subsequence of the path's directory components — tolerating + /// omitted intermediates. So a bare `lib.rs` matches any `lib.rs`, and the shorthand + /// `warp_features/lib.rs` matches `crates/warp_features/src/lib.rs` (skipping `crates/` and + /// `src/`), while `other/lib.rs` does not. Both separators are accepted in the token. + pub(crate) fn repo_path_matches_token(repo_relative_path: &str, token: &str) -> bool { + let path_components: Vec<&str> = repo_relative_path + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .collect(); + let token_components: Vec<&str> = token + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .collect(); + + let (Some(token_basename), Some(path_basename)) = + (token_components.last(), path_components.last()) + else { + return false; + }; + + // The file name itself must match exactly. + if token_basename != path_basename { + return false; + } + + // Leading token directories must be an ordered subsequence of the path's directories. + let token_dirs = &token_components[..token_components.len() - 1]; + let path_dirs = &path_components[..path_components.len() - 1]; + let mut remaining_path_dirs = path_dirs.iter(); + token_dirs + .iter() + .all(|token_dir| remaining_path_dirs.any(|path_dir| path_dir == token_dir)) + } + + fn create_ambiguous_link( + query: String, + path_range: std::ops::RangeInclusive, + possible_path: &WithinModel, + ) -> GridHighlightedLink { + let inner_link = AmbiguousFileLink { + link: Link { + range: path_range, + is_empty: false, + }, + query, + }; + GridHighlightedLink::AmbiguousFile(wrap_within_model(inner_link, possible_path)) } fn create_valid_link( @@ -619,13 +838,7 @@ impl super::TerminalView { absolute_path, line_and_column_num, }; - - match possible_path { - WithinModel::AltScreen(_) => WithinModel::AltScreen(inner_link), - WithinModel::BlockList(inner) => { - WithinModel::BlockList(WithinBlock::new(inner_link, inner.block_index, inner.grid)) - } - } + wrap_within_model(inner_link, possible_path) } fn handle_file_link_completed( diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 4455951b0f..edbd3eb430 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -512,9 +512,12 @@ fn alt_screen_file_links_gated_by_flag_setting_and_cli_agent_session() { }); // Flag enabled + setting OFF + session present -> not detected (user opt-out). - crate::terminal::general_settings::GeneralSettings::handle(&app).update(&mut app, |settings, ctx| { - let _ = settings.cli_agent_file_links.set_value(false, ctx); - }); + crate::terminal::general_settings::GeneralSettings::handle(&app).update( + &mut app, + |settings, ctx| { + let _ = settings.cli_agent_file_links.set_value(false, ctx); + }, + ); terminal.update(&mut app, |view, ctx| { let _flag = FeatureFlag::CliAgentFileLinks.override_enabled(true); assert!( @@ -524,9 +527,12 @@ fn alt_screen_file_links_gated_by_flag_setting_and_cli_agent_session() { }); // Setting back on + Codex session -> detected (multi-agent coverage). - crate::terminal::general_settings::GeneralSettings::handle(&app).update(&mut app, |settings, ctx| { - let _ = settings.cli_agent_file_links.set_value(true, ctx); - }); + crate::terminal::general_settings::GeneralSettings::handle(&app).update( + &mut app, + |settings, ctx| { + let _ = settings.cli_agent_file_links.set_value(true, ctx); + }, + ); set_session(&mut app, view_id, CLIAgent::Codex); terminal.update(&mut app, |view, ctx| { let _flag = FeatureFlag::CliAgentFileLinks.override_enabled(true); @@ -538,6 +544,198 @@ fn alt_screen_file_links_gated_by_flag_setting_and_cli_agent_session() { }) } +// Tests for the CLI-agent repo-index fallback: when a reference doesn't resolve relative to the +// agent's cwd, we match it against the repo file index by filename / path-suffix. These exercise +// the pure matching + branching logic (no filesystem) via `compute_repo_index_link`. +#[cfg(feature = "local_fs")] +mod cli_agent_repo_index_fallback { + use warp_util::path::CleanPathResult; + + use crate::search::files::search_item::FileSearchResult; + use crate::terminal::model::grid::grid_handler::PossiblePath; + use crate::terminal::model::index::Point; + use crate::terminal::model::terminal_model::WithinModel; + use crate::terminal::view::link_detection::GridHighlightedLink; + use crate::terminal::view::TerminalView; + + fn token(reference: &str) -> WithinModel { + WithinModel::AltScreen(PossiblePath { + path: CleanPathResult { + path: reference.to_string(), + line_and_column_num: None, + }, + range: Point::default()..=Point::default(), + }) + } + + fn file(path: &str, project_directory: &str) -> FileSearchResult { + FileSearchResult { + path: path.to_string(), + project_directory: project_directory.to_string(), + is_directory: false, + } + } + + #[test] + fn single_repo_match_resolves_to_file_link() { + let repo = vec![ + file("app/src/search/files/model.rs", "/repo"), + file("app/src/main.rs", "/repo"), + ]; + let candidates = [token("model.rs")]; + + let link = TerminalView::compute_repo_index_link(&candidates, &repo) + .expect("a single repo match should linkify"); + match link { + GridHighlightedLink::File(file_link) => { + assert_eq!( + file_link.get_inner().absolute_path(), + Some(std::path::PathBuf::from( + "/repo/app/src/search/files/model.rs" + )) + ); + } + other => panic!("expected a File link for a unique match, got {other:?}"), + } + } + + #[test] + fn multiple_repo_matches_produce_ambiguous_link() { + let repo = vec![ + file("app/src/search/files/mod.rs", "/repo"), + file("app/src/pane_group/mod.rs", "/repo"), + file("app/src/main.rs", "/repo"), + ]; + let candidates = [token("mod.rs")]; + + let link = TerminalView::compute_repo_index_link(&candidates, &repo) + .expect("multiple repo matches should still linkify"); + match link { + GridHighlightedLink::AmbiguousFile(ambiguous) => { + assert_eq!(ambiguous.get_inner().query, "mod.rs"); + } + other => panic!("expected an AmbiguousFile link for 2+ matches, got {other:?}"), + } + } + + #[test] + fn no_repo_match_produces_no_link() { + let repo = vec![file("app/src/main.rs", "/repo")]; + let candidates = [token("does_not_exist.rs")]; + + assert!( + TerminalView::compute_repo_index_link(&candidates, &repo).is_none(), + "a reference with no repo match should not linkify" + ); + } + + #[test] + fn partial_path_token_matches_only_component_aligned_suffix() { + let repo = vec![ + file("app/src/search/files/model.rs", "/repo"), + file("app/src/other/model.rs", "/repo"), + ]; + // `files/model.rs` should match only the file under `files/`, not the other `model.rs`. + let candidates = [token("files/model.rs")]; + + let link = TerminalView::compute_repo_index_link(&candidates, &repo) + .expect("the partial-path token should match exactly one file"); + match link { + GridHighlightedLink::File(file_link) => { + assert_eq!( + file_link.get_inner().absolute_path(), + Some(std::path::PathBuf::from( + "/repo/app/src/search/files/model.rs" + )) + ); + } + other => panic!("expected a unique File link, got {other:?}"), + } + } + + #[test] + fn directories_are_not_matched() { + let mut dir = file("app/src/search/files", "/repo"); + dir.is_directory = true; + let repo = vec![dir]; + let candidates = [token("files")]; + + assert!( + TerminalView::compute_repo_index_link(&candidates, &repo).is_none(), + "directory entries should be skipped by the file-link fallback" + ); + } + + #[test] + fn repo_path_matches_token_rules() { + // Exact whole-path match. + assert!(TerminalView::repo_path_matches_token( + "app/src/main.rs", + "app/src/main.rs" + )); + // Bare filename matches any file with that basename. + assert!(TerminalView::repo_path_matches_token( + "app/src/main.rs", + "main.rs" + )); + // Component-aligned suffix. + assert!(TerminalView::repo_path_matches_token( + "app/src/search/files/model.rs", + "files/model.rs" + )); + // Shorthand path that skips intermediate directories (e.g. `crates/` and `src/`). + assert!(TerminalView::repo_path_matches_token( + "crates/warp_features/src/lib.rs", + "warp_features/lib.rs" + )); + // Leading dirs must still appear in order: reversed order -> no match. + assert!(!TerminalView::repo_path_matches_token( + "crates/warp_features/src/lib.rs", + "lib/warp_features.rs" + )); + // Basename must match exactly (mid-component) -> no match. + assert!(!TerminalView::repo_path_matches_token( + "app/src/xmain.rs", + "main.rs" + )); + // Leading directory component not present in the path -> no match. + assert!(!TerminalView::repo_path_matches_token( + "app/src/other/model.rs", + "files/model.rs" + )); + // Unrelated path -> no match. + assert!(!TerminalView::repo_path_matches_token( + "app/src/main.rs", + "other.rs" + )); + } + + #[test] + fn shorthand_path_skipping_intermediate_dirs_resolves() { + let repo = vec![ + file("crates/warp_features/src/lib.rs", "/repo"), + file("crates/warp_core/src/lib.rs", "/repo"), + file("app/src/lib.rs", "/repo"), + ]; + // `warp_features/lib.rs` omits `crates/` and `src/` but should still resolve uniquely. + let candidates = [token("warp_features/lib.rs")]; + + let link = TerminalView::compute_repo_index_link(&candidates, &repo) + .expect("shorthand path should resolve to the matching lib.rs"); + match link { + GridHighlightedLink::File(file_link) => { + assert_eq!( + file_link.get_inner().absolute_path(), + Some(std::path::PathBuf::from( + "/repo/crates/warp_features/src/lib.rs" + )) + ); + } + other => panic!("expected a unique File link, got {other:?}"), + } + } +} + #[test] fn unregister_cli_agent_session_restores_unlocked_input_config() { App::test((), |mut app| async move { diff --git a/app/src/util/file.rs b/app/src/util/file.rs index dbbcc027eb..43ba5afe4e 100644 --- a/app/src/util/file.rs +++ b/app/src/util/file.rs @@ -123,6 +123,22 @@ impl ContainsPoint for FileLink { } } +/// A file reference that resolved to more than one candidate in the repo file index (e.g. a bare +/// filename like `mod.rs` that exists in several directories). Rather than guessing, clicking it +/// opens the command-palette Files mode pre-filled with `query` so the user picks the right one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AmbiguousFileLink { + pub link: Link, + /// Query to pre-fill the Files palette with (the referenced file name / partial path). + pub query: String, +} + +impl ContainsPoint for AmbiguousFileLink { + fn contains(&self, point: Point) -> bool { + self.link.contains(point) + } +} + /// Creates the file at the given path if it doesn't already exist, opening it /// in write mode. If any directories in the path are missing, those are created /// as well. diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 4f2d74231e..6a1149c84d 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -15959,8 +15959,16 @@ impl Workspace { }); } } - pane_group::Event::OpenFilesPalette { source } => { - self.open_palette_action(PaletteMode::Files, *source, None, ctx); + pane_group::Event::OpenFilesPalette { + source, + initial_query, + } => { + self.open_palette_action( + PaletteMode::Files, + *source, + initial_query.as_deref(), + ctx, + ); } pane_group::Event::ToggleLeftPanel { target_view,