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,