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().read(cx);
 51            let source_buffer = multibuffer.as_singleton()?;
 52            let selections = editor.selections.all::<Point>(&editor.display_snapshot(cx));
 53            let buffer_snapshot = source_buffer.read(cx);
 54            let first_selection = selections.first()?;
 55            let max_point = buffer_snapshot.max_point();
 56
 57            if first_selection.is_empty() {
 58                let full_range = Point::new(0, 0)..max_point;
 59                return Some((source_buffer, full_range));
 60            }
 61
 62            let start = first_selection.start;
 63            let end = first_selection.end;
 64            let expanded_start = Point::new(start.row, 0);
 65
 66            let expanded_end = if end.column > 0 {
 67                let next_row = end.row + 1;
 68                cmp::min(max_point, Point::new(next_row, 0))
 69            } else {
 70                end
 71            };
 72            Some((source_buffer, expanded_start..expanded_end))
 73        });
 74
 75        let Some((source_buffer, expanded_selection_range)) = selection_data else {
 76            log::warn!("There should always be at least one selection in Zed. This is a bug.");
 77            return None;
 78        };
 79
 80        source_editor.update(cx, |source_editor, cx| {
 81            source_editor.change_selections(Default::default(), window, cx, |s| {
 82                s.select_ranges(vec![
 83                    expanded_selection_range.start..expanded_selection_range.end,
 84                ]);
 85            })
 86        });
 87
 88        let source_buffer_snapshot = source_buffer.read(cx).snapshot();
 89        let mut clipboard_text = diff_data.clipboard_text.clone();
 90
 91        if !clipboard_text.ends_with("\n") {
 92            clipboard_text.push_str("\n");
 93        }
 94
 95        let workspace = workspace.weak_handle();
 96        let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
 97        let clipboard_buffer = build_clipboard_buffer(
 98            clipboard_text,
 99            &source_buffer,
100            expanded_selection_range.clone(),
101            cx,
102        );
103
104        let task = window.spawn(cx, async move |cx| {
105            let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
106
107            update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
108
109            workspace.update_in(cx, |workspace, window, cx| {
110                let diff_view = cx.new(|cx| {
111                    TextDiffView::new(
112                        clipboard_buffer,
113                        source_editor,
114                        source_buffer,
115                        expanded_selection_range,
116                        diff_buffer,
117                        project,
118                        window,
119                        cx,
120                    )
121                });
122
123                let pane = workspace.active_pane();
124                pane.update(cx, |pane, cx| {
125                    pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
126                });
127
128                diff_view
129            })
130        });
131
132        Some(task)
133    }
134
135    pub fn new(
136        clipboard_buffer: Entity<Buffer>,
137        source_editor: Entity<Editor>,
138        source_buffer: Entity<Buffer>,
139        source_range: Range<Point>,
140        diff_buffer: Entity<BufferDiff>,
141        project: Entity<Project>,
142        window: &mut Window,
143        cx: &mut Context<Self>,
144    ) -> Self {
145        let multibuffer = cx.new(|cx| {
146            let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
147
148            multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
149
150            multibuffer.add_diff(diff_buffer.clone(), cx);
151            multibuffer
152        });
153        let diff_editor = cx.new(|cx| {
154            let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx);
155            editor.start_temporary_diff_override();
156            editor.disable_diagnostics(cx);
157            editor.set_expand_all_diff_hunks(cx);
158            editor.set_render_diff_hunk_controls(
159                Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
160                cx,
161            );
162            editor
163        });
164
165        let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
166
167        cx.subscribe(&source_buffer, move |this, _, event, _| match event {
168            language::BufferEvent::Edited
169            | language::BufferEvent::LanguageChanged(_)
170            | language::BufferEvent::Reparsed => {
171                this.buffer_changes_tx.send(()).ok();
172            }
173            _ => {}
174        })
175        .detach();
176
177        let editor = source_editor.read(cx);
178        let title = editor.buffer().read(cx).title(cx).to_string();
179        let selection_location_text = selection_location_text(editor, cx);
180        let selection_location_title = selection_location_text
181            .as_ref()
182            .map(|text| format!("{} @ {}", title, text))
183            .unwrap_or(title);
184
185        let path = editor
186            .buffer()
187            .read(cx)
188            .as_singleton()
189            .and_then(|b| {
190                b.read(cx)
191                    .file()
192                    .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
193            })
194            .unwrap_or("untitled".into());
195
196        let selection_location_path = selection_location_text
197            .map(|text| format!("{} @ {}", path, text))
198            .unwrap_or(path);
199
200        Self {
201            diff_editor,
202            title: format!("Clipboard ↔ {selection_location_title}").into(),
203            path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
204            buffer_changes_tx,
205            _recalculate_diff_task: cx.spawn(async move |_, cx| {
206                while buffer_changes_rx.recv().await.is_ok() {
207                    loop {
208                        let mut timer = cx
209                            .background_executor()
210                            .timer(RECALCULATE_DIFF_DEBOUNCE)
211                            .fuse();
212                        let mut recv = pin!(buffer_changes_rx.recv().fuse());
213                        select_biased! {
214                            _ = timer => break,
215                            _ = recv => continue,
216                        }
217                    }
218
219                    log::trace!("start recalculating");
220                    update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
221                    log::trace!("finish recalculating");
222                }
223                Ok(())
224            }),
225        }
226    }
227}
228
229fn build_clipboard_buffer(
230    text: String,
231    source_buffer: &Entity<Buffer>,
232    replacement_range: Range<Point>,
233    cx: &mut App,
234) -> Entity<Buffer> {
235    let source_buffer_snapshot = source_buffer.read(cx).snapshot();
236    cx.new(|cx| {
237        let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
238        let language = source_buffer.read(cx).language().cloned();
239        buffer.set_language(language, cx);
240
241        let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
242        let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
243        buffer.edit([(range_start..range_end, text)], None, cx);
244
245        buffer
246    })
247}
248
249async fn update_diff_buffer(
250    diff: &Entity<BufferDiff>,
251    source_buffer: &Entity<Buffer>,
252    clipboard_buffer: &Entity<Buffer>,
253    cx: &mut AsyncApp,
254) -> Result<()> {
255    let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
256    let language = source_buffer_snapshot.language().cloned();
257    let language_registry = source_buffer.read_with(cx, |buffer, _| buffer.language_registry());
258
259    let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
260    let base_text = base_buffer_snapshot.text();
261
262    let update = diff
263        .update(cx, |diff, cx| {
264            diff.update_diff(
265                source_buffer_snapshot.text.clone(),
266                Some(Arc::from(base_text.as_str())),
267                Some(true),
268                language.clone(),
269                cx,
270            )
271        })
272        .await;
273
274    diff.update(cx, |diff, cx| {
275        diff.language_changed(language, language_registry, cx);
276        diff.set_snapshot(update, &source_buffer_snapshot.text, cx)
277    })
278    .await;
279    Ok(())
280}
281
282impl EventEmitter<EditorEvent> for TextDiffView {}
283
284impl Focusable for TextDiffView {
285    fn focus_handle(&self, cx: &App) -> FocusHandle {
286        self.diff_editor.focus_handle(cx)
287    }
288}
289
290impl Item for TextDiffView {
291    type Event = EditorEvent;
292
293    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
294        Some(Icon::new(IconName::Diff).color(Color::Muted))
295    }
296
297    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
298        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
299            .color(if params.selected {
300                Color::Default
301            } else {
302                Color::Muted
303            })
304            .into_any_element()
305    }
306
307    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
308        self.title.clone()
309    }
310
311    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
312        self.path.clone()
313    }
314
315    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
316        Editor::to_item_events(event, f)
317    }
318
319    fn telemetry_event_text(&self) -> Option<&'static str> {
320        Some("Selection Diff View Opened")
321    }
322
323    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
324        self.diff_editor
325            .update(cx, |editor, cx| editor.deactivated(window, cx));
326    }
327
328    fn act_as_type<'a>(
329        &'a self,
330        type_id: TypeId,
331        self_handle: &'a Entity<Self>,
332        _: &'a App,
333    ) -> Option<gpui::AnyEntity> {
334        if type_id == TypeId::of::<Self>() {
335            Some(self_handle.clone().into())
336        } else if type_id == TypeId::of::<Editor>() {
337            Some(self.diff_editor.clone().into())
338        } else {
339            None
340        }
341    }
342
343    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
344        Some(Box::new(self.diff_editor.clone()))
345    }
346
347    fn for_each_project_item(
348        &self,
349        cx: &App,
350        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
351    ) {
352        self.diff_editor.for_each_project_item(cx, f)
353    }
354
355    fn set_nav_history(
356        &mut self,
357        nav_history: ItemNavHistory,
358        _: &mut Window,
359        cx: &mut Context<Self>,
360    ) {
361        self.diff_editor.update(cx, |editor, _| {
362            editor.set_nav_history(Some(nav_history));
363        });
364    }
365
366    fn navigate(
367        &mut self,
368        data: Arc<dyn Any + Send>,
369        window: &mut Window,
370        cx: &mut Context<Self>,
371    ) -> bool {
372        self.diff_editor
373            .update(cx, |editor, cx| editor.navigate(data, window, cx))
374    }
375
376    fn added_to_workspace(
377        &mut self,
378        workspace: &mut Workspace,
379        window: &mut Window,
380        cx: &mut Context<Self>,
381    ) {
382        self.diff_editor.update(cx, |editor, cx| {
383            editor.added_to_workspace(workspace, window, cx)
384        });
385    }
386
387    fn can_save(&self, cx: &App) -> bool {
388        // The editor handles the new buffer, so delegate to it
389        self.diff_editor.read(cx).can_save(cx)
390    }
391
392    fn save(
393        &mut self,
394        options: SaveOptions,
395        project: Entity<Project>,
396        window: &mut Window,
397        cx: &mut Context<Self>,
398    ) -> Task<Result<()>> {
399        // Delegate saving to the editor, which manages the new buffer
400        self.diff_editor
401            .update(cx, |editor, cx| editor.save(options, project, window, cx))
402    }
403}
404
405pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
406    let buffer = editor.buffer().read(cx);
407    let buffer_snapshot = buffer.snapshot(cx);
408    let first_selection = editor.selections.disjoint_anchors().first()?;
409
410    let selection_start = first_selection.start.to_point(&buffer_snapshot);
411    let selection_end = first_selection.end.to_point(&buffer_snapshot);
412
413    let start_row = selection_start.row;
414    let start_column = selection_start.column;
415    let end_row = selection_end.row;
416    let end_column = selection_end.column;
417
418    let range_text = if start_row == end_row {
419        format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
420    } else {
421        format!(
422            "L{}:{}-L{}:{}",
423            start_row + 1,
424            start_column + 1,
425            end_row + 1,
426            end_column + 1
427        )
428    };
429
430    Some(range_text)
431}
432
433impl Render for TextDiffView {
434    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
435        self.diff_editor.clone()
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use editor::{MultiBufferOffset, test::editor_test_context::assert_state_with_diff};
443    use gpui::{TestAppContext, VisualContext};
444    use project::{FakeFs, Project};
445    use serde_json::json;
446    use settings::SettingsStore;
447    use unindent::unindent;
448    use util::{path, test::marked_text_ranges};
449    use workspace::MultiWorkspace;
450
451    fn init_test(cx: &mut TestAppContext) {
452        cx.update(|cx| {
453            let settings_store = SettingsStore::test(cx);
454            cx.set_global(settings_store);
455            theme::init(theme::LoadThemes::JustBase, cx);
456        });
457    }
458
459    #[gpui::test]
460    async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
461        cx: &mut TestAppContext,
462    ) {
463        base_test(
464            path!("/test"),
465            path!("/test/text.txt"),
466            "def process_incoming_inventory(items, warehouse_id):\n    pass\n",
467            "def process_outgoing_inventory(items, warehouse_id):\n    passˇ\n",
468            &unindent(
469                "
470                - def process_incoming_inventory(items, warehouse_id):
471                + ˇdef process_outgoing_inventory(items, warehouse_id):
472                      pass
473                ",
474            ),
475            "Clipboard ↔ text.txt @ L1:1-L3:1",
476            &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
477            cx,
478        )
479        .await;
480    }
481
482    #[gpui::test]
483    async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
484        cx: &mut TestAppContext,
485    ) {
486        base_test(
487            path!("/test"),
488            path!("/test/text.txt"),
489            "def process_incoming_inventory(items, warehouse_id):\n    pass\n",
490            "«def process_outgoing_inventory(items, warehouse_id):\n    passˇ»\n",
491            &unindent(
492                "
493                - def process_incoming_inventory(items, warehouse_id):
494                + ˇdef process_outgoing_inventory(items, warehouse_id):
495                      pass
496                ",
497            ),
498            "Clipboard ↔ text.txt @ L1:1-L3:1",
499            &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
500            cx,
501        )
502        .await;
503    }
504
505    #[gpui::test]
506    async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
507        base_test(
508            path!("/test"),
509            path!("/test/text.txt"),
510            "a",
511            "«bbˇ»",
512            &unindent(
513                "
514                - a
515                + ˇbb",
516            ),
517            "Clipboard ↔ text.txt @ L1:1-3",
518            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
519            cx,
520        )
521        .await;
522    }
523
524    #[gpui::test]
525    async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
526        base_test(
527            path!("/test"),
528            path!("/test/text.txt"),
529            "    a",
530            "«bbˇ»",
531            &unindent(
532                "
533                -     a
534                + ˇbb",
535            ),
536            "Clipboard ↔ text.txt @ L1:1-3",
537            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
538            cx,
539        )
540        .await;
541    }
542
543    #[gpui::test]
544    async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
545        base_test(
546            path!("/test"),
547            path!("/test/text.txt"),
548            "a",
549            "    «bbˇ»",
550            &unindent(
551                "
552                - a
553                + ˇ    bb",
554            ),
555            "Clipboard ↔ text.txt @ L1:1-7",
556            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
557            cx,
558        )
559        .await;
560    }
561
562    #[gpui::test]
563    async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
564        cx: &mut TestAppContext,
565    ) {
566        base_test(
567            path!("/test"),
568            path!("/test/text.txt"),
569            "a",
570            "«    bbˇ»",
571            &unindent(
572                "
573                - a
574                + ˇ    bb",
575            ),
576            "Clipboard ↔ text.txt @ L1:1-7",
577            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
578            cx,
579        )
580        .await;
581    }
582
583    #[gpui::test]
584    async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
585        cx: &mut TestAppContext,
586    ) {
587        base_test(
588            path!("/test"),
589            path!("/test/text.txt"),
590            "    a",
591            "    «bbˇ»",
592            &unindent(
593                "
594                -     a
595                + ˇ    bb",
596            ),
597            "Clipboard ↔ text.txt @ L1:1-7",
598            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
599            cx,
600        )
601        .await;
602    }
603
604    #[gpui::test]
605    async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
606        cx: &mut TestAppContext,
607    ) {
608        base_test(
609            path!("/test"),
610            path!("/test/text.txt"),
611            "    a",
612            "«    bbˇ»",
613            &unindent(
614                "
615                -     a
616                + ˇ    bb",
617            ),
618            "Clipboard ↔ text.txt @ L1:1-7",
619            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
620            cx,
621        )
622        .await;
623    }
624
625    #[gpui::test]
626    async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
627        cx: &mut TestAppContext,
628    ) {
629        base_test(
630            path!("/test"),
631            path!("/test/text.txt"),
632            "a",
633            "«bˇ»b",
634            &unindent(
635                "
636                - a
637                + ˇbb",
638            ),
639            "Clipboard ↔ text.txt @ L1:1-3",
640            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
641            cx,
642        )
643        .await;
644    }
645
646    async fn base_test(
647        project_root: &str,
648        file_path: &str,
649        clipboard_text: &str,
650        editor_text: &str,
651        expected_diff: &str,
652        expected_tab_title: &str,
653        expected_tab_tooltip: &str,
654        cx: &mut TestAppContext,
655    ) {
656        init_test(cx);
657
658        let file_name = std::path::Path::new(file_path)
659            .file_name()
660            .unwrap()
661            .to_str()
662            .unwrap();
663
664        let fs = FakeFs::new(cx.executor());
665        fs.insert_tree(
666            project_root,
667            json!({
668                file_name: editor_text
669            }),
670        )
671        .await;
672
673        let project = Project::test(fs, [project_root.as_ref()], cx).await;
674
675        let (multi_workspace, cx) =
676            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
677        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
678
679        let buffer = project
680            .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
681            .await
682            .unwrap();
683
684        let editor = cx.new_window_entity(|window, cx| {
685            let mut editor = Editor::for_buffer(buffer, None, window, cx);
686            let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
687            editor.set_text(unmarked_text, window, cx);
688            editor.change_selections(Default::default(), window, cx, |s| {
689                s.select_ranges(
690                    selection_ranges
691                        .into_iter()
692                        .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
693                )
694            });
695
696            editor
697        });
698
699        let diff_view = workspace
700            .update_in(cx, |workspace, window, cx| {
701                TextDiffView::open(
702                    &DiffClipboardWithSelectionData {
703                        clipboard_text: clipboard_text.to_string(),
704                        editor,
705                    },
706                    workspace,
707                    window,
708                    cx,
709                )
710            })
711            .unwrap()
712            .await
713            .unwrap();
714
715        cx.executor().run_until_parked();
716
717        assert_state_with_diff(
718            &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
719            cx,
720            expected_diff,
721        );
722
723        diff_view.read_with(cx, |diff_view, cx| {
724            assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
725            assert_eq!(
726                diff_view.tab_tooltip_text(cx).unwrap(),
727                expected_tab_tooltip
728            );
729        });
730    }
731}