Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:34:12.914Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

file_diff_view.rs

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