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