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