Skip to repository content725 lines · 23.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:27:39.481Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
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::{
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