diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 2dac9fc563e41ea1985754ea80b6b31e2a7e3add..583d8070d98697f4620bf45a3284d88760ebf9e7 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -575,6 +575,7 @@ impl ContextPickerCompletionProvider { .unwrap_or_default(); let workspace = workspace.read(cx); let project = workspace.project().read(cx); + let include_root_name = workspace.visible_worktrees(cx).count() > 1; if let Some(agent_panel) = workspace.panel::(cx) && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx) @@ -601,7 +602,11 @@ impl ContextPickerCompletionProvider { project .worktree_for_id(project_path.worktree_id, cx) .map(|worktree| { - let path_prefix = worktree.read(cx).root_name().into(); + let path_prefix = if include_root_name { + worktree.read(cx).root_name().into() + } else { + RelPath::empty().into() + }; Match::File(FileMatch { mat: fuzzy::PathMatch { score: 1., @@ -828,9 +833,21 @@ impl CompletionProvider for ContextPickerCompletionProvider { path: mat.path.clone(), }; + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + let path_prefix = if mat.path.is_empty() { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| mat.path_prefix.clone()) + } else { + mat.path_prefix.clone() + }; + Self::completion_for_path( project_path, - &mat.path_prefix, + &path_prefix, is_recent, mat.is_dir, source_range.clone(), diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 08bdfa2b49690190e8cecf07016da3a65e28cec7..5fe591caca5b88b97351884593a8b1550d8a1d11 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -2165,10 +2165,10 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - format!("eight.txt dir{slash}b{slash}"), - format!("seven.txt dir{slash}b{slash}"), - format!("six.txt dir{slash}b{slash}"), - format!("five.txt dir{slash}b{slash}"), + format!("eight.txt b{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), ] ); editor.set_text("", window, cx); @@ -2196,10 +2196,10 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - format!("eight.txt dir{slash}b{slash}"), - format!("seven.txt dir{slash}b{slash}"), - format!("six.txt dir{slash}b{slash}"), - format!("five.txt dir{slash}b{slash}"), + format!("eight.txt b{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), "Files & Directories".into(), "Symbols".into(), "Threads".into(), @@ -2232,7 +2232,7 @@ mod tests { assert!(editor.has_visible_completions_menu()); assert_eq!( current_completion_labels(editor), - vec![format!("one.txt dir{slash}a{slash}")] + vec![format!("one.txt a{slash}")] ); }); @@ -2511,7 +2511,7 @@ mod tests { format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) ); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]); + assert_eq!(current_completion_labels(editor), &["x.png "]); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -2553,7 +2553,7 @@ mod tests { format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) ); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &[format!("x.png dir{slash}")]); + assert_eq!(current_completion_labels(editor), &["x.png "]); }); editor.update_in(&mut cx, |editor, window, cx| { diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index caffb31521e397ca7cd6b1fa0c8f4ae73d5ab9ff..0a6e811673aa47339087e538003e87b1940d0039 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -662,6 +662,7 @@ pub(crate) fn recent_context_picker_entries( let mut recent = Vec::with_capacity(6); let workspace = workspace.read(cx); let project = workspace.project().read(cx); + let include_root_name = workspace.visible_worktrees(cx).count() > 1; recent.extend( workspace @@ -675,9 +676,16 @@ pub(crate) fn recent_context_picker_entries( .filter_map(|(project_path, _)| { project .worktree_for_id(project_path.worktree_id, cx) - .map(|worktree| RecentEntry::File { - project_path, - path_prefix: worktree.read(cx).root_name().into(), + .map(|worktree| { + let path_prefix = if include_root_name { + worktree.read(cx).root_name().into() + } else { + RelPath::empty().into() + }; + RecentEntry::File { + project_path, + path_prefix, + } }) }), ); diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index 56444141f12903db4868f9e154cccdb872b48514..3a3ea45c800e3031dc8939c1801ca989a220bf0c 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -655,13 +655,12 @@ impl ContextPickerCompletionProvider { let SymbolLocation::InProject(symbol_path) = &symbol.path else { return None; }; - let path_prefix = workspace + let _path_prefix = workspace .read(cx) .project() .read(cx) - .worktree_for_id(symbol_path.worktree_id, cx)? - .read(cx) - .root_name(); + .worktree_for_id(symbol_path.worktree_id, cx)?; + let path_prefix = RelPath::empty(); let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( &symbol_path.path, @@ -818,9 +817,21 @@ impl CompletionProvider for ContextPickerCompletionProvider { return None; } + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + let path_prefix = if mat.path.is_empty() { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| mat.path_prefix.clone()) + } else { + mat.path_prefix.clone() + }; + Some(Self::completion_for_path( project_path, - &mat.path_prefix, + &path_prefix, is_recent, mat.is_dir, excerpt_id, @@ -1309,10 +1320,10 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - format!("seven.txt dir{slash}b{slash}"), - format!("six.txt dir{slash}b{slash}"), - format!("five.txt dir{slash}b{slash}"), - format!("four.txt dir{slash}a{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), + format!("four.txt a{slash}"), "Files & Directories".into(), "Symbols".into(), "Fetch".into() @@ -1344,7 +1355,7 @@ mod tests { assert!(editor.has_visible_completions_menu()); assert_eq!( current_completion_labels(editor), - vec![format!("one.txt dir{slash}a{slash}")] + vec![format!("one.txt a{slash}")] ); }); @@ -1356,12 +1367,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) ") + format!("Lorem [@one.txt](@file:a{slash}one.txt) ") ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1370,12 +1381,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) ") + format!("Lorem [@one.txt](@file:a{slash}one.txt) ") ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1384,12 +1395,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum "), + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum "), ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1398,12 +1409,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum @file "), + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum @file "), ); assert!(editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1416,14 +1427,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum [@seven.txt](@file:dir{slash}b{slash}seven.txt) ") + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) ") ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80) + Point::new(0, 6)..Point::new(0, 33), + Point::new(0, 41)..Point::new(0, 72) ] ); }); @@ -1433,14 +1444,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum [@seven.txt](@file:dir{slash}b{slash}seven.txt) \n@") + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) \n@") ); assert!(editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80) + Point::new(0, 6)..Point::new(0, 33), + Point::new(0, 41)..Point::new(0, 72) ] ); }); @@ -1454,20 +1465,203 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("Lorem [@one.txt](@file:dir{slash}a{slash}one.txt) Ipsum [@seven.txt](@file:dir{slash}b{slash}seven.txt) \n[@six.txt](@file:dir{slash}b{slash}six.txt) ") + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) \n[@six.txt](@file:b{slash}six.txt) ") ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80), - Point::new(1, 0)..Point::new(1, 31) + Point::new(0, 6)..Point::new(0, 33), + Point::new(0, 41)..Point::new(0, 72), + Point::new(1, 0)..Point::new(1, 27) ] ); }); } + #[gpui::test] + async fn test_context_completion_provider_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project1"), + json!({ + "a": { + "one.txt": "", + "two.txt": "", + } + }), + ) + .await; + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project2"), + json!({ + "b": { + "three.txt": "", + "four.txt": "", + } + }), + ) + .await; + + let project = Project::test( + app_state.fs.clone(), + [path!("/project1").as_ref(), path!("/project2").as_ref()], + cx, + ) + .await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let worktrees = project.update(cx, |project, cx| { + let worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 2); + worktrees + }); + + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let slash = PathStyle::local().separator(); + + for (worktree_idx, paths) in [ + vec![rel_path("a/one.txt"), rel_path("a/two.txt")], + vec![rel_path("b/three.txt"), rel_path("b/four.txt")], + ] + .iter() + .enumerate() + { + let worktree_id = worktrees[worktree_idx].read_with(&cx, |wt, _| wt.id()); + for path in paths { + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: (*path).into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap(); + } + } + + let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + Editor::new( + editor::EditorMode::full(), + multi_buffer::MultiBuffer::build_simple("", cx), + None, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| AtMentionEditor(editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + editor + }); + + let context_store = cx.new(|_| ContextStore::new(project.downgrade())); + + let editor_entity = editor.downgrade(); + editor.update_in(&mut cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + workspace.downgrade(), + context_store.downgrade(), + None, + None, + editor_entity, + None, + )))); + }); + + cx.simulate_input("@"); + + // With multiple worktrees, we should see the project name as prefix + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@"); + assert!(editor.has_visible_completions_menu()); + let labels = current_completion_labels(editor); + + assert!( + labels.contains(&format!("four.txt project2{slash}b{slash}")), + "Expected 'four.txt project2{slash}b{slash}' in labels: {:?}", + labels + ); + assert!( + labels.contains(&format!("three.txt project2{slash}b{slash}")), + "Expected 'three.txt project2{slash}b{slash}' in labels: {:?}", + labels + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@file "); + assert!(editor.has_visible_completions_menu()); + }); + + cx.simulate_input("one"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@file one"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + vec![format!("one.txt project1{slash}a{slash}")] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("[@one.txt](@file:project1{slash}a{slash}one.txt) ") + ); + assert!(!editor.has_visible_completions_menu()); + }); + } + fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { let snapshot = editor.buffer().read(cx).snapshot(cx); editor.display_map.update(cx, |display_map, cx| { diff --git a/crates/agent_ui/src/context_picker/file_context_picker.rs b/crates/agent_ui/src/context_picker/file_context_picker.rs index 8d1e5cb46dfba7bc89770356334fb08a7bf7a0c5..ded24caa922d27d8821e46e5c58b5ed22ab754ff 100644 --- a/crates/agent_ui/src/context_picker/file_context_picker.rs +++ b/crates/agent_ui/src/context_picker/file_context_picker.rs @@ -197,34 +197,50 @@ pub(crate) fn search_files( if query.is_empty() { let workspace = workspace.read(cx); let project = workspace.project().read(cx); + let visible_worktrees = workspace.visible_worktrees(cx).collect::>(); + let include_root_name = visible_worktrees.len() > 1; + let recent_matches = workspace .recent_navigation_history(Some(10), cx) .into_iter() - .filter_map(|(project_path, _)| { - let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; - Some(FileMatch { + .map(|(project_path, _)| { + let path_prefix = if include_root_name { + project + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| RelPath::empty().into()) + } else { + RelPath::empty().into() + }; + + FileMatch { mat: PathMatch { score: 0., positions: Vec::new(), worktree_id: project_path.worktree_id.to_usize(), path: project_path.path, - path_prefix: worktree.read(cx).root_name().into(), + path_prefix, distance_to_relative_ancestor: 0, is_dir: false, }, is_recent: true, - }) + } }); - let file_matches = project.worktrees(cx).flat_map(|worktree| { + let file_matches = visible_worktrees.into_iter().flat_map(|worktree| { let worktree = worktree.read(cx); + let path_prefix: Arc = if include_root_name { + worktree.root_name().into() + } else { + RelPath::empty().into() + }; worktree.entries(false, 0).map(move |entry| FileMatch { mat: PathMatch { score: 0., positions: Vec::new(), worktree_id: worktree.id().to_usize(), path: entry.path.clone(), - path_prefix: worktree.root_name().into(), + path_prefix: path_prefix.clone(), distance_to_relative_ancestor: 0, is_dir: entry.is_dir(), }, @@ -235,6 +251,7 @@ pub(crate) fn search_files( Task::ready(recent_matches.chain(file_matches).collect()) } else { let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let include_root_name = worktrees.len() > 1; let candidate_sets = worktrees .into_iter() .map(|worktree| { @@ -243,7 +260,7 @@ pub(crate) fn search_files( PathMatchCandidateSet { snapshot: worktree.snapshot(), include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored), - include_root_name: true, + include_root_name, candidates: project::Candidates::Entries, } }) @@ -276,6 +293,12 @@ pub fn extract_file_name_and_directory( path_prefix: &RelPath, path_style: PathStyle, ) -> (SharedString, Option) { + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + if path.is_empty() && !path_prefix.is_empty() { + return (path_prefix.display(path_style).to_string().into(), None); + } + let full_path = path_prefix.join(path); let file_name = full_path.file_name().unwrap_or_default(); let display_path = full_path.display(path_style);