diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index ac7b2d23cb7966..403b71736c9470 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -96,13 +96,18 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let normalized = if path_style.is_windows() { + let trimmed = if path_style.is_windows() { path.trim_start_matches("/") } else { path }; - let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); - let path = decoded.as_ref(); + let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed)); + let normalized: Cow = if path_style.is_windows() { + Cow::Owned(decoded.replace('/', "\\")) + } else { + decoded + }; + let path = normalized.as_ref(); if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1); @@ -493,6 +498,49 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), file_uri); } + #[test] + fn test_parse_file_uris_use_native_separators_on_windows() { + let parsed = MentionUri::parse("file:///C:/path/to/file.rs", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + + let parsed = MentionUri::parse("file:///C:/path/to/dir/", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Directory { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\dir\\")); + } + other => panic!("Expected Directory variant, got {other:?}"), + } + + let parsed = MentionUri::parse( + "file:///C:/path/to/file.rs?symbol=MySymbol#L10:20", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Symbol { abs_path, .. } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs")); + } + other => panic!("Expected Symbol variant, got {other:?}"), + } + + let parsed = + MentionUri::parse("file:///C:/path/to/file.rs#L5:15", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + .. + } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs")); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 0c7b3eb6baaa37..6b552610cf42a6 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -174,6 +174,10 @@ impl MentionSet { self.mentions.values().map(|(uri, _)| uri.clone()).collect() } + pub fn mention_uri_for_crease(&self, crease_id: &CreaseId) -> Option { + self.mentions.get(crease_id).map(|(uri, _)| uri.clone()) + } + pub fn set_mentions(&mut self, mentions: HashMap) { self.mentions = mentions; } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 0f213cb9f1e365..213ce4e88c9172 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -15,12 +15,14 @@ use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, - actions::Paste, code_context_menus::CodeContextMenu, scroll::Autoscroll, + actions::{Copy, Paste}, + code_context_menus::CodeContextMenu, + scroll::Autoscroll, }; use futures::{FutureExt as _, future::join_all}; use gpui::{ - AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, - KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, + AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; @@ -1186,6 +1188,16 @@ impl MessageEditor { cx.propagate(); } + fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + let Some(text) = self.serialized_copy_text(cx) else { + cx.propagate(); + return; + }; + + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); window.defer(cx, move |window, cx| { @@ -1765,6 +1777,76 @@ impl MessageEditor { editor.set_text(text, window, cx); }); } + + fn serialized_copy_text(&self, cx: &mut App) -> Option { + let display_snapshot = self + .editor + .update(cx, |editor, cx| editor.display_snapshot(cx)); + let editor = self.editor.read(cx); + if !editor.has_non_empty_selection(&display_snapshot) { + return None; + } + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mention_set = self.mention_set.read(cx); + let mention_ranges = display_snapshot + .crease_snapshot + .crease_items_with_offsets(&snapshot) + .into_iter() + .filter_map(|(crease_id, range)| { + mention_set.mention_uri_for_crease(&crease_id).map(|uri| { + ( + range.start.to_offset(&snapshot), + range.end.to_offset(&snapshot), + uri, + ) + }) + }) + .collect::>(); + + let mut text = String::new(); + let mut has_mentions = false; + let mut is_first = true; + + for selection in editor + .selections + .all::(&display_snapshot) + { + if is_first { + is_first = false; + } else { + text.push('\n'); + } + + let mut overlapping_mentions = mention_ranges + .iter() + .filter(|(start, end, _)| *start < selection.end && selection.start < *end) + .peekable(); + + if overlapping_mentions.peek().is_none() { + text.extend(snapshot.text_for_range(selection.start..selection.end)); + continue; + } + + has_mentions = true; + + let mut cursor = selection.start; + for (start, end, uri) in overlapping_mentions { + if cursor < *start { + text.extend(snapshot.text_for_range(cursor..*start)); + } + + write!(text, "{}", uri.as_link()).unwrap(); + cursor = *end; + } + + if cursor < selection.end { + text.extend(snapshot.text_for_range(cursor..selection.end)); + } + } + + has_mentions.then_some(text) + } } impl Focusable for MessageEditor { @@ -1781,6 +1863,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::send_immediately)) .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) + .capture_action(cx.listener(Self::copy)) .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -1915,10 +1998,10 @@ mod tests { }; use fs::FakeFs; - use futures::StreamExt as _; + use futures::{FutureExt as _, StreamExt as _}; use gpui::{ AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths, - FocusHandle, Focusable, TestAppContext, VisualTestContext, + FocusHandle, Focusable, Task, TestAppContext, VisualTestContext, }; use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; @@ -1934,6 +2017,7 @@ mod tests { use crate::completion_provider::PromptContextType; use crate::{ conversation_view::tests::init_test, + mention_set::insert_crease_for_mention, message_editor::{Mention, MessageEditor, SessionCapabilities, parse_mention_links}, }; @@ -3848,6 +3932,318 @@ mod tests { ); } + #[gpui::test] + async fn test_copy_with_selection_mentions_serializes_links(cx: &mut TestAppContext) { + init_test(cx); + + let (source_message_editor, _source_editor, mut cx) = setup_paste_test_message_editor( + json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}), + cx, + ) + .await; + + let workspace = source_message_editor.read_with(&cx, |message_editor, _| { + message_editor.workspace.upgrade().expect("workspace") + }); + let project = workspace.read_with(&cx, |workspace, _| workspace.project().clone()); + + let source_text = "selection needs work\nselection looks fine"; + let first_range = 0..9; + let second_start = "selection needs work\n".len(); + let second_range = second_start..(second_start + "selection".len()); + let first_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 0..=1, + }; + let second_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 2..=3, + }; + + source_message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.set_text(source_text, window, cx); + + let snapshot = message_editor + .editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + for (range, uri, content) in [ + ( + first_range.clone(), + first_uri.clone(), + "line 1\nline 2\n".to_string(), + ), + ( + second_range.clone(), + second_uri.clone(), + "line 3\nline 4\n".to_string(), + ), + ] { + let Some((crease_id, tx)) = insert_crease_for_mention( + snapshot + .anchor_to_buffer_anchor( + snapshot.anchor_before(MultiBufferOffset(range.start)), + ) + .expect("selection mention anchor should map to a buffer") + .0, + range.len(), + uri.name().into(), + uri.icon_path(cx), + uri.tooltip_text(), + Some(uri.clone()), + Some(message_editor.workspace.clone()), + None, + message_editor.editor.clone(), + window, + cx, + ) else { + panic!("expected mention crease insertion"); + }; + drop(tx); + + message_editor.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + uri, + Task::ready(Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + })) + .shared(), + ); + }); + } + + let buffer_len = snapshot.len(); + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..buffer_len]); + }); + }); + }); + + let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| { + message_editor + .serialized_copy_text(cx) + .expect("selection mentions should serialize") + }); + let expected_text = format!( + "{} needs work\n{} looks fine", + first_uri.as_link(), + second_uri.as_link() + ); + assert_eq!(copied_text, expected_text); + + let target_message_editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + None, + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + message_editor + }); + + cx.write_to_clipboard(ClipboardItem::new_string(copied_text)); + target_message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + + let target_text = target_message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(target_text, expected_text); + + let contents = mention_contents(&target_message_editor, &mut cx).await; + assert_eq!(contents.len(), 2); + assert!(contents.iter().any(|(uri, _)| uri == &first_uri)); + assert!(contents.iter().any(|(uri, _)| uri == &second_uri)); + } + + struct SelectionMentionFixture { + message_editor: Entity, + first_uri: MentionUri, + first_range: Range, + second_range: Range, + } + + async fn setup_selection_mention_fixture( + cx: &mut TestAppContext, + ) -> (SelectionMentionFixture, VisualTestContext) { + let (message_editor, _source_editor, mut cx) = setup_paste_test_message_editor( + json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}), + cx, + ) + .await; + + let source_text = "selection needs work\nselection looks fine"; + let first_range = 0..9; + let second_start = "selection needs work\n".len(); + let second_range = second_start..(second_start + "selection".len()); + let first_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 0..=1, + }; + let second_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 2..=3, + }; + + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.set_text(source_text, window, cx); + + let snapshot = message_editor + .editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + for (range, uri, content) in [ + ( + first_range.clone(), + first_uri.clone(), + "line 1\nline 2\n".to_string(), + ), + ( + second_range.clone(), + second_uri.clone(), + "line 3\nline 4\n".to_string(), + ), + ] { + let Some((crease_id, tx)) = insert_crease_for_mention( + snapshot + .anchor_to_buffer_anchor( + snapshot.anchor_before(MultiBufferOffset(range.start)), + ) + .expect("selection mention anchor should map to a buffer") + .0, + range.len(), + uri.name().into(), + uri.icon_path(cx), + uri.tooltip_text(), + Some(uri.clone()), + Some(message_editor.workspace.clone()), + None, + message_editor.editor.clone(), + window, + cx, + ) else { + panic!("expected mention crease insertion"); + }; + drop(tx); + + message_editor.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + uri, + Task::ready(Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + })) + .shared(), + ); + }); + } + }); + + ( + SelectionMentionFixture { + message_editor, + first_uri, + first_range, + second_range, + }, + cx, + ) + } + + #[gpui::test] + async fn test_serialized_copy_text_selection_covers_only_mention(cx: &mut TestAppContext) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + let range = fixture.first_range.clone(); + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) + ]); + }); + }); + }); + + let copied = fixture + .message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.serialized_copy_text(cx) + }); + + assert_eq!(copied, Some(fixture.first_uri.as_link().to_string())); + } + + #[gpui::test] + async fn test_serialized_copy_text_returns_none_when_mentions_outside_selection( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let between_start = fixture.first_range.end; + let between_end = fixture.second_range.start - 1; + + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + MultiBufferOffset(between_start)..MultiBufferOffset(between_end) + ]); + }); + }); + }); + + let copied = fixture + .message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.serialized_copy_text(cx) + }); + + assert_eq!(copied, None); + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext,