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, 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: &Workspace,
44 window: &mut Window,
45 cx: &mut App,
46 ) -> Task<Result<Entity<Self>>> {
47 let workspace = workspace.weak_handle();
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 diff_view = cx.new(|cx| {
62 FileDiffView::new(
63 old_buffer,
64 new_buffer,
65 buffer_diff,
66 project.clone(),
67 window,
68 cx,
69 )
70 });
71
72 let pane = workspace.active_pane();
73 pane.update(cx, |pane, cx| {
74 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
75 });
76
77 diff_view
78 })
79 })
80 }
81
82 pub fn new(
83 old_buffer: Entity<Buffer>,
84 new_buffer: Entity<Buffer>,
85 diff: Entity<BufferDiff>,
86 project: Entity<Project>,
87 window: &mut Window,
88 cx: &mut Context<Self>,
89 ) -> Self {
90 let multibuffer = cx.new(|cx| {
91 let mut multibuffer = MultiBuffer::singleton(new_buffer.clone(), cx);
92 multibuffer.add_diff(diff.clone(), cx);
93 multibuffer
94 });
95 let editor = cx.new(|cx| {
96 let mut editor =
97 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
98 editor.start_temporary_diff_override();
99 editor.disable_diagnostics(cx);
100 editor.set_expand_all_diff_hunks(cx);
101 editor.set_render_diff_hunk_controls(
102 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
103 cx,
104 );
105 editor
106 });
107
108 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
109
110 for buffer in [&old_buffer, &new_buffer] {
111 cx.subscribe(buffer, move |this, _, event, _| match event {
112 language::BufferEvent::Edited
113 | language::BufferEvent::LanguageChanged(_)
114 | language::BufferEvent::Reparsed => {
115 this.buffer_changes_tx.send(()).ok();
116 }
117 _ => {}
118 })
119 .detach();
120 }
121
122 Self {
123 editor,
124 buffer_changes_tx,
125 old_buffer,
126 new_buffer,
127 _recalculate_diff_task: cx.spawn(async move |this, cx| {
128 while buffer_changes_rx.recv().await.is_ok() {
129 loop {
130 let mut timer = cx
131 .background_executor()
132 .timer(RECALCULATE_DIFF_DEBOUNCE)
133 .fuse();
134 let mut recv = pin!(buffer_changes_rx.recv().fuse());
135 select_biased! {
136 _ = timer => break,
137 _ = recv => continue,
138 }
139 }
140
141 log::trace!("start recalculating");
142 let (old_snapshot, new_snapshot) = this.update(cx, |this, cx| {
143 (
144 this.old_buffer.read(cx).snapshot(),
145 this.new_buffer.read(cx).snapshot(),
146 )
147 })?;
148 diff.update(cx, |diff, cx| {
149 diff.set_base_text(
150 Some(old_snapshot.text().as_str().into()),
151 old_snapshot.language().cloned(),
152 new_snapshot.text.clone(),
153 cx,
154 )
155 })
156 .await
157 .ok();
158 log::trace!("finish recalculating");
159 }
160 Ok(())
161 }),
162 }
163 }
164}
165
166#[ztracing::instrument(skip_all)]
167async fn build_buffer_diff(
168 old_buffer: &Entity<Buffer>,
169 new_buffer: &Entity<Buffer>,
170 language_registry: Arc<LanguageRegistry>,
171 cx: &mut AsyncApp,
172) -> Result<Entity<BufferDiff>> {
173 let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot());
174 let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot());
175
176 let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
177
178 let update = diff
179 .update(cx, |diff, cx| {
180 diff.update_diff(
181 new_buffer_snapshot.text.clone(),
182 Some(old_buffer_snapshot.text().into()),
183 Some(true),
184 new_buffer_snapshot.language().cloned(),
185 cx,
186 )
187 })
188 .await;
189
190 diff.update(cx, |diff, cx| {
191 diff.language_changed(
192 new_buffer_snapshot.language().cloned(),
193 Some(language_registry),
194 cx,
195 );
196 diff.set_snapshot(update, &new_buffer_snapshot.text, cx)
197 })
198 .await;
199
200 Ok(diff)
201}
202
203impl EventEmitter<EditorEvent> for FileDiffView {}
204
205impl Focusable for FileDiffView {
206 fn focus_handle(&self, cx: &App) -> FocusHandle {
207 self.editor.focus_handle(cx)
208 }
209}
210
211impl Item for FileDiffView {
212 type Event = EditorEvent;
213
214 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
215 Some(Icon::new(IconName::Diff).color(Color::Muted))
216 }
217
218 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
219 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
220 .color(if params.selected {
221 Color::Default
222 } else {
223 Color::Muted
224 })
225 .into_any_element()
226 }
227
228 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
229 let title_text = |buffer: &Entity<Buffer>| {
230 buffer
231 .read(cx)
232 .file()
233 .and_then(|file| {
234 Some(
235 file.full_path(cx)
236 .file_name()?
237 .to_string_lossy()
238 .to_string(),
239 )
240 })
241 .unwrap_or_else(|| "untitled".into())
242 };
243 let old_filename = title_text(&self.old_buffer);
244 let new_filename = title_text(&self.new_buffer);
245
246 format!("{old_filename} ↔ {new_filename}").into()
247 }
248
249 fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
250 let path = |buffer: &Entity<Buffer>| {
251 buffer
252 .read(cx)
253 .file()
254 .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
255 .unwrap_or_else(|| "untitled".into())
256 };
257 let old_path = path(&self.old_buffer);
258 let new_path = path(&self.new_buffer);
259
260 Some(format!("{old_path} ↔ {new_path}").into())
261 }
262
263 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
264 Editor::to_item_events(event, f)
265 }
266
267 fn telemetry_event_text(&self) -> Option<&'static str> {
268 Some("Diff View Opened")
269 }
270
271 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
272 self.editor
273 .update(cx, |editor, cx| editor.deactivated(window, cx));
274 }
275
276 fn act_as_type<'a>(
277 &'a self,
278 type_id: TypeId,
279 self_handle: &'a Entity<Self>,
280 _: &'a App,
281 ) -> Option<gpui::AnyEntity> {
282 if type_id == TypeId::of::<Self>() {
283 Some(self_handle.clone().into())
284 } else if type_id == TypeId::of::<Editor>() {
285 Some(self.editor.clone().into())
286 } else {
287 None
288 }
289 }
290
291 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
292 Some(Box::new(self.editor.clone()))
293 }
294
295 fn for_each_project_item(
296 &self,
297 cx: &App,
298 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
299 ) {
300 self.editor.for_each_project_item(cx, f)
301 }
302
303 fn set_nav_history(
304 &mut self,
305 nav_history: ItemNavHistory,
306 _: &mut Window,
307 cx: &mut Context<Self>,
308 ) {
309 self.editor.update(cx, |editor, _| {
310 editor.set_nav_history(Some(nav_history));
311 });
312 }
313
314 fn navigate(
315 &mut self,
316 data: Arc<dyn Any + Send>,
317 window: &mut Window,
318 cx: &mut Context<Self>,
319 ) -> bool {
320 self.editor
321 .update(cx, |editor, cx| editor.navigate(data, window, cx))
322 }
323
324 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
325 ToolbarItemLocation::PrimaryLeft
326 }
327
328 fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
329 self.editor.breadcrumbs(cx)
330 }
331
332 fn added_to_workspace(
333 &mut self,
334 workspace: &mut Workspace,
335 window: &mut Window,
336 cx: &mut Context<Self>,
337 ) {
338 self.editor.update(cx, |editor, cx| {
339 editor.added_to_workspace(workspace, window, cx)
340 });
341 }
342
343 fn can_save(&self, cx: &App) -> bool {
344 // The editor handles the new buffer, so delegate to it
345 self.editor.read(cx).can_save(cx)
346 }
347
348 fn save(
349 &mut self,
350 options: SaveOptions,
351 project: Entity<Project>,
352 window: &mut Window,
353 cx: &mut Context<Self>,
354 ) -> Task<Result<()>> {
355 // Delegate saving to the editor, which manages the new buffer
356 self.editor
357 .update(cx, |editor, cx| editor.save(options, project, window, cx))
358 }
359}
360
361impl Render for FileDiffView {
362 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
363 self.editor.clone()
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use editor::test::editor_test_context::assert_state_with_diff;
371 use gpui::TestAppContext;
372 use project::{FakeFs, Fs, Project};
373 use settings::SettingsStore;
374 use std::path::PathBuf;
375 use unindent::unindent;
376 use util::path;
377 use workspace::Workspace;
378
379 fn init_test(cx: &mut TestAppContext) {
380 cx.update(|cx| {
381 let settings_store = SettingsStore::test(cx);
382 cx.set_global(settings_store);
383 theme::init(theme::LoadThemes::JustBase, cx);
384 });
385 }
386
387 #[gpui::test]
388 async fn test_diff_view(cx: &mut TestAppContext) {
389 init_test(cx);
390
391 let fs = FakeFs::new(cx.executor());
392 fs.insert_tree(
393 path!("/test"),
394 serde_json::json!({
395 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
396 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
397 }),
398 )
399 .await;
400
401 let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
402
403 let (workspace, cx) =
404 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
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,
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 (workspace, cx) =
538 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
539
540 let diff_view = workspace
541 .update_in(cx, |workspace, window, cx| {
542 FileDiffView::open(
543 PathBuf::from(path!("/test/old_file.txt")),
544 PathBuf::from(path!("/test/new_file.txt")),
545 workspace,
546 window,
547 cx,
548 )
549 })
550 .await
551 .unwrap();
552
553 diff_view.update_in(cx, |diff_view, window, cx| {
554 diff_view.editor.update(cx, |editor, cx| {
555 editor.insert("modified ", window, cx);
556 });
557 });
558
559 diff_view.update_in(cx, |diff_view, _, cx| {
560 let buffer = diff_view.new_buffer.read(cx);
561 assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
562 });
563
564 let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
565 workspace::Item::save(
566 diff_view,
567 workspace::item::SaveOptions::default(),
568 project.clone(),
569 window,
570 cx,
571 )
572 });
573
574 save_task.await.expect("Save should succeed");
575
576 let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
577 assert_eq!(
578 saved_content,
579 "modified new line 1\nline 2\nnew line 3\nline 4\n"
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 not be dirty after save");
585 });
586 }
587}