Skip to repository content949 lines · 31.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:30:15.864Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
text_diff_view.rs
1//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
2
3use anyhow::Result;
4use buffer_diff::BufferDiff;
5use editor::{
6 Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate,
7 SplittableEditor, ToPoint, actions::DiffClipboardWithSelectionData,
8};
9use futures::{FutureExt, select_biased};
10use gpui::{
11 AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
12 Focusable, IntoElement, Render, Task, Window,
13};
14use language::{self, Buffer, Capability, OffsetRangeExt, Point};
15use project::{Project, ProjectPath};
16use settings::Settings;
17use std::{
18 any::{Any, TypeId},
19 cmp,
20 ops::Range,
21 pin::pin,
22 sync::Arc,
23 time::Duration,
24};
25use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
26use util::paths::PathExt;
27
28use workspace::{
29 Item, ItemNavHistory, Workspace,
30 item::{ItemEvent, SaveOptions, TabContentParams},
31 searchable::SearchableItemHandle,
32};
33
34pub struct TextDiffView {
35 diff_editor: Entity<SplittableEditor>,
36 title: SharedString,
37 path: Option<SharedString>,
38 buffer_changes_tx: watch::Sender<()>,
39 _recalculate_diff_task: Task<Result<()>>,
40}
41
42const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
43
44impl TextDiffView {
45 pub fn open(
46 diff_data: &DiffClipboardWithSelectionData,
47 workspace: &Workspace,
48 window: &mut Window,
49 cx: &mut App,
50 ) -> Option<Task<Result<Entity<Self>>>> {
51 let source_editor = diff_data.editor.clone();
52
53 let selection_data = source_editor.update(cx, |editor, cx| {
54 let multibuffer = editor.buffer();
55 let multibuffer_snapshot = multibuffer.read(cx).snapshot(cx);
56 let first_selection = editor.selections.newest_anchor();
57
58 let (source_buffer, buffer_range) = multibuffer_snapshot
59 .anchor_range_to_buffer_anchor_range(first_selection.range())?;
60 let max_point = source_buffer.max_point();
61 let buffer_range = buffer_range.to_point(source_buffer);
62 let source_buffer = multibuffer.read(cx).buffer(source_buffer.remote_id())?;
63
64 if buffer_range.is_empty() {
65 let full_range = Point::new(0, 0)..max_point;
66 return Some((source_buffer, full_range));
67 }
68
69 let expanded_start = Point::new(buffer_range.start.row, 0);
70 let expanded_end = if buffer_range.end.column > 0 {
71 let next_row = buffer_range.end.row + 1;
72 cmp::min(max_point, Point::new(next_row, 0))
73 } else {
74 buffer_range.end
75 };
76 Some((source_buffer, expanded_start..expanded_end))
77 });
78
79 let Some((source_buffer, expanded_selection_range)) = selection_data else {
80 log::warn!("There should always be at least one selection in Omega. This is a bug.");
81 return None;
82 };
83
84 source_editor.update(cx, |source_editor, cx| {
85 let multibuffer = source_editor.buffer();
86 let mb_range = {
87 let mb = multibuffer.read(cx);
88 let start_anchor =
89 mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
90 let end_anchor =
91 mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
92 start_anchor.zip(end_anchor).map(|(s, e)| {
93 let snapshot = mb.snapshot(cx);
94 s.to_point(&snapshot)..e.to_point(&snapshot)
95 })
96 };
97
98 if let Some(range) = mb_range {
99 source_editor.change_selections(Default::default(), window, cx, |s| {
100 s.select_ranges(vec![range]);
101 });
102 }
103 });
104
105 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
106 let mut clipboard_text = diff_data.clipboard_text.clone();
107
108 if !clipboard_text.ends_with("\n") {
109 clipboard_text.push_str("\n");
110 }
111
112 let workspace = workspace.weak_handle();
113 let clipboard_buffer = build_clipboard_buffer(
114 clipboard_text,
115 &source_buffer,
116 expanded_selection_range.clone(),
117 cx,
118 );
119 let diff_buffer = cx.new(|cx| {
120 BufferDiff::new_with_base_text_buffer(
121 &source_buffer_snapshot.text,
122 clipboard_buffer.clone(),
123 cx,
124 )
125 });
126
127 let task = window.spawn(cx, async move |cx| {
128 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await;
129
130 workspace.update_in(cx, |workspace, window, cx| {
131 let project = workspace.project().clone();
132 let workspace_entity = cx.entity();
133 let diff_view = cx.new(|cx| {
134 TextDiffView::new(
135 clipboard_buffer,
136 source_editor,
137 source_buffer,
138 expanded_selection_range,
139 diff_buffer,
140 project,
141 workspace_entity,
142 window,
143 cx,
144 )
145 });
146
147 let pane = workspace.active_pane();
148 pane.update(cx, |pane, cx| {
149 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
150 });
151
152 diff_view
153 })
154 });
155
156 Some(task)
157 }
158
159 pub fn new(
160 clipboard_buffer: Entity<Buffer>,
161 source_editor: Entity<Editor>,
162 source_buffer: Entity<Buffer>,
163 source_range: Range<Point>,
164 diff_buffer: Entity<BufferDiff>,
165 project: Entity<Project>,
166 workspace: Entity<Workspace>,
167 window: &mut Window,
168 cx: &mut Context<Self>,
169 ) -> Self {
170 let multibuffer = cx.new(|cx| {
171 let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
172
173 multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
174
175 multibuffer.add_diff(diff_buffer.clone(), cx);
176 multibuffer
177 });
178 let diff_editor = cx.new(|cx| {
179 let splittable = SplittableEditor::new(
180 EditorSettings::get_global(cx).diff_view_style,
181 multibuffer,
182 project,
183 workspace,
184 window,
185 cx,
186 );
187 splittable
188 .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx);
189 splittable
190 });
191
192 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
193
194 cx.subscribe(&source_buffer, move |this, _, event, _| match event {
195 language::BufferEvent::Edited { .. }
196 | language::BufferEvent::LanguageChanged(_)
197 | language::BufferEvent::Reparsed => {
198 this.buffer_changes_tx.send(()).ok();
199 }
200 _ => {}
201 })
202 .detach();
203
204 let editor = source_editor.read(cx);
205 let title = editor.buffer().read(cx).title(cx).to_string();
206 let selection_location_text = selection_location_text(editor, cx);
207 let selection_location_title = selection_location_text
208 .as_ref()
209 .map(|text| format!("{} @ {}", title, text))
210 .unwrap_or(title);
211
212 let path = editor
213 .buffer()
214 .read(cx)
215 .as_singleton()
216 .and_then(|b| {
217 b.read(cx)
218 .file()
219 .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
220 })
221 .unwrap_or(MultiBuffer::DEFAULT_TITLE.into());
222
223 let selection_location_path = selection_location_text
224 .map(|text| format!("{} @ {}", path, text))
225 .unwrap_or(path);
226
227 Self {
228 diff_editor,
229 title: format!("Clipboard ↔ {selection_location_title}").into(),
230 path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
231 buffer_changes_tx,
232 _recalculate_diff_task: cx.spawn(async move |_, cx| {
233 while buffer_changes_rx.recv().await.is_ok() {
234 loop {
235 let mut timer = cx
236 .background_executor()
237 .timer(RECALCULATE_DIFF_DEBOUNCE)
238 .fuse();
239 let mut recv = pin!(buffer_changes_rx.recv().fuse());
240 select_biased! {
241 _ = timer => break,
242 _ = recv => continue,
243 }
244 }
245
246 log::trace!("start recalculating");
247 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await;
248 log::trace!("finish recalculating");
249 }
250 Ok(())
251 }),
252 }
253 }
254}
255
256fn build_clipboard_buffer(
257 text: String,
258 source_buffer: &Entity<Buffer>,
259 replacement_range: Range<Point>,
260 cx: &mut App,
261) -> Entity<Buffer> {
262 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
263 cx.new(|cx| {
264 let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
265 let language = source_buffer.read(cx).language().cloned();
266 if let Some(language_registry) = source_buffer.read(cx).language_registry() {
267 buffer.set_language_registry(language_registry);
268 }
269 buffer.set_language(language, cx);
270
271 let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
272 let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
273 buffer.edit([(range_start..range_end, text)], None, cx);
274
275 buffer.set_capability(Capability::ReadOnly, cx);
276
277 buffer
278 })
279}
280
281async fn update_diff_buffer(
282 diff: &Entity<BufferDiff>,
283 source_buffer: &Entity<Buffer>,
284 clipboard_buffer: &Entity<Buffer>,
285 cx: &mut AsyncApp,
286) {
287 let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
288 let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
289 let base_text = Arc::<str>::from(base_buffer_snapshot.text());
290
291 let update = diff
292 .update(cx, |diff, cx| {
293 diff.update_diff(
294 source_buffer_snapshot.text.clone(),
295 &base_buffer_snapshot,
296 Some(base_text.clone()),
297 cx,
298 )
299 })
300 .await;
301
302 diff.update(cx, |diff, cx| diff.set_snapshot(update, cx));
303}
304
305impl EventEmitter<EditorEvent> for TextDiffView {}
306
307impl Focusable for TextDiffView {
308 fn focus_handle(&self, cx: &App) -> FocusHandle {
309 self.diff_editor.focus_handle(cx)
310 }
311}
312
313impl Item for TextDiffView {
314 type Event = EditorEvent;
315
316 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
317 Some(Icon::new(IconName::Diff).color(Color::Muted))
318 }
319
320 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
321 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
322 .color(if params.selected {
323 Color::Default
324 } else {
325 Color::Muted
326 })
327 .into_any_element()
328 }
329
330 fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
331 self.title.clone()
332 }
333
334 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
335 self.path.clone()
336 }
337
338 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
339 Editor::to_item_events(event, f)
340 }
341
342 fn telemetry_event_text(&self) -> Option<&'static str> {
343 Some("Selection Diff View Opened")
344 }
345
346 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
347 self.diff_editor
348 .update(cx, |editor, cx| editor.deactivated(window, cx));
349 }
350
351 fn act_as_type<'a>(
352 &'a self,
353 type_id: TypeId,
354 self_handle: &'a Entity<Self>,
355 cx: &'a App,
356 ) -> Option<gpui::AnyEntity> {
357 if type_id == TypeId::of::<Self>() {
358 Some(self_handle.clone().into())
359 } else if type_id == TypeId::of::<SplittableEditor>() {
360 Some(self.diff_editor.clone().into())
361 } else if type_id == TypeId::of::<Editor>() {
362 Some(self.diff_editor.read(cx).rhs_editor().clone().into())
363 } else {
364 None
365 }
366 }
367
368 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
369 Some(Box::new(self.diff_editor.clone()))
370 }
371
372 fn for_each_project_item(
373 &self,
374 cx: &App,
375 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
376 ) {
377 self.diff_editor.read(cx).for_each_project_item(cx, f)
378 }
379
380 fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
381 self.diff_editor.read(cx).active_project_path(cx)
382 }
383
384 fn set_nav_history(
385 &mut self,
386 nav_history: ItemNavHistory,
387 _: &mut Window,
388 cx: &mut Context<Self>,
389 ) {
390 let rhs = self.diff_editor.read(cx).rhs_editor().clone();
391 rhs.update(cx, |editor, _| {
392 editor.set_nav_history(Some(nav_history));
393 });
394 }
395
396 fn navigate(
397 &mut self,
398 data: Arc<dyn Any + Send>,
399 window: &mut Window,
400 cx: &mut Context<Self>,
401 ) -> bool {
402 self.diff_editor
403 .update(cx, |editor, cx| editor.navigate(data, window, cx))
404 }
405
406 fn added_to_workspace(
407 &mut self,
408 workspace: &mut Workspace,
409 window: &mut Window,
410 cx: &mut Context<Self>,
411 ) {
412 self.diff_editor.update(cx, |editor, cx| {
413 editor.added_to_workspace(workspace, window, cx)
414 });
415 }
416
417 fn can_save(&self, cx: &App) -> bool {
418 // The editor handles the new buffer, so delegate to it
419 self.diff_editor.read(cx).can_save(cx)
420 }
421
422 fn save(
423 &mut self,
424 options: SaveOptions,
425 project: Entity<Project>,
426 window: &mut Window,
427 cx: &mut Context<Self>,
428 ) -> Task<Result<()>> {
429 // Delegate saving to the editor, which manages the new buffer
430 self.diff_editor
431 .update(cx, |editor, cx| editor.save(options, project, window, cx))
432 }
433}
434
435pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
436 let buffer = editor.buffer().read(cx);
437 let buffer_snapshot = buffer.snapshot(cx);
438 let first_selection = editor.selections.disjoint_anchors().first()?;
439
440 let selection_start = first_selection.start.to_point(&buffer_snapshot);
441 let selection_end = first_selection.end.to_point(&buffer_snapshot);
442
443 let start_row = selection_start.row;
444 let start_column = selection_start.column;
445 let end_row = selection_end.row;
446 let end_column = selection_end.column;
447
448 let range_text = if start_row == end_row {
449 format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
450 } else {
451 format!(
452 "L{}:{}-L{}:{}",
453 start_row + 1,
454 start_column + 1,
455 end_row + 1,
456 end_column + 1
457 )
458 };
459
460 Some(range_text)
461}
462
463impl Render for TextDiffView {
464 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
465 self.diff_editor.clone()
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
473 use gpui::{BorrowAppContext, TestAppContext, VisualContext};
474 use language::Point;
475 use project::{FakeFs, Project};
476 use serde_json::json;
477 use settings::{DiffViewStyle, SettingsStore};
478 use unindent::unindent;
479 use util::{path, test::marked_text_ranges};
480 use workspace::MultiWorkspace;
481
482 fn init_test(cx: &mut TestAppContext) {
483 cx.update(|cx| {
484 let settings_store = SettingsStore::test(cx);
485 cx.set_global(settings_store);
486 cx.update_global::<SettingsStore, _>(|store, cx| {
487 store.update_user_settings(cx, |settings| {
488 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
489 });
490 });
491 theme_settings::init(theme::LoadThemes::JustBase, cx);
492 });
493 }
494
495 #[gpui::test]
496 async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
497 cx: &mut TestAppContext,
498 ) {
499 base_test(
500 path!("/test"),
501 path!("/test/text.txt"),
502 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
503 "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
504 &unindent(
505 "
506 - def process_incoming_inventory(items, warehouse_id):
507 + ˇdef process_outgoing_inventory(items, warehouse_id):
508 pass
509 ",
510 ),
511 "Clipboard ↔ text.txt @ L1:1-L3:1",
512 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
513 cx,
514 )
515 .await;
516 }
517
518 #[gpui::test]
519 async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
520 cx: &mut TestAppContext,
521 ) {
522 base_test(
523 path!("/test"),
524 path!("/test/text.txt"),
525 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
526 "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
527 &unindent(
528 "
529 - def process_incoming_inventory(items, warehouse_id):
530 + ˇdef process_outgoing_inventory(items, warehouse_id):
531 pass
532 ",
533 ),
534 "Clipboard ↔ text.txt @ L1:1-L3:1",
535 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
536 cx,
537 )
538 .await;
539 }
540
541 #[gpui::test]
542 async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
543 base_test(
544 path!("/test"),
545 path!("/test/text.txt"),
546 "a",
547 "«bbˇ»",
548 &unindent(
549 "
550 - a
551 + ˇbb",
552 ),
553 "Clipboard ↔ text.txt @ L1:1-3",
554 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
555 cx,
556 )
557 .await;
558 }
559
560 #[gpui::test]
561 async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
562 base_test(
563 path!("/test"),
564 path!("/test/text.txt"),
565 " a",
566 "«bbˇ»",
567 &unindent(
568 "
569 - a
570 + ˇbb",
571 ),
572 "Clipboard ↔ text.txt @ L1:1-3",
573 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
574 cx,
575 )
576 .await;
577 }
578
579 #[gpui::test]
580 async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
581 base_test(
582 path!("/test"),
583 path!("/test/text.txt"),
584 "a",
585 " «bbˇ»",
586 &unindent(
587 "
588 - a
589 + ˇ bb",
590 ),
591 "Clipboard ↔ text.txt @ L1:1-7",
592 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
593 cx,
594 )
595 .await;
596 }
597
598 #[gpui::test]
599 async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
600 cx: &mut TestAppContext,
601 ) {
602 base_test(
603 path!("/test"),
604 path!("/test/text.txt"),
605 "a",
606 "« bbˇ»",
607 &unindent(
608 "
609 - a
610 + ˇ bb",
611 ),
612 "Clipboard ↔ text.txt @ L1:1-7",
613 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
614 cx,
615 )
616 .await;
617 }
618
619 #[gpui::test]
620 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
621 cx: &mut TestAppContext,
622 ) {
623 base_test(
624 path!("/test"),
625 path!("/test/text.txt"),
626 " a",
627 " «bbˇ»",
628 &unindent(
629 "
630 - a
631 + ˇ bb",
632 ),
633 "Clipboard ↔ text.txt @ L1:1-7",
634 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
635 cx,
636 )
637 .await;
638 }
639
640 #[gpui::test]
641 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
642 cx: &mut TestAppContext,
643 ) {
644 base_test(
645 path!("/test"),
646 path!("/test/text.txt"),
647 " a",
648 "« bbˇ»",
649 &unindent(
650 "
651 - a
652 + ˇ bb",
653 ),
654 "Clipboard ↔ text.txt @ L1:1-7",
655 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
656 cx,
657 )
658 .await;
659 }
660
661 #[gpui::test]
662 async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
663 cx: &mut TestAppContext,
664 ) {
665 base_test(
666 path!("/test"),
667 path!("/test/text.txt"),
668 "a",
669 "«bˇ»b",
670 &unindent(
671 "
672 - a
673 + ˇbb",
674 ),
675 "Clipboard ↔ text.txt @ L1:1-3",
676 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
677 cx,
678 )
679 .await;
680 }
681
682 #[gpui::test]
683 async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
684 init_test(cx);
685
686 let fs = FakeFs::new(cx.executor());
687 fs.insert_tree(
688 path!("/project"),
689 json!({
690 "a.txt": "alpha\nbeta\ngamma",
691 "b.txt": "one\ntwo\nthree"
692 }),
693 )
694 .await;
695
696 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
697
698 let buffer_a = project
699 .update(cx, |project, cx| {
700 project.open_local_buffer(path!("/project/a.txt"), cx)
701 })
702 .await
703 .unwrap();
704 let buffer_b = project
705 .update(cx, |project, cx| {
706 project.open_local_buffer(path!("/project/b.txt"), cx)
707 })
708 .await
709 .unwrap();
710
711 let (multi_workspace, cx) =
712 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
713 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
714
715 let editor = cx.new_window_entity(|window, cx| {
716 let multibuffer = cx.new(|cx| {
717 let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
718 mb.set_excerpts_for_path(
719 PathKey::sorted(0),
720 buffer_a.clone(),
721 [Point::new(0, 0)..Point::new(2, 5)],
722 0,
723 cx,
724 );
725 mb.set_excerpts_for_path(
726 PathKey::sorted(1),
727 buffer_b.clone(),
728 [Point::new(0, 0)..Point::new(2, 5)],
729 0,
730 cx,
731 );
732 mb
733 });
734
735 let mut editor =
736 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
737 // Select "beta" inside the first excerpt
738 editor.change_selections(Default::default(), window, cx, |s| {
739 s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
740 });
741 editor
742 });
743
744 let diff_view = workspace
745 .update_in(cx, |workspace, window, cx| {
746 TextDiffView::open(
747 &DiffClipboardWithSelectionData {
748 clipboard_text: "REPLACED".to_string(),
749 editor,
750 },
751 workspace,
752 window,
753 cx,
754 )
755 })
756 .unwrap()
757 .await
758 .unwrap();
759
760 cx.executor().run_until_parked();
761
762 diff_view.read_with(cx, |diff_view, _cx| {
763 assert!(
764 diff_view.title.contains("Clipboard"),
765 "diff view should have opened with a clipboard diff title, got: {}",
766 diff_view.title
767 );
768 });
769 }
770
771 #[gpui::test]
772 async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
773 init_test(cx);
774
775 let fs = FakeFs::new(cx.executor());
776 fs.insert_tree(
777 path!("/project"),
778 json!({
779 "a.txt": "alpha\nbeta\ngamma",
780 "b.txt": "one\ntwo\nthree"
781 }),
782 )
783 .await;
784
785 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
786
787 let buffer_a = project
788 .update(cx, |project, cx| {
789 project.open_local_buffer(path!("/project/a.txt"), cx)
790 })
791 .await
792 .unwrap();
793 let buffer_b = project
794 .update(cx, |project, cx| {
795 project.open_local_buffer(path!("/project/b.txt"), cx)
796 })
797 .await
798 .unwrap();
799
800 let (multi_workspace, cx) =
801 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
802 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
803
804 let editor = cx.new_window_entity(|window, cx| {
805 let multibuffer = cx.new(|cx| {
806 let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
807 mb.set_excerpts_for_path(
808 PathKey::sorted(0),
809 buffer_a.clone(),
810 [Point::new(0, 0)..Point::new(2, 5)],
811 0,
812 cx,
813 );
814 mb.set_excerpts_for_path(
815 PathKey::sorted(1),
816 buffer_b.clone(),
817 [Point::new(0, 0)..Point::new(2, 5)],
818 0,
819 cx,
820 );
821 mb
822 });
823
824 let mut editor =
825 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
826 // Cursor inside the first excerpt (no selection)
827 editor.change_selections(Default::default(), window, cx, |s| {
828 s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
829 });
830 editor
831 });
832
833 let diff_view = workspace
834 .update_in(cx, |workspace, window, cx| {
835 TextDiffView::open(
836 &DiffClipboardWithSelectionData {
837 clipboard_text: "REPLACED".to_string(),
838 editor,
839 },
840 workspace,
841 window,
842 cx,
843 )
844 })
845 .unwrap()
846 .await
847 .unwrap();
848
849 cx.executor().run_until_parked();
850
851 // Empty selection should diff the full underlying buffer
852 diff_view.read_with(cx, |diff_view, _cx| {
853 assert!(
854 diff_view.title.contains("Clipboard"),
855 "diff view should have opened with a clipboard diff title, got: {}",
856 diff_view.title
857 );
858 });
859 }
860
861 async fn base_test(
862 project_root: &str,
863 file_path: &str,
864 clipboard_text: &str,
865 editor_text: &str,
866 expected_diff: &str,
867 expected_tab_title: &str,
868 expected_tab_tooltip: &str,
869 cx: &mut TestAppContext,
870 ) {
871 init_test(cx);
872
873 let file_name = std::path::Path::new(file_path)
874 .file_name()
875 .unwrap()
876 .to_str()
877 .unwrap();
878
879 let fs = FakeFs::new(cx.executor());
880 fs.insert_tree(
881 project_root,
882 json!({
883 file_name: editor_text
884 }),
885 )
886 .await;
887
888 let project = Project::test(fs, [project_root.as_ref()], cx).await;
889
890 let (multi_workspace, cx) =
891 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
892 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
893
894 let buffer = project
895 .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
896 .await
897 .unwrap();
898
899 let editor = cx.new_window_entity(|window, cx| {
900 let mut editor = Editor::for_buffer(buffer, None, window, cx);
901 let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
902 editor.set_text(unmarked_text, window, cx);
903 editor.change_selections(Default::default(), window, cx, |s| {
904 s.select_ranges(
905 selection_ranges
906 .into_iter()
907 .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
908 )
909 });
910
911 editor
912 });
913
914 let diff_view = workspace
915 .update_in(cx, |workspace, window, cx| {
916 TextDiffView::open(
917 &DiffClipboardWithSelectionData {
918 clipboard_text: clipboard_text.to_string(),
919 editor,
920 },
921 workspace,
922 window,
923 cx,
924 )
925 })
926 .unwrap()
927 .await
928 .unwrap();
929
930 cx.executor().run_until_parked();
931
932 assert_state_with_diff(
933 &diff_view.read_with(cx, |diff_view, cx| {
934 diff_view.diff_editor.read(cx).rhs_editor().clone()
935 }),
936 cx,
937 expected_diff,
938 );
939
940 diff_view.read_with(cx, |diff_view, cx| {
941 assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
942 assert_eq!(
943 diff_view.tab_tooltip_text(cx).unwrap(),
944 expected_tab_tooltip
945 );
946 });
947 }
948}
949