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, SplittableEditor, ToPoint,
7 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, OffsetRangeExt, Point};
15use project::Project;
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 Zed. 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 diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
114 let clipboard_buffer = build_clipboard_buffer(
115 clipboard_text,
116 &source_buffer,
117 expanded_selection_range.clone(),
118 cx,
119 );
120
121 let task = window.spawn(cx, async move |cx| {
122 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
123
124 workspace.update_in(cx, |workspace, window, cx| {
125 let project = workspace.project().clone();
126 let workspace_entity = cx.entity();
127 let diff_view = cx.new(|cx| {
128 TextDiffView::new(
129 clipboard_buffer,
130 source_editor,
131 source_buffer,
132 expanded_selection_range,
133 diff_buffer,
134 project,
135 workspace_entity,
136 window,
137 cx,
138 )
139 });
140
141 let pane = workspace.active_pane();
142 pane.update(cx, |pane, cx| {
143 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
144 });
145
146 diff_view
147 })
148 });
149
150 Some(task)
151 }
152
153 pub fn new(
154 clipboard_buffer: Entity<Buffer>,
155 source_editor: Entity<Editor>,
156 source_buffer: Entity<Buffer>,
157 source_range: Range<Point>,
158 diff_buffer: Entity<BufferDiff>,
159 project: Entity<Project>,
160 workspace: Entity<Workspace>,
161 window: &mut Window,
162 cx: &mut Context<Self>,
163 ) -> Self {
164 let multibuffer = cx.new(|cx| {
165 let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
166
167 multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
168
169 multibuffer.add_diff(diff_buffer.clone(), cx);
170 multibuffer
171 });
172 let diff_editor = cx.new(|cx| {
173 let splittable = SplittableEditor::new(
174 EditorSettings::get_global(cx).diff_view_style,
175 multibuffer,
176 project,
177 workspace,
178 window,
179 cx,
180 );
181 splittable.disable_diff_hunk_controls(cx);
182 splittable.rhs_editor().update(cx, |editor, _cx| {
183 editor.start_temporary_diff_override();
184 });
185 splittable
186 });
187
188 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
189
190 cx.subscribe(&source_buffer, move |this, _, event, _| match event {
191 language::BufferEvent::Edited { .. }
192 | language::BufferEvent::LanguageChanged(_)
193 | language::BufferEvent::Reparsed => {
194 this.buffer_changes_tx.send(()).ok();
195 }
196 _ => {}
197 })
198 .detach();
199
200 let editor = source_editor.read(cx);
201 let title = editor.buffer().read(cx).title(cx).to_string();
202 let selection_location_text = selection_location_text(editor, cx);
203 let selection_location_title = selection_location_text
204 .as_ref()
205 .map(|text| format!("{} @ {}", title, text))
206 .unwrap_or(title);
207
208 let path = editor
209 .buffer()
210 .read(cx)
211 .as_singleton()
212 .and_then(|b| {
213 b.read(cx)
214 .file()
215 .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
216 })
217 .unwrap_or("untitled".into());
218
219 let selection_location_path = selection_location_text
220 .map(|text| format!("{} @ {}", path, text))
221 .unwrap_or(path);
222
223 Self {
224 diff_editor,
225 title: format!("Clipboard ↔ {selection_location_title}").into(),
226 path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
227 buffer_changes_tx,
228 _recalculate_diff_task: cx.spawn(async move |_, cx| {
229 while buffer_changes_rx.recv().await.is_ok() {
230 loop {
231 let mut timer = cx
232 .background_executor()
233 .timer(RECALCULATE_DIFF_DEBOUNCE)
234 .fuse();
235 let mut recv = pin!(buffer_changes_rx.recv().fuse());
236 select_biased! {
237 _ = timer => break,
238 _ = recv => continue,
239 }
240 }
241
242 log::trace!("start recalculating");
243 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
244 log::trace!("finish recalculating");
245 }
246 Ok(())
247 }),
248 }
249 }
250}
251
252fn build_clipboard_buffer(
253 text: String,
254 source_buffer: &Entity<Buffer>,
255 replacement_range: Range<Point>,
256 cx: &mut App,
257) -> Entity<Buffer> {
258 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
259 cx.new(|cx| {
260 let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
261 let language = source_buffer.read(cx).language().cloned();
262 buffer.set_language(language, cx);
263
264 let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
265 let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
266 buffer.edit([(range_start..range_end, text)], None, cx);
267
268 buffer
269 })
270}
271
272async fn update_diff_buffer(
273 diff: &Entity<BufferDiff>,
274 source_buffer: &Entity<Buffer>,
275 clipboard_buffer: &Entity<Buffer>,
276 cx: &mut AsyncApp,
277) -> Result<()> {
278 let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
279 let language = source_buffer_snapshot.language().cloned();
280 let language_registry = source_buffer.read_with(cx, |buffer, _| buffer.language_registry());
281
282 let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
283 let base_text = base_buffer_snapshot.text();
284
285 let update = diff
286 .update(cx, |diff, cx| {
287 diff.update_diff(
288 source_buffer_snapshot.text.clone(),
289 Some(Arc::from(base_text.as_str())),
290 Some(true),
291 language.clone(),
292 cx,
293 )
294 })
295 .await;
296
297 diff.update(cx, |diff, cx| {
298 diff.language_changed(language, language_registry, cx);
299 diff.set_snapshot(update, &source_buffer_snapshot.text, cx)
300 })
301 .await;
302 Ok(())
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 set_nav_history(
381 &mut self,
382 nav_history: ItemNavHistory,
383 _: &mut Window,
384 cx: &mut Context<Self>,
385 ) {
386 let rhs = self.diff_editor.read(cx).rhs_editor().clone();
387 rhs.update(cx, |editor, _| {
388 editor.set_nav_history(Some(nav_history));
389 });
390 }
391
392 fn navigate(
393 &mut self,
394 data: Arc<dyn Any + Send>,
395 window: &mut Window,
396 cx: &mut Context<Self>,
397 ) -> bool {
398 self.diff_editor
399 .update(cx, |editor, cx| editor.navigate(data, window, cx))
400 }
401
402 fn added_to_workspace(
403 &mut self,
404 workspace: &mut Workspace,
405 window: &mut Window,
406 cx: &mut Context<Self>,
407 ) {
408 self.diff_editor.update(cx, |editor, cx| {
409 editor.added_to_workspace(workspace, window, cx)
410 });
411 }
412
413 fn can_save(&self, cx: &App) -> bool {
414 // The editor handles the new buffer, so delegate to it
415 self.diff_editor.read(cx).can_save(cx)
416 }
417
418 fn save(
419 &mut self,
420 options: SaveOptions,
421 project: Entity<Project>,
422 window: &mut Window,
423 cx: &mut Context<Self>,
424 ) -> Task<Result<()>> {
425 // Delegate saving to the editor, which manages the new buffer
426 self.diff_editor
427 .update(cx, |editor, cx| editor.save(options, project, window, cx))
428 }
429}
430
431pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
432 let buffer = editor.buffer().read(cx);
433 let buffer_snapshot = buffer.snapshot(cx);
434 let first_selection = editor.selections.disjoint_anchors().first()?;
435
436 let selection_start = first_selection.start.to_point(&buffer_snapshot);
437 let selection_end = first_selection.end.to_point(&buffer_snapshot);
438
439 let start_row = selection_start.row;
440 let start_column = selection_start.column;
441 let end_row = selection_end.row;
442 let end_column = selection_end.column;
443
444 let range_text = if start_row == end_row {
445 format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
446 } else {
447 format!(
448 "L{}:{}-L{}:{}",
449 start_row + 1,
450 start_column + 1,
451 end_row + 1,
452 end_column + 1
453 )
454 };
455
456 Some(range_text)
457}
458
459impl Render for TextDiffView {
460 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
461 self.diff_editor.clone()
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
469 use gpui::{BorrowAppContext, TestAppContext, VisualContext};
470 use language::Point;
471 use project::{FakeFs, Project};
472 use serde_json::json;
473 use settings::{DiffViewStyle, SettingsStore};
474 use unindent::unindent;
475 use util::{path, test::marked_text_ranges};
476 use workspace::MultiWorkspace;
477
478 fn init_test(cx: &mut TestAppContext) {
479 cx.update(|cx| {
480 let settings_store = SettingsStore::test(cx);
481 cx.set_global(settings_store);
482 cx.update_global::<SettingsStore, _>(|store, cx| {
483 store.update_user_settings(cx, |settings| {
484 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
485 });
486 });
487 theme_settings::init(theme::LoadThemes::JustBase, cx);
488 });
489 }
490
491 #[gpui::test]
492 async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
493 cx: &mut TestAppContext,
494 ) {
495 base_test(
496 path!("/test"),
497 path!("/test/text.txt"),
498 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
499 "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
500 &unindent(
501 "
502 - def process_incoming_inventory(items, warehouse_id):
503 + ˇdef process_outgoing_inventory(items, warehouse_id):
504 pass
505 ",
506 ),
507 "Clipboard ↔ text.txt @ L1:1-L3:1",
508 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
509 cx,
510 )
511 .await;
512 }
513
514 #[gpui::test]
515 async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
516 cx: &mut TestAppContext,
517 ) {
518 base_test(
519 path!("/test"),
520 path!("/test/text.txt"),
521 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
522 "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
523 &unindent(
524 "
525 - def process_incoming_inventory(items, warehouse_id):
526 + ˇdef process_outgoing_inventory(items, warehouse_id):
527 pass
528 ",
529 ),
530 "Clipboard ↔ text.txt @ L1:1-L3:1",
531 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
532 cx,
533 )
534 .await;
535 }
536
537 #[gpui::test]
538 async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
539 base_test(
540 path!("/test"),
541 path!("/test/text.txt"),
542 "a",
543 "«bbˇ»",
544 &unindent(
545 "
546 - a
547 + ˇbb",
548 ),
549 "Clipboard ↔ text.txt @ L1:1-3",
550 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
551 cx,
552 )
553 .await;
554 }
555
556 #[gpui::test]
557 async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
558 base_test(
559 path!("/test"),
560 path!("/test/text.txt"),
561 " a",
562 "«bbˇ»",
563 &unindent(
564 "
565 - a
566 + ˇbb",
567 ),
568 "Clipboard ↔ text.txt @ L1:1-3",
569 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
570 cx,
571 )
572 .await;
573 }
574
575 #[gpui::test]
576 async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
577 base_test(
578 path!("/test"),
579 path!("/test/text.txt"),
580 "a",
581 " «bbˇ»",
582 &unindent(
583 "
584 - a
585 + ˇ bb",
586 ),
587 "Clipboard ↔ text.txt @ L1:1-7",
588 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
589 cx,
590 )
591 .await;
592 }
593
594 #[gpui::test]
595 async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
596 cx: &mut TestAppContext,
597 ) {
598 base_test(
599 path!("/test"),
600 path!("/test/text.txt"),
601 "a",
602 "« bbˇ»",
603 &unindent(
604 "
605 - a
606 + ˇ bb",
607 ),
608 "Clipboard ↔ text.txt @ L1:1-7",
609 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
610 cx,
611 )
612 .await;
613 }
614
615 #[gpui::test]
616 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
617 cx: &mut TestAppContext,
618 ) {
619 base_test(
620 path!("/test"),
621 path!("/test/text.txt"),
622 " a",
623 " «bbˇ»",
624 &unindent(
625 "
626 - a
627 + ˇ bb",
628 ),
629 "Clipboard ↔ text.txt @ L1:1-7",
630 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
631 cx,
632 )
633 .await;
634 }
635
636 #[gpui::test]
637 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
638 cx: &mut TestAppContext,
639 ) {
640 base_test(
641 path!("/test"),
642 path!("/test/text.txt"),
643 " a",
644 "« bbˇ»",
645 &unindent(
646 "
647 - a
648 + ˇ bb",
649 ),
650 "Clipboard ↔ text.txt @ L1:1-7",
651 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
652 cx,
653 )
654 .await;
655 }
656
657 #[gpui::test]
658 async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
659 cx: &mut TestAppContext,
660 ) {
661 base_test(
662 path!("/test"),
663 path!("/test/text.txt"),
664 "a",
665 "«bˇ»b",
666 &unindent(
667 "
668 - a
669 + ˇbb",
670 ),
671 "Clipboard ↔ text.txt @ L1:1-3",
672 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
673 cx,
674 )
675 .await;
676 }
677
678 #[gpui::test]
679 async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
680 init_test(cx);
681
682 let fs = FakeFs::new(cx.executor());
683 fs.insert_tree(
684 path!("/project"),
685 json!({
686 "a.txt": "alpha\nbeta\ngamma",
687 "b.txt": "one\ntwo\nthree"
688 }),
689 )
690 .await;
691
692 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
693
694 let buffer_a = project
695 .update(cx, |project, cx| {
696 project.open_local_buffer(path!("/project/a.txt"), cx)
697 })
698 .await
699 .unwrap();
700 let buffer_b = project
701 .update(cx, |project, cx| {
702 project.open_local_buffer(path!("/project/b.txt"), cx)
703 })
704 .await
705 .unwrap();
706
707 let (multi_workspace, cx) =
708 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
709 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
710
711 let editor = cx.new_window_entity(|window, cx| {
712 let multibuffer = cx.new(|cx| {
713 let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
714 mb.set_excerpts_for_path(
715 PathKey::sorted(0),
716 buffer_a.clone(),
717 [Point::new(0, 0)..Point::new(2, 5)],
718 0,
719 cx,
720 );
721 mb.set_excerpts_for_path(
722 PathKey::sorted(1),
723 buffer_b.clone(),
724 [Point::new(0, 0)..Point::new(2, 5)],
725 0,
726 cx,
727 );
728 mb
729 });
730
731 let mut editor =
732 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
733 // Select "beta" inside the first excerpt
734 editor.change_selections(Default::default(), window, cx, |s| {
735 s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
736 });
737 editor
738 });
739
740 let diff_view = workspace
741 .update_in(cx, |workspace, window, cx| {
742 TextDiffView::open(
743 &DiffClipboardWithSelectionData {
744 clipboard_text: "REPLACED".to_string(),
745 editor,
746 },
747 workspace,
748 window,
749 cx,
750 )
751 })
752 .unwrap()
753 .await
754 .unwrap();
755
756 cx.executor().run_until_parked();
757
758 diff_view.read_with(cx, |diff_view, _cx| {
759 assert!(
760 diff_view.title.contains("Clipboard"),
761 "diff view should have opened with a clipboard diff title, got: {}",
762 diff_view.title
763 );
764 });
765 }
766
767 #[gpui::test]
768 async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
769 init_test(cx);
770
771 let fs = FakeFs::new(cx.executor());
772 fs.insert_tree(
773 path!("/project"),
774 json!({
775 "a.txt": "alpha\nbeta\ngamma",
776 "b.txt": "one\ntwo\nthree"
777 }),
778 )
779 .await;
780
781 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
782
783 let buffer_a = project
784 .update(cx, |project, cx| {
785 project.open_local_buffer(path!("/project/a.txt"), cx)
786 })
787 .await
788 .unwrap();
789 let buffer_b = project
790 .update(cx, |project, cx| {
791 project.open_local_buffer(path!("/project/b.txt"), cx)
792 })
793 .await
794 .unwrap();
795
796 let (multi_workspace, cx) =
797 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
798 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
799
800 let editor = cx.new_window_entity(|window, cx| {
801 let multibuffer = cx.new(|cx| {
802 let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
803 mb.set_excerpts_for_path(
804 PathKey::sorted(0),
805 buffer_a.clone(),
806 [Point::new(0, 0)..Point::new(2, 5)],
807 0,
808 cx,
809 );
810 mb.set_excerpts_for_path(
811 PathKey::sorted(1),
812 buffer_b.clone(),
813 [Point::new(0, 0)..Point::new(2, 5)],
814 0,
815 cx,
816 );
817 mb
818 });
819
820 let mut editor =
821 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
822 // Cursor inside the first excerpt (no selection)
823 editor.change_selections(Default::default(), window, cx, |s| {
824 s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
825 });
826 editor
827 });
828
829 let diff_view = workspace
830 .update_in(cx, |workspace, window, cx| {
831 TextDiffView::open(
832 &DiffClipboardWithSelectionData {
833 clipboard_text: "REPLACED".to_string(),
834 editor,
835 },
836 workspace,
837 window,
838 cx,
839 )
840 })
841 .unwrap()
842 .await
843 .unwrap();
844
845 cx.executor().run_until_parked();
846
847 // Empty selection should diff the full underlying buffer
848 diff_view.read_with(cx, |diff_view, _cx| {
849 assert!(
850 diff_view.title.contains("Clipboard"),
851 "diff view should have opened with a clipboard diff title, got: {}",
852 diff_view.title
853 );
854 });
855 }
856
857 async fn base_test(
858 project_root: &str,
859 file_path: &str,
860 clipboard_text: &str,
861 editor_text: &str,
862 expected_diff: &str,
863 expected_tab_title: &str,
864 expected_tab_tooltip: &str,
865 cx: &mut TestAppContext,
866 ) {
867 init_test(cx);
868
869 let file_name = std::path::Path::new(file_path)
870 .file_name()
871 .unwrap()
872 .to_str()
873 .unwrap();
874
875 let fs = FakeFs::new(cx.executor());
876 fs.insert_tree(
877 project_root,
878 json!({
879 file_name: editor_text
880 }),
881 )
882 .await;
883
884 let project = Project::test(fs, [project_root.as_ref()], cx).await;
885
886 let (multi_workspace, cx) =
887 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
888 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
889
890 let buffer = project
891 .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
892 .await
893 .unwrap();
894
895 let editor = cx.new_window_entity(|window, cx| {
896 let mut editor = Editor::for_buffer(buffer, None, window, cx);
897 let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
898 editor.set_text(unmarked_text, window, cx);
899 editor.change_selections(Default::default(), window, cx, |s| {
900 s.select_ranges(
901 selection_ranges
902 .into_iter()
903 .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
904 )
905 });
906
907 editor
908 });
909
910 let diff_view = workspace
911 .update_in(cx, |workspace, window, cx| {
912 TextDiffView::open(
913 &DiffClipboardWithSelectionData {
914 clipboard_text: clipboard_text.to_string(),
915 editor,
916 },
917 workspace,
918 window,
919 cx,
920 )
921 })
922 .unwrap()
923 .await
924 .unwrap();
925
926 cx.executor().run_until_parked();
927
928 assert_state_with_diff(
929 &diff_view.read_with(cx, |diff_view, cx| {
930 diff_view.diff_editor.read(cx).rhs_editor().clone()
931 }),
932 cx,
933 expected_diff,
934 );
935
936 diff_view.read_with(cx, |diff_view, cx| {
937 assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
938 assert_eq!(
939 diff_view.tab_tooltip_text(cx).unwrap(),
940 expected_tab_tooltip
941 );
942 });
943 }
944}