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