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, BufferDiffSnapshot};
  5use editor::{Editor, EditorEvent, MultiBuffer, ToPoint, actions::DiffClipboardWithSelectionData};
  6use futures::{FutureExt, select_biased};
  7use gpui::{
  8    AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
  9    FocusHandle, Focusable, IntoElement, Render, Task, Window,
 10};
 11use language::{self, Buffer, Point};
 12use project::Project;
 13use std::{
 14    any::{Any, TypeId},
 15    ops::Range,
 16    pin::pin,
 17    sync::Arc,
 18    time::Duration,
 19};
 20use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
 21use util::paths::PathExt;
 22
 23use workspace::{
 24    Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
 25    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
 26    searchable::SearchableItemHandle,
 27};
 28
 29pub struct TextDiffView {
 30    diff_editor: Entity<Editor>,
 31    title: SharedString,
 32    path: Option<SharedString>,
 33    buffer_changes_tx: watch::Sender<()>,
 34    _recalculate_diff_task: Task<Result<()>>,
 35}
 36
 37const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
 38
 39impl TextDiffView {
 40    pub fn open(
 41        diff_data: &DiffClipboardWithSelectionData,
 42        workspace: &Workspace,
 43        window: &mut Window,
 44        cx: &mut App,
 45    ) -> Option<Task<Result<Entity<Self>>>> {
 46        let source_editor = diff_data.editor.clone();
 47
 48        let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| {
 49            let multibuffer = editor.buffer().read(cx);
 50            let source_buffer = multibuffer.as_singleton()?.clone();
 51            let selections = editor.selections.all::<Point>(cx);
 52            let buffer_snapshot = source_buffer.read(cx);
 53            let first_selection = selections.first()?;
 54            let selection_range = if first_selection.is_empty() {
 55                Point::new(0, 0)..buffer_snapshot.max_point()
 56            } else {
 57                first_selection.start..first_selection.end
 58            };
 59
 60            Some((source_buffer, selection_range))
 61        });
 62
 63        let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else {
 64            log::warn!("There should always be at least one selection in Zed. This is a bug.");
 65            return None;
 66        };
 67
 68        let clipboard_text = diff_data.clipboard_text.clone();
 69
 70        let workspace = workspace.weak_handle();
 71
 72        let diff_buffer = cx.new(|cx| {
 73            let source_buffer_snapshot = source_buffer.read(cx).snapshot();
 74            let diff = BufferDiff::new(&source_buffer_snapshot.text, cx);
 75            diff
 76        });
 77
 78        let clipboard_buffer =
 79            build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx);
 80
 81        let task = window.spawn(cx, async move |cx| {
 82            let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
 83
 84            update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
 85
 86            workspace.update_in(cx, |workspace, window, cx| {
 87                let diff_view = cx.new(|cx| {
 88                    TextDiffView::new(
 89                        clipboard_buffer,
 90                        source_editor,
 91                        source_buffer,
 92                        selected_range,
 93                        diff_buffer,
 94                        project,
 95                        window,
 96                        cx,
 97                    )
 98                });
 99
100                let pane = workspace.active_pane();
101                pane.update(cx, |pane, cx| {
102                    pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
103                });
104
105                diff_view
106            })
107        });
108
109        Some(task)
110    }
111
112    pub fn new(
113        clipboard_buffer: Entity<Buffer>,
114        source_editor: Entity<Editor>,
115        source_buffer: Entity<Buffer>,
116        source_range: Range<Point>,
117        diff_buffer: Entity<BufferDiff>,
118        project: Entity<Project>,
119        window: &mut Window,
120        cx: &mut Context<Self>,
121    ) -> Self {
122        let multibuffer = cx.new(|cx| {
123            let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
124
125            multibuffer.push_excerpts(
126                source_buffer.clone(),
127                [editor::ExcerptRange::new(source_range)],
128                cx,
129            );
130
131            multibuffer.add_diff(diff_buffer.clone(), cx);
132            multibuffer
133        });
134        let diff_editor = cx.new(|cx| {
135            let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx);
136            editor.start_temporary_diff_override();
137            editor.disable_diagnostics(cx);
138            editor.set_expand_all_diff_hunks(cx);
139            editor.set_render_diff_hunk_controls(
140                Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
141                cx,
142            );
143            editor
144        });
145
146        let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
147
148        cx.subscribe(&source_buffer, move |this, _, event, _| match event {
149            language::BufferEvent::Edited
150            | language::BufferEvent::LanguageChanged
151            | language::BufferEvent::Reparsed => {
152                this.buffer_changes_tx.send(()).ok();
153            }
154            _ => {}
155        })
156        .detach();
157
158        let editor = source_editor.read(cx);
159        let title = editor.buffer().read(cx).title(cx).to_string();
160        let selection_location_text = selection_location_text(editor, cx);
161        let selection_location_title = selection_location_text
162            .as_ref()
163            .map(|text| format!("{} @ {}", title, text))
164            .unwrap_or(title);
165
166        let path = editor
167            .buffer()
168            .read(cx)
169            .as_singleton()
170            .and_then(|b| {
171                b.read(cx)
172                    .file()
173                    .map(|f| f.full_path(cx).compact().to_string_lossy().to_string())
174            })
175            .unwrap_or("untitled".into());
176
177        let selection_location_path = selection_location_text
178            .map(|text| format!("{} @ {}", path, text))
179            .unwrap_or(path);
180
181        Self {
182            diff_editor,
183            title: format!("Clipboard ↔ {selection_location_title}").into(),
184            path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
185            buffer_changes_tx,
186            _recalculate_diff_task: cx.spawn(async move |_, cx| {
187                while let Ok(_) = buffer_changes_rx.recv().await {
188                    loop {
189                        let mut timer = cx
190                            .background_executor()
191                            .timer(RECALCULATE_DIFF_DEBOUNCE)
192                            .fuse();
193                        let mut recv = pin!(buffer_changes_rx.recv().fuse());
194                        select_biased! {
195                            _ = timer => break,
196                            _ = recv => continue,
197                        }
198                    }
199
200                    log::trace!("start recalculating");
201                    update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
202                    log::trace!("finish recalculating");
203                }
204                Ok(())
205            }),
206        }
207    }
208}
209
210fn build_clipboard_buffer(
211    clipboard_text: String,
212    source_buffer: &Entity<Buffer>,
213    selected_range: Range<Point>,
214    cx: &mut App,
215) -> Entity<Buffer> {
216    let source_buffer_snapshot = source_buffer.read(cx).snapshot();
217    cx.new(|cx| {
218        let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
219        let language = source_buffer.read(cx).language().cloned();
220        buffer.set_language(language, cx);
221
222        let range_start = source_buffer_snapshot.point_to_offset(selected_range.start);
223        let range_end = source_buffer_snapshot.point_to_offset(selected_range.end);
224        buffer.edit([(range_start..range_end, clipboard_text)], None, cx);
225
226        buffer
227    })
228}
229
230async fn update_diff_buffer(
231    diff: &Entity<BufferDiff>,
232    source_buffer: &Entity<Buffer>,
233    clipboard_buffer: &Entity<Buffer>,
234    cx: &mut AsyncApp,
235) -> Result<()> {
236    let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
237
238    let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
239    let base_text = base_buffer_snapshot.text().to_string();
240
241    let diff_snapshot = cx
242        .update(|cx| {
243            BufferDiffSnapshot::new_with_base_buffer(
244                source_buffer_snapshot.text.clone(),
245                Some(Arc::new(base_text)),
246                base_buffer_snapshot,
247                cx,
248            )
249        })?
250        .await;
251
252    diff.update(cx, |diff, cx| {
253        diff.set_snapshot(diff_snapshot, &source_buffer_snapshot.text, cx);
254    })?;
255    Ok(())
256}
257
258impl EventEmitter<EditorEvent> for TextDiffView {}
259
260impl Focusable for TextDiffView {
261    fn focus_handle(&self, cx: &App) -> FocusHandle {
262        self.diff_editor.focus_handle(cx)
263    }
264}
265
266impl Item for TextDiffView {
267    type Event = EditorEvent;
268
269    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
270        Some(Icon::new(IconName::Diff).color(Color::Muted))
271    }
272
273    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
274        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
275            .color(if params.selected {
276                Color::Default
277            } else {
278                Color::Muted
279            })
280            .into_any_element()
281    }
282
283    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
284        self.title.clone()
285    }
286
287    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
288        self.path.clone()
289    }
290
291    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
292        Editor::to_item_events(event, f)
293    }
294
295    fn telemetry_event_text(&self) -> Option<&'static str> {
296        Some("Diff View Opened")
297    }
298
299    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
300        self.diff_editor
301            .update(cx, |editor, cx| editor.deactivated(window, cx));
302    }
303
304    fn is_singleton(&self, _: &App) -> bool {
305        false
306    }
307
308    fn act_as_type<'a>(
309        &'a self,
310        type_id: TypeId,
311        self_handle: &'a Entity<Self>,
312        _: &'a App,
313    ) -> Option<AnyView> {
314        if type_id == TypeId::of::<Self>() {
315            Some(self_handle.to_any())
316        } else if type_id == TypeId::of::<Editor>() {
317            Some(self.diff_editor.to_any())
318        } else {
319            None
320        }
321    }
322
323    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
324        Some(Box::new(self.diff_editor.clone()))
325    }
326
327    fn for_each_project_item(
328        &self,
329        cx: &App,
330        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
331    ) {
332        self.diff_editor.for_each_project_item(cx, f)
333    }
334
335    fn set_nav_history(
336        &mut self,
337        nav_history: ItemNavHistory,
338        _: &mut Window,
339        cx: &mut Context<Self>,
340    ) {
341        self.diff_editor.update(cx, |editor, _| {
342            editor.set_nav_history(Some(nav_history));
343        });
344    }
345
346    fn navigate(
347        &mut self,
348        data: Box<dyn Any>,
349        window: &mut Window,
350        cx: &mut Context<Self>,
351    ) -> bool {
352        self.diff_editor
353            .update(cx, |editor, cx| editor.navigate(data, window, cx))
354    }
355
356    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
357        ToolbarItemLocation::PrimaryLeft
358    }
359
360    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
361        self.diff_editor.breadcrumbs(theme, cx)
362    }
363
364    fn added_to_workspace(
365        &mut self,
366        workspace: &mut Workspace,
367        window: &mut Window,
368        cx: &mut Context<Self>,
369    ) {
370        self.diff_editor.update(cx, |editor, cx| {
371            editor.added_to_workspace(workspace, window, cx)
372        });
373    }
374
375    fn can_save(&self, cx: &App) -> bool {
376        // The editor handles the new buffer, so delegate to it
377        self.diff_editor.read(cx).can_save(cx)
378    }
379
380    fn save(
381        &mut self,
382        options: SaveOptions,
383        project: Entity<Project>,
384        window: &mut Window,
385        cx: &mut Context<Self>,
386    ) -> Task<Result<()>> {
387        // Delegate saving to the editor, which manages the new buffer
388        self.diff_editor
389            .update(cx, |editor, cx| editor.save(options, project, window, cx))
390    }
391}
392
393pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
394    let buffer = editor.buffer().read(cx);
395    let buffer_snapshot = buffer.snapshot(cx);
396    let first_selection = editor.selections.disjoint.first()?;
397
398    let (start_row, start_column, end_row, end_column) =
399        if first_selection.start == first_selection.end {
400            let max_point = buffer_snapshot.max_point();
401            (0, 0, max_point.row, max_point.column)
402        } else {
403            let selection_start = first_selection.start.to_point(&buffer_snapshot);
404            let selection_end = first_selection.end.to_point(&buffer_snapshot);
405
406            (
407                selection_start.row,
408                selection_start.column,
409                selection_end.row,
410                selection_end.column,
411            )
412        };
413
414    let range_text = if start_row == end_row {
415        format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
416    } else {
417        format!(
418            "L{}:{}-L{}:{}",
419            start_row + 1,
420            start_column + 1,
421            end_row + 1,
422            end_column + 1
423        )
424    };
425
426    Some(range_text)
427}
428
429impl Render for TextDiffView {
430    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
431        self.diff_editor.clone()
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    use editor::{actions, test::editor_test_context::assert_state_with_diff};
440    use gpui::{TestAppContext, VisualContext};
441    use project::{FakeFs, Project};
442    use serde_json::json;
443    use settings::{Settings, SettingsStore};
444    use unindent::unindent;
445    use util::path;
446
447    fn init_test(cx: &mut TestAppContext) {
448        cx.update(|cx| {
449            let settings_store = SettingsStore::test(cx);
450            cx.set_global(settings_store);
451            language::init(cx);
452            Project::init_settings(cx);
453            workspace::init_settings(cx);
454            editor::init_settings(cx);
455            theme::ThemeSettings::register(cx)
456        });
457    }
458
459    #[gpui::test]
460    async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) {
461        base_test(true, cx).await;
462    }
463
464    #[gpui::test]
465    async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer(
466        cx: &mut TestAppContext,
467    ) {
468        base_test(false, cx).await;
469    }
470
471    async fn base_test(select_all_text: bool, cx: &mut TestAppContext) {
472        init_test(cx);
473
474        let fs = FakeFs::new(cx.executor());
475        fs.insert_tree(
476            path!("/test"),
477            json!({
478                "a": {
479                    "b": {
480                        "text.txt": "new line 1\nline 2\nnew line 3\nline 4"
481                    }
482                }
483            }),
484        )
485        .await;
486
487        let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
488
489        let (workspace, mut cx) =
490            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
491
492        let buffer = project
493            .update(cx, |project, cx| {
494                project.open_local_buffer(path!("/test/a/b/text.txt"), cx)
495            })
496            .await
497            .unwrap();
498
499        let editor = cx.new_window_entity(|window, cx| {
500            let mut editor = Editor::for_buffer(buffer, None, window, cx);
501            editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx);
502
503            if select_all_text {
504                editor.select_all(&actions::SelectAll, window, cx);
505            }
506
507            editor
508        });
509
510        let diff_view = workspace
511            .update_in(cx, |workspace, window, cx| {
512                TextDiffView::open(
513                    &DiffClipboardWithSelectionData {
514                        clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(),
515                        editor,
516                    },
517                    workspace,
518                    window,
519                    cx,
520                )
521            })
522            .unwrap()
523            .await
524            .unwrap();
525
526        cx.executor().run_until_parked();
527
528        assert_state_with_diff(
529            &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
530            &mut cx,
531            &unindent(
532                "
533                - old line 1
534                + Λ‡new line 1
535                  line 2
536                - old line 3
537                + new line 3
538                  line 4
539                ",
540            ),
541        );
542
543        diff_view.read_with(cx, |diff_view, cx| {
544            assert_eq!(
545                diff_view.tab_content_text(0, cx),
546                "Clipboard ↔ text.txt @ L1:1-L5:1"
547            );
548            assert_eq!(
549                diff_view.tab_tooltip_text(cx).unwrap(),
550                format!("Clipboard ↔ {}", path!("test/a/b/text.txt @ L1:1-L5:1"))
551            );
552        });
553    }
554}