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, 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}