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::{Editor, EditorEvent, MultiBuffer, ToPoint, actions::DiffClipboardWithSelectionData};
  6use futures::{FutureExt, select_biased};
  7use gpui::{
  8    AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
  9    Focusable, IntoElement, Render, Task, Window,
 10};
 11use language::{self, Buffer, Point};
 12use project::Project;
 13use std::{
 14    any::{Any, TypeId},
 15    cmp,
 16    ops::Range,
 17    pin::pin,
 18    sync::Arc,
 19    time::Duration,
 20};
 21use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
 22use util::paths::PathExt;
 23
 24use workspace::{
 25    Item, ItemHandle as _, ItemNavHistory, Workspace,
 26    item::{ItemEvent, SaveOptions, TabContentParams},
 27    searchable::SearchableItemHandle,
 28};
 29
 30pub struct TextDiffView {
 31    diff_editor: Entity<Editor>,
 32    title: SharedString,
 33    path: Option<SharedString>,
 34    buffer_changes_tx: watch::Sender<()>,
 35    _recalculate_diff_task: Task<Result<()>>,
 36}
 37
 38const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
 39
 40impl TextDiffView {
 41    pub fn open(
 42        diff_data: &DiffClipboardWithSelectionData,
 43        workspace: &Workspace,
 44        window: &mut Window,
 45        cx: &mut App,
 46    ) -> Option<Task<Result<Entity<Self>>>> {
 47        let source_editor = diff_data.editor.clone();
 48
 49        let selection_data = source_editor.update(cx, |editor, cx| {
 50            let multibuffer = editor.buffer();
 51            let selections = editor.selections.all::<Point>(&editor.display_snapshot(cx));
 52            let first_selection = selections.first()?;
 53
 54            let (source_buffer, buffer_start, start_excerpt) = multibuffer
 55                .read(cx)
 56                .point_to_buffer_point(first_selection.start, cx)?;
 57            let buffer_end = multibuffer
 58                .read(cx)
 59                .point_to_buffer_point(first_selection.end, cx)
 60                .and_then(|(buf, pt, end_excerpt)| {
 61                    (buf.read(cx).remote_id() == source_buffer.read(cx).remote_id()
 62                        && end_excerpt == start_excerpt)
 63                        .then_some(pt)
 64                })
 65                .unwrap_or(buffer_start);
 66
 67            let buffer_snapshot = source_buffer.read(cx);
 68            let max_point = buffer_snapshot.max_point();
 69
 70            if first_selection.is_empty() {
 71                let full_range = Point::new(0, 0)..max_point;
 72                return Some((source_buffer, full_range));
 73            }
 74
 75            let expanded_start = Point::new(buffer_start.row, 0);
 76            let expanded_end = if buffer_end.column > 0 {
 77                let next_row = buffer_end.row + 1;
 78                cmp::min(max_point, Point::new(next_row, 0))
 79            } else {
 80                buffer_end
 81            };
 82            Some((source_buffer, expanded_start..expanded_end))
 83        });
 84
 85        let Some((source_buffer, expanded_selection_range)) = selection_data else {
 86            log::warn!("There should always be at least one selection in Zed. This is a bug.");
 87            return None;
 88        };
 89
 90        source_editor.update(cx, |source_editor, cx| {
 91            let multibuffer = source_editor.buffer();
 92            let mb_range = {
 93                let mb = multibuffer.read(cx);
 94                let start_anchor =
 95                    mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
 96                let end_anchor =
 97                    mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
 98                start_anchor.zip(end_anchor).map(|(s, e)| {
 99                    let snapshot = mb.snapshot(cx);
100                    s.to_point(&snapshot)..e.to_point(&snapshot)
101                })
102            };
103
104            if let Some(range) = mb_range {
105                source_editor.change_selections(Default::default(), window, cx, |s| {
106                    s.select_ranges(vec![range]);
107                });
108            }
109        });
110
111        let source_buffer_snapshot = source_buffer.read(cx).snapshot();
112        let mut clipboard_text = diff_data.clipboard_text.clone();
113
114        if !clipboard_text.ends_with("\n") {
115            clipboard_text.push_str("\n");
116        }
117
118        let workspace = workspace.weak_handle();
119        let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
120        let clipboard_buffer = build_clipboard_buffer(
121            clipboard_text,
122            &source_buffer,
123            expanded_selection_range.clone(),
124            cx,
125        );
126
127        let task = window.spawn(cx, async move |cx| {
128            let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
129
130            update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
131
132            workspace.update_in(cx, |workspace, window, cx| {
133                let diff_view = cx.new(|cx| {
134                    TextDiffView::new(
135                        clipboard_buffer,
136                        source_editor,
137                        source_buffer,
138                        expanded_selection_range,
139                        diff_buffer,
140                        project,
141                        window,
142                        cx,
143                    )
144                });
145
146                let pane = workspace.active_pane();
147                pane.update(cx, |pane, cx| {
148                    pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
149                });
150
151                diff_view
152            })
153        });
154
155        Some(task)
156    }
157
158    pub fn new(
159        clipboard_buffer: Entity<Buffer>,
160        source_editor: Entity<Editor>,
161        source_buffer: Entity<Buffer>,
162        source_range: Range<Point>,
163        diff_buffer: Entity<BufferDiff>,
164        project: Entity<Project>,
165        window: &mut Window,
166        cx: &mut Context<Self>,
167    ) -> Self {
168        let multibuffer = cx.new(|cx| {
169            let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
170
171            multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
172
173            multibuffer.add_diff(diff_buffer.clone(), cx);
174            multibuffer
175        });
176        let diff_editor = cx.new(|cx| {
177            let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx);
178            editor.start_temporary_diff_override();
179            editor.disable_diagnostics(cx);
180            editor.set_expand_all_diff_hunks(cx);
181            editor.set_render_diff_hunk_controls(
182                Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
183                cx,
184            );
185            editor
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        _: &'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::<Editor>() {
360            Some(self.diff_editor.clone().into())
361        } else {
362            None
363        }
364    }
365
366    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
367        Some(Box::new(self.diff_editor.clone()))
368    }
369
370    fn for_each_project_item(
371        &self,
372        cx: &App,
373        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
374    ) {
375        self.diff_editor.for_each_project_item(cx, f)
376    }
377
378    fn set_nav_history(
379        &mut self,
380        nav_history: ItemNavHistory,
381        _: &mut Window,
382        cx: &mut Context<Self>,
383    ) {
384        self.diff_editor.update(cx, |editor, _| {
385            editor.set_nav_history(Some(nav_history));
386        });
387    }
388
389    fn navigate(
390        &mut self,
391        data: Arc<dyn Any + Send>,
392        window: &mut Window,
393        cx: &mut Context<Self>,
394    ) -> bool {
395        self.diff_editor
396            .update(cx, |editor, cx| editor.navigate(data, window, cx))
397    }
398
399    fn added_to_workspace(
400        &mut self,
401        workspace: &mut Workspace,
402        window: &mut Window,
403        cx: &mut Context<Self>,
404    ) {
405        self.diff_editor.update(cx, |editor, cx| {
406            editor.added_to_workspace(workspace, window, cx)
407        });
408    }
409
410    fn can_save(&self, cx: &App) -> bool {
411        // The editor handles the new buffer, so delegate to it
412        self.diff_editor.read(cx).can_save(cx)
413    }
414
415    fn save(
416        &mut self,
417        options: SaveOptions,
418        project: Entity<Project>,
419        window: &mut Window,
420        cx: &mut Context<Self>,
421    ) -> Task<Result<()>> {
422        // Delegate saving to the editor, which manages the new buffer
423        self.diff_editor
424            .update(cx, |editor, cx| editor.save(options, project, window, cx))
425    }
426}
427
428pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
429    let buffer = editor.buffer().read(cx);
430    let buffer_snapshot = buffer.snapshot(cx);
431    let first_selection = editor.selections.disjoint_anchors().first()?;
432
433    let selection_start = first_selection.start.to_point(&buffer_snapshot);
434    let selection_end = first_selection.end.to_point(&buffer_snapshot);
435
436    let start_row = selection_start.row;
437    let start_column = selection_start.column;
438    let end_row = selection_end.row;
439    let end_column = selection_end.column;
440
441    let range_text = if start_row == end_row {
442        format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
443    } else {
444        format!(
445            "L{}:{}-L{}:{}",
446            start_row + 1,
447            start_column + 1,
448            end_row + 1,
449            end_column + 1
450        )
451    };
452
453    Some(range_text)
454}
455
456impl Render for TextDiffView {
457    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
458        self.diff_editor.clone()
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
466    use gpui::{TestAppContext, VisualContext};
467    use language::Point;
468    use project::{FakeFs, Project};
469    use serde_json::json;
470    use settings::SettingsStore;
471    use unindent::unindent;
472    use util::{path, test::marked_text_ranges};
473    use workspace::MultiWorkspace;
474
475    fn init_test(cx: &mut TestAppContext) {
476        cx.update(|cx| {
477            let settings_store = SettingsStore::test(cx);
478            cx.set_global(settings_store);
479            theme::init(theme::LoadThemes::JustBase, cx);
480        });
481    }
482
483    #[gpui::test]
484    async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
485        cx: &mut TestAppContext,
486    ) {
487        base_test(
488            path!("/test"),
489            path!("/test/text.txt"),
490            "def process_incoming_inventory(items, warehouse_id):\n    pass\n",
491            "def process_outgoing_inventory(items, warehouse_id):\n    passˇ\n",
492            &unindent(
493                "
494                - def process_incoming_inventory(items, warehouse_id):
495                + ˇdef process_outgoing_inventory(items, warehouse_id):
496                      pass
497                ",
498            ),
499            "Clipboard ↔ text.txt @ L1:1-L3:1",
500            &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
501            cx,
502        )
503        .await;
504    }
505
506    #[gpui::test]
507    async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
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_single_line_selection(cx: &mut TestAppContext) {
531        base_test(
532            path!("/test"),
533            path!("/test/text.txt"),
534            "a",
535            "«bbˇ»",
536            &unindent(
537                "
538                - a
539                + ˇbb",
540            ),
541            "Clipboard ↔ text.txt @ L1:1-3",
542            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
543            cx,
544        )
545        .await;
546    }
547
548    #[gpui::test]
549    async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
550        base_test(
551            path!("/test"),
552            path!("/test/text.txt"),
553            "    a",
554            "«bbˇ»",
555            &unindent(
556                "
557                -     a
558                + ˇbb",
559            ),
560            "Clipboard ↔ text.txt @ L1:1-3",
561            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
562            cx,
563        )
564        .await;
565    }
566
567    #[gpui::test]
568    async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
569        base_test(
570            path!("/test"),
571            path!("/test/text.txt"),
572            "a",
573            "    «bbˇ»",
574            &unindent(
575                "
576                - a
577                + ˇ    bb",
578            ),
579            "Clipboard ↔ text.txt @ L1:1-7",
580            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
581            cx,
582        )
583        .await;
584    }
585
586    #[gpui::test]
587    async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
588        cx: &mut TestAppContext,
589    ) {
590        base_test(
591            path!("/test"),
592            path!("/test/text.txt"),
593            "a",
594            "«    bbˇ»",
595            &unindent(
596                "
597                - a
598                + ˇ    bb",
599            ),
600            "Clipboard ↔ text.txt @ L1:1-7",
601            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
602            cx,
603        )
604        .await;
605    }
606
607    #[gpui::test]
608    async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
609        cx: &mut TestAppContext,
610    ) {
611        base_test(
612            path!("/test"),
613            path!("/test/text.txt"),
614            "    a",
615            "    «bbˇ»",
616            &unindent(
617                "
618                -     a
619                + ˇ    bb",
620            ),
621            "Clipboard ↔ text.txt @ L1:1-7",
622            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
623            cx,
624        )
625        .await;
626    }
627
628    #[gpui::test]
629    async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
630        cx: &mut TestAppContext,
631    ) {
632        base_test(
633            path!("/test"),
634            path!("/test/text.txt"),
635            "    a",
636            "«    bbˇ»",
637            &unindent(
638                "
639                -     a
640                + ˇ    bb",
641            ),
642            "Clipboard ↔ text.txt @ L1:1-7",
643            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
644            cx,
645        )
646        .await;
647    }
648
649    #[gpui::test]
650    async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
651        cx: &mut TestAppContext,
652    ) {
653        base_test(
654            path!("/test"),
655            path!("/test/text.txt"),
656            "a",
657            "«bˇ»b",
658            &unindent(
659                "
660                - a
661                + ˇbb",
662            ),
663            "Clipboard ↔ text.txt @ L1:1-3",
664            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
665            cx,
666        )
667        .await;
668    }
669
670    #[gpui::test]
671    async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
672        init_test(cx);
673
674        let fs = FakeFs::new(cx.executor());
675        fs.insert_tree(
676            path!("/project"),
677            json!({
678                "a.txt": "alpha\nbeta\ngamma",
679                "b.txt": "one\ntwo\nthree"
680            }),
681        )
682        .await;
683
684        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
685
686        let buffer_a = project
687            .update(cx, |project, cx| {
688                project.open_local_buffer(path!("/project/a.txt"), cx)
689            })
690            .await
691            .unwrap();
692        let buffer_b = project
693            .update(cx, |project, cx| {
694                project.open_local_buffer(path!("/project/b.txt"), cx)
695            })
696            .await
697            .unwrap();
698
699        let (multi_workspace, cx) =
700            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
701        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
702
703        let editor = cx.new_window_entity(|window, cx| {
704            let multibuffer = cx.new(|cx| {
705                let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
706                mb.set_excerpts_for_path(
707                    PathKey::sorted(0),
708                    buffer_a.clone(),
709                    [Point::new(0, 0)..Point::new(2, 5)],
710                    0,
711                    cx,
712                );
713                mb.set_excerpts_for_path(
714                    PathKey::sorted(1),
715                    buffer_b.clone(),
716                    [Point::new(0, 0)..Point::new(2, 5)],
717                    0,
718                    cx,
719                );
720                mb
721            });
722
723            let mut editor =
724                Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
725            // Select "beta" inside the first excerpt
726            editor.change_selections(Default::default(), window, cx, |s| {
727                s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
728            });
729            editor
730        });
731
732        let diff_view = workspace
733            .update_in(cx, |workspace, window, cx| {
734                TextDiffView::open(
735                    &DiffClipboardWithSelectionData {
736                        clipboard_text: "REPLACED".to_string(),
737                        editor,
738                    },
739                    workspace,
740                    window,
741                    cx,
742                )
743            })
744            .unwrap()
745            .await
746            .unwrap();
747
748        cx.executor().run_until_parked();
749
750        diff_view.read_with(cx, |diff_view, _cx| {
751            assert!(
752                diff_view.title.contains("Clipboard"),
753                "diff view should have opened with a clipboard diff title, got: {}",
754                diff_view.title
755            );
756        });
757    }
758
759    #[gpui::test]
760    async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
761        init_test(cx);
762
763        let fs = FakeFs::new(cx.executor());
764        fs.insert_tree(
765            path!("/project"),
766            json!({
767                "a.txt": "alpha\nbeta\ngamma",
768                "b.txt": "one\ntwo\nthree"
769            }),
770        )
771        .await;
772
773        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
774
775        let buffer_a = project
776            .update(cx, |project, cx| {
777                project.open_local_buffer(path!("/project/a.txt"), cx)
778            })
779            .await
780            .unwrap();
781        let buffer_b = project
782            .update(cx, |project, cx| {
783                project.open_local_buffer(path!("/project/b.txt"), cx)
784            })
785            .await
786            .unwrap();
787
788        let (multi_workspace, cx) =
789            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
790        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
791
792        let editor = cx.new_window_entity(|window, cx| {
793            let multibuffer = cx.new(|cx| {
794                let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
795                mb.set_excerpts_for_path(
796                    PathKey::sorted(0),
797                    buffer_a.clone(),
798                    [Point::new(0, 0)..Point::new(2, 5)],
799                    0,
800                    cx,
801                );
802                mb.set_excerpts_for_path(
803                    PathKey::sorted(1),
804                    buffer_b.clone(),
805                    [Point::new(0, 0)..Point::new(2, 5)],
806                    0,
807                    cx,
808                );
809                mb
810            });
811
812            let mut editor =
813                Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
814            // Cursor inside the first excerpt (no selection)
815            editor.change_selections(Default::default(), window, cx, |s| {
816                s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
817            });
818            editor
819        });
820
821        let diff_view = workspace
822            .update_in(cx, |workspace, window, cx| {
823                TextDiffView::open(
824                    &DiffClipboardWithSelectionData {
825                        clipboard_text: "REPLACED".to_string(),
826                        editor,
827                    },
828                    workspace,
829                    window,
830                    cx,
831                )
832            })
833            .unwrap()
834            .await
835            .unwrap();
836
837        cx.executor().run_until_parked();
838
839        // Empty selection should diff the full underlying buffer
840        diff_view.read_with(cx, |diff_view, _cx| {
841            assert!(
842                diff_view.title.contains("Clipboard"),
843                "diff view should have opened with a clipboard diff title, got: {}",
844                diff_view.title
845            );
846        });
847    }
848
849    async fn base_test(
850        project_root: &str,
851        file_path: &str,
852        clipboard_text: &str,
853        editor_text: &str,
854        expected_diff: &str,
855        expected_tab_title: &str,
856        expected_tab_tooltip: &str,
857        cx: &mut TestAppContext,
858    ) {
859        init_test(cx);
860
861        let file_name = std::path::Path::new(file_path)
862            .file_name()
863            .unwrap()
864            .to_str()
865            .unwrap();
866
867        let fs = FakeFs::new(cx.executor());
868        fs.insert_tree(
869            project_root,
870            json!({
871                file_name: editor_text
872            }),
873        )
874        .await;
875
876        let project = Project::test(fs, [project_root.as_ref()], cx).await;
877
878        let (multi_workspace, cx) =
879            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
880        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
881
882        let buffer = project
883            .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
884            .await
885            .unwrap();
886
887        let editor = cx.new_window_entity(|window, cx| {
888            let mut editor = Editor::for_buffer(buffer, None, window, cx);
889            let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
890            editor.set_text(unmarked_text, window, cx);
891            editor.change_selections(Default::default(), window, cx, |s| {
892                s.select_ranges(
893                    selection_ranges
894                        .into_iter()
895                        .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
896                )
897            });
898
899            editor
900        });
901
902        let diff_view = workspace
903            .update_in(cx, |workspace, window, cx| {
904                TextDiffView::open(
905                    &DiffClipboardWithSelectionData {
906                        clipboard_text: clipboard_text.to_string(),
907                        editor,
908                    },
909                    workspace,
910                    window,
911                    cx,
912                )
913            })
914            .unwrap()
915            .await
916            .unwrap();
917
918        cx.executor().run_until_parked();
919
920        assert_state_with_diff(
921            &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
922            cx,
923            expected_diff,
924        );
925
926        diff_view.read_with(cx, |diff_view, cx| {
927            assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
928            assert_eq!(
929                diff_view.tab_tooltip_text(cx).unwrap(),
930                expected_tab_tooltip
931            );
932        });
933    }
934}