file_diff_view.rs

  1//! FileDiffView provides a UI for displaying differences between two buffers.
  2
  3use anyhow::Result;
  4use buffer_diff::BufferDiff;
  5use editor::{Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor};
  6use futures::{FutureExt, select_biased};
  7use gpui::{
  8    AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
  9    Focusable, Font, IntoElement, Render, Task, WeakEntity, Window,
 10};
 11use language::{Buffer, HighlightedText, LanguageRegistry};
 12use project::Project;
 13use settings::Settings;
 14use std::{
 15    any::{Any, TypeId},
 16    path::PathBuf,
 17    pin::pin,
 18    sync::Arc,
 19    time::Duration,
 20};
 21use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
 22use util::paths::PathExt as _;
 23use workspace::{
 24    Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
 25    item::{ItemEvent, SaveOptions, TabContentParams},
 26    searchable::SearchableItemHandle,
 27};
 28
 29pub struct FileDiffView {
 30    editor: Entity<SplittableEditor>,
 31    old_buffer: Entity<Buffer>,
 32    new_buffer: Entity<Buffer>,
 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 FileDiffView {
 40    #[ztracing::instrument(skip_all)]
 41    pub fn open(
 42        old_path: PathBuf,
 43        new_path: PathBuf,
 44        workspace: WeakEntity<Workspace>,
 45        window: &mut Window,
 46        cx: &mut App,
 47    ) -> Task<Result<Entity<Self>>> {
 48        window.spawn(cx, async move |cx| {
 49            let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
 50            let old_buffer = project
 51                .update(cx, |project, cx| project.open_local_buffer(&old_path, cx))
 52                .await?;
 53            let new_buffer = project
 54                .update(cx, |project, cx| project.open_local_buffer(&new_path, cx))
 55                .await?;
 56            let languages = project.update(cx, |project, _| project.languages().clone());
 57
 58            let buffer_diff = build_buffer_diff(&old_buffer, &new_buffer, languages, cx).await?;
 59
 60            workspace.update_in(cx, |workspace, window, cx| {
 61                let workspace_entity = cx.entity();
 62                let diff_view = cx.new(|cx| {
 63                    FileDiffView::new(
 64                        old_buffer,
 65                        new_buffer,
 66                        buffer_diff,
 67                        project.clone(),
 68                        workspace_entity,
 69                        window,
 70                        cx,
 71                    )
 72                });
 73
 74                let pane = workspace.active_pane();
 75                pane.update(cx, |pane, cx| {
 76                    pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
 77                });
 78
 79                diff_view
 80            })
 81        })
 82    }
 83
 84    pub fn new(
 85        old_buffer: Entity<Buffer>,
 86        new_buffer: Entity<Buffer>,
 87        diff: Entity<BufferDiff>,
 88        project: Entity<Project>,
 89        workspace: Entity<Workspace>,
 90        window: &mut Window,
 91        cx: &mut Context<Self>,
 92    ) -> Self {
 93        let multibuffer = cx.new(|cx| {
 94            let mut multibuffer = MultiBuffer::singleton(new_buffer.clone(), cx);
 95            multibuffer.add_diff(diff.clone(), cx);
 96            multibuffer
 97        });
 98        let editor = cx.new(|cx| {
 99            let splittable = SplittableEditor::new(
100                EditorSettings::get_global(cx).diff_view_style,
101                multibuffer.clone(),
102                project.clone(),
103                workspace,
104                window,
105                cx,
106            );
107            splittable.rhs_editor().update(cx, |editor, _| {
108                editor.start_temporary_diff_override();
109            });
110            splittable.disable_diff_hunk_controls(cx);
111            splittable
112        });
113
114        let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
115
116        for buffer in [&old_buffer, &new_buffer] {
117            cx.subscribe(buffer, move |this, _, event, _| match event {
118                language::BufferEvent::Edited { .. }
119                | language::BufferEvent::LanguageChanged(_)
120                | language::BufferEvent::Reparsed => {
121                    this.buffer_changes_tx.send(()).ok();
122                }
123                _ => {}
124            })
125            .detach();
126        }
127
128        Self {
129            editor,
130            buffer_changes_tx,
131            old_buffer,
132            new_buffer,
133            _recalculate_diff_task: cx.spawn(async move |this, cx| {
134                while buffer_changes_rx.recv().await.is_ok() {
135                    loop {
136                        let mut timer = cx
137                            .background_executor()
138                            .timer(RECALCULATE_DIFF_DEBOUNCE)
139                            .fuse();
140                        let mut recv = pin!(buffer_changes_rx.recv().fuse());
141                        select_biased! {
142                            _ = timer => break,
143                            _ = recv => continue,
144                        }
145                    }
146
147                    log::trace!("start recalculating");
148                    let (old_snapshot, new_snapshot) = this.update(cx, |this, cx| {
149                        (
150                            this.old_buffer.read(cx).snapshot(),
151                            this.new_buffer.read(cx).snapshot(),
152                        )
153                    })?;
154                    diff.update(cx, |diff, cx| {
155                        diff.set_base_text(
156                            Some(old_snapshot.text().as_str().into()),
157                            old_snapshot.language().cloned(),
158                            new_snapshot.text.clone(),
159                            cx,
160                        )
161                    })
162                    .await
163                    .ok();
164                    log::trace!("finish recalculating");
165                }
166                Ok(())
167            }),
168        }
169    }
170}
171
172#[ztracing::instrument(skip_all)]
173async fn build_buffer_diff(
174    old_buffer: &Entity<Buffer>,
175    new_buffer: &Entity<Buffer>,
176    language_registry: Arc<LanguageRegistry>,
177    cx: &mut AsyncApp,
178) -> Result<Entity<BufferDiff>> {
179    let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot());
180    let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot());
181
182    let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
183
184    let update = diff
185        .update(cx, |diff, cx| {
186            diff.update_diff(
187                new_buffer_snapshot.text.clone(),
188                Some(old_buffer_snapshot.text().into()),
189                Some(true),
190                new_buffer_snapshot.language().cloned(),
191                cx,
192            )
193        })
194        .await;
195
196    diff.update(cx, |diff, cx| {
197        diff.language_changed(
198            new_buffer_snapshot.language().cloned(),
199            Some(language_registry),
200            cx,
201        );
202        diff.set_snapshot(update, &new_buffer_snapshot.text, cx)
203    })
204    .await;
205
206    Ok(diff)
207}
208
209impl EventEmitter<EditorEvent> for FileDiffView {}
210
211impl Focusable for FileDiffView {
212    fn focus_handle(&self, cx: &App) -> FocusHandle {
213        self.editor.focus_handle(cx)
214    }
215}
216
217impl Item for FileDiffView {
218    type Event = EditorEvent;
219
220    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
221        Some(Icon::new(IconName::Diff).color(Color::Muted))
222    }
223
224    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
225        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
226            .color(if params.selected {
227                Color::Default
228            } else {
229                Color::Muted
230            })
231            .into_any_element()
232    }
233
234    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
235        let title_text = |buffer: &Entity<Buffer>| {
236            buffer
237                .read(cx)
238                .file()
239                .and_then(|file| {
240                    Some(
241                        file.full_path(cx)
242                            .file_name()?
243                            .to_string_lossy()
244                            .to_string(),
245                    )
246                })
247                .unwrap_or_else(|| "untitled".into())
248        };
249        let old_filename = title_text(&self.old_buffer);
250        let new_filename = title_text(&self.new_buffer);
251
252        format!("{old_filename}{new_filename}").into()
253    }
254
255    fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
256        let path = |buffer: &Entity<Buffer>| {
257            buffer
258                .read(cx)
259                .file()
260                .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
261                .unwrap_or_else(|| "untitled".into())
262        };
263        let old_path = path(&self.old_buffer);
264        let new_path = path(&self.new_buffer);
265
266        Some(format!("{old_path}{new_path}").into())
267    }
268
269    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
270        Editor::to_item_events(event, f)
271    }
272
273    fn telemetry_event_text(&self) -> Option<&'static str> {
274        Some("Diff View Opened")
275    }
276
277    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
278        self.editor.deactivated(window, cx);
279    }
280
281    fn act_as_type<'a>(
282        &'a self,
283        type_id: TypeId,
284        self_handle: &'a Entity<Self>,
285        cx: &'a App,
286    ) -> Option<gpui::AnyEntity> {
287        if type_id == TypeId::of::<Self>() {
288            Some(self_handle.clone().into())
289        } else {
290            self.editor.act_as_type(type_id, cx)
291        }
292    }
293
294    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
295        Some(Box::new(self.editor.clone()))
296    }
297
298    fn for_each_project_item(
299        &self,
300        cx: &App,
301        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
302    ) {
303        self.editor.for_each_project_item(cx, f)
304    }
305
306    fn set_nav_history(
307        &mut self,
308        nav_history: ItemNavHistory,
309        _: &mut Window,
310        cx: &mut Context<Self>,
311    ) {
312        self.editor.update(cx, |editor, cx| {
313            editor.rhs_editor().update(cx, |editor, _| {
314                editor.set_nav_history(Some(nav_history));
315            })
316        });
317    }
318
319    fn navigate(
320        &mut self,
321        data: Arc<dyn Any + Send>,
322        window: &mut Window,
323        cx: &mut Context<Self>,
324    ) -> bool {
325        self.editor.update(cx, |editor, cx| {
326            editor
327                .rhs_editor()
328                .update(cx, |editor, cx| editor.navigate(data, window, cx))
329        })
330    }
331
332    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
333        ToolbarItemLocation::PrimaryLeft
334    }
335
336    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
337        self.editor.breadcrumbs(cx)
338    }
339
340    fn added_to_workspace(
341        &mut self,
342        workspace: &mut Workspace,
343        window: &mut Window,
344        cx: &mut Context<Self>,
345    ) {
346        self.editor.update(cx, |editor, cx| {
347            editor.rhs_editor().update(cx, |editor, cx| {
348                editor.added_to_workspace(workspace, window, cx)
349            })
350        });
351    }
352
353    fn can_save(&self, cx: &App) -> bool {
354        self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
355    }
356
357    fn save(
358        &mut self,
359        options: SaveOptions,
360        project: Entity<Project>,
361        window: &mut Window,
362        cx: &mut Context<Self>,
363    ) -> Task<Result<()>> {
364        self.editor.save(options, project, window, cx)
365    }
366}
367
368impl Render for FileDiffView {
369    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
370        self.editor.clone()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use editor::test::editor_test_context::assert_state_with_diff;
378    use gpui::BorrowAppContext;
379    use gpui::TestAppContext;
380    use project::{FakeFs, Fs, Project};
381    use settings::{DiffViewStyle, SettingsStore};
382    use std::path::PathBuf;
383    use unindent::unindent;
384    use util::path;
385    use workspace::MultiWorkspace;
386
387    fn init_test(cx: &mut TestAppContext) {
388        cx.update(|cx| {
389            let settings_store = SettingsStore::test(cx);
390            cx.set_global(settings_store);
391            cx.update_global::<SettingsStore, _>(|store, cx| {
392                store.update_user_settings(cx, |settings| {
393                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
394                });
395            });
396            theme_settings::init(theme::LoadThemes::JustBase, cx);
397        });
398    }
399
400    #[gpui::test]
401    async fn test_diff_view(cx: &mut TestAppContext) {
402        init_test(cx);
403
404        let fs = FakeFs::new(cx.executor());
405        fs.insert_tree(
406            path!("/test"),
407            serde_json::json!({
408                "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
409                "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
410            }),
411        )
412        .await;
413
414        let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
415
416        let (multi_workspace, cx) =
417            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
418        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
419
420        let diff_view = workspace
421            .update_in(cx, |workspace, window, cx| {
422                FileDiffView::open(
423                    path!("/test/old_file.txt").into(),
424                    path!("/test/new_file.txt").into(),
425                    workspace.weak_handle(),
426                    window,
427                    cx,
428                )
429            })
430            .await
431            .unwrap();
432
433        // Verify initial diff
434        assert_state_with_diff(
435            &diff_view.read_with(cx, |diff_view, cx| {
436                diff_view.editor.read(cx).rhs_editor().clone()
437            }),
438            cx,
439            &unindent(
440                "
441                - old line 1
442                + ˇnew line 1
443                  line 2
444                - old line 3
445                + new line 3
446                  line 4
447                ",
448            ),
449        );
450
451        // Modify the new file on disk
452        fs.save(
453            path!("/test/new_file.txt").as_ref(),
454            &unindent(
455                "
456                new line 1
457                line 2
458                new line 3
459                line 4
460                new line 5
461                ",
462            )
463            .into(),
464            Default::default(),
465        )
466        .await
467        .unwrap();
468
469        // The diff now reflects the changes to the new file
470        cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
471        assert_state_with_diff(
472            &diff_view.read_with(cx, |diff_view, cx| {
473                diff_view.editor.read(cx).rhs_editor().clone()
474            }),
475            cx,
476            &unindent(
477                "
478                - old line 1
479                + ˇnew line 1
480                  line 2
481                - old line 3
482                + new line 3
483                  line 4
484                + new line 5
485                ",
486            ),
487        );
488
489        // Modify the old file on disk
490        fs.save(
491            path!("/test/old_file.txt").as_ref(),
492            &unindent(
493                "
494                new line 1
495                line 2
496                old line 3
497                line 4
498                ",
499            )
500            .into(),
501            Default::default(),
502        )
503        .await
504        .unwrap();
505
506        // The diff now reflects the changes to the new file
507        cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
508        assert_state_with_diff(
509            &diff_view.read_with(cx, |diff_view, cx| {
510                diff_view.editor.read(cx).rhs_editor().clone()
511            }),
512            cx,
513            &unindent(
514                "
515                  ˇnew line 1
516                  line 2
517                - old line 3
518                + new line 3
519                  line 4
520                + new line 5
521                ",
522            ),
523        );
524
525        diff_view.read_with(cx, |diff_view, cx| {
526            assert_eq!(
527                diff_view.tab_content_text(0, cx),
528                "old_file.txt ↔ new_file.txt"
529            );
530            assert_eq!(
531                diff_view.tab_tooltip_text(cx).unwrap(),
532                format!(
533                    "{}{}",
534                    path!("test/old_file.txt"),
535                    path!("test/new_file.txt")
536                )
537            );
538        })
539    }
540
541    #[gpui::test]
542    async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) {
543        init_test(cx);
544
545        let fs = FakeFs::new(cx.executor());
546        fs.insert_tree(
547            path!("/test"),
548            serde_json::json!({
549                "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
550                "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
551            }),
552        )
553        .await;
554
555        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
556
557        let (multi_workspace, cx) =
558            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
559        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
560
561        let diff_view = workspace
562            .update_in(cx, |workspace, window, cx| {
563                FileDiffView::open(
564                    PathBuf::from(path!("/test/old_file.txt")),
565                    PathBuf::from(path!("/test/new_file.txt")),
566                    workspace.weak_handle(),
567                    window,
568                    cx,
569                )
570            })
571            .await
572            .unwrap();
573
574        diff_view.update_in(cx, |diff_view, window, cx| {
575            diff_view.editor.update(cx, |splittable, cx| {
576                splittable.rhs_editor().update(cx, |editor, cx| {
577                    editor.insert("modified ", window, cx);
578                });
579            });
580        });
581
582        diff_view.update_in(cx, |diff_view, _, cx| {
583            let buffer = diff_view.new_buffer.read(cx);
584            assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
585        });
586
587        let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
588            workspace::Item::save(
589                diff_view,
590                workspace::item::SaveOptions::default(),
591                project.clone(),
592                window,
593                cx,
594            )
595        });
596
597        save_task.await.expect("Save should succeed");
598
599        let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
600        assert_eq!(
601            saved_content,
602            "modified new line 1\nline 2\nnew line 3\nline 4\n"
603        );
604
605        diff_view.update_in(cx, |diff_view, _, cx| {
606            let buffer = diff_view.new_buffer.read(cx);
607            assert!(!buffer.is_dirty(), "Buffer should not be dirty after save");
608        });
609    }
610}