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}