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