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 pub fn open(
40 old_path: PathBuf,
41 new_path: PathBuf,
42 workspace: &Workspace,
43 window: &mut Window,
44 cx: &mut App,
45 ) -> Task<Result<Entity<Self>>> {
46 let workspace = workspace.weak_handle();
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
165async fn build_buffer_diff(
166 old_buffer: &Entity<Buffer>,
167 new_buffer: &Entity<Buffer>,
168 language_registry: Arc<LanguageRegistry>,
169 cx: &mut AsyncApp,
170) -> Result<Entity<BufferDiff>> {
171 let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot());
172 let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot());
173
174 let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
175
176 let update = diff
177 .update(cx, |diff, cx| {
178 diff.update_diff(
179 new_buffer_snapshot.text.clone(),
180 Some(old_buffer_snapshot.text().into()),
181 true,
182 new_buffer_snapshot.language().cloned(),
183 cx,
184 )
185 })
186 .await;
187
188 diff.update(cx, |diff, cx| {
189 diff.language_changed(
190 new_buffer_snapshot.language().cloned(),
191 Some(language_registry),
192 cx,
193 );
194 diff.set_snapshot(update, &new_buffer_snapshot.text, cx)
195 })
196 .await;
197
198 Ok(diff)
199}
200
201impl EventEmitter<EditorEvent> for FileDiffView {}
202
203impl Focusable for FileDiffView {
204 fn focus_handle(&self, cx: &App) -> FocusHandle {
205 self.editor.focus_handle(cx)
206 }
207}
208
209impl Item for FileDiffView {
210 type Event = EditorEvent;
211
212 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
213 Some(Icon::new(IconName::Diff).color(Color::Muted))
214 }
215
216 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
217 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
218 .color(if params.selected {
219 Color::Default
220 } else {
221 Color::Muted
222 })
223 .into_any_element()
224 }
225
226 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
227 let title_text = |buffer: &Entity<Buffer>| {
228 buffer
229 .read(cx)
230 .file()
231 .and_then(|file| {
232 Some(
233 file.full_path(cx)
234 .file_name()?
235 .to_string_lossy()
236 .to_string(),
237 )
238 })
239 .unwrap_or_else(|| "untitled".into())
240 };
241 let old_filename = title_text(&self.old_buffer);
242 let new_filename = title_text(&self.new_buffer);
243
244 format!("{old_filename} ↔ {new_filename}").into()
245 }
246
247 fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
248 let path = |buffer: &Entity<Buffer>| {
249 buffer
250 .read(cx)
251 .file()
252 .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
253 .unwrap_or_else(|| "untitled".into())
254 };
255 let old_path = path(&self.old_buffer);
256 let new_path = path(&self.new_buffer);
257
258 Some(format!("{old_path} ↔ {new_path}").into())
259 }
260
261 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
262 Editor::to_item_events(event, f)
263 }
264
265 fn telemetry_event_text(&self) -> Option<&'static str> {
266 Some("Diff View Opened")
267 }
268
269 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
270 self.editor
271 .update(cx, |editor, cx| editor.deactivated(window, cx));
272 }
273
274 fn act_as_type<'a>(
275 &'a self,
276 type_id: TypeId,
277 self_handle: &'a Entity<Self>,
278 _: &'a App,
279 ) -> Option<gpui::AnyEntity> {
280 if type_id == TypeId::of::<Self>() {
281 Some(self_handle.clone().into())
282 } else if type_id == TypeId::of::<Editor>() {
283 Some(self.editor.clone().into())
284 } else {
285 None
286 }
287 }
288
289 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
290 Some(Box::new(self.editor.clone()))
291 }
292
293 fn for_each_project_item(
294 &self,
295 cx: &App,
296 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
297 ) {
298 self.editor.for_each_project_item(cx, f)
299 }
300
301 fn set_nav_history(
302 &mut self,
303 nav_history: ItemNavHistory,
304 _: &mut Window,
305 cx: &mut Context<Self>,
306 ) {
307 self.editor.update(cx, |editor, _| {
308 editor.set_nav_history(Some(nav_history));
309 });
310 }
311
312 fn navigate(
313 &mut self,
314 data: Box<dyn Any>,
315 window: &mut Window,
316 cx: &mut Context<Self>,
317 ) -> bool {
318 self.editor
319 .update(cx, |editor, cx| editor.navigate(data, window, cx))
320 }
321
322 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
323 ToolbarItemLocation::PrimaryLeft
324 }
325
326 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
327 self.editor.breadcrumbs(theme, cx)
328 }
329
330 fn added_to_workspace(
331 &mut self,
332 workspace: &mut Workspace,
333 window: &mut Window,
334 cx: &mut Context<Self>,
335 ) {
336 self.editor.update(cx, |editor, cx| {
337 editor.added_to_workspace(workspace, window, cx)
338 });
339 }
340
341 fn can_save(&self, cx: &App) -> bool {
342 // The editor handles the new buffer, so delegate to it
343 self.editor.read(cx).can_save(cx)
344 }
345
346 fn save(
347 &mut self,
348 options: SaveOptions,
349 project: Entity<Project>,
350 window: &mut Window,
351 cx: &mut Context<Self>,
352 ) -> Task<Result<()>> {
353 // Delegate saving to the editor, which manages the new buffer
354 self.editor
355 .update(cx, |editor, cx| editor.save(options, project, window, cx))
356 }
357}
358
359impl Render for FileDiffView {
360 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
361 self.editor.clone()
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use editor::test::editor_test_context::assert_state_with_diff;
369 use gpui::TestAppContext;
370 use project::{FakeFs, Fs, Project};
371 use settings::SettingsStore;
372 use std::path::PathBuf;
373 use unindent::unindent;
374 use util::path;
375 use workspace::Workspace;
376
377 fn init_test(cx: &mut TestAppContext) {
378 cx.update(|cx| {
379 let settings_store = SettingsStore::test(cx);
380 cx.set_global(settings_store);
381 theme::init(theme::LoadThemes::JustBase, cx);
382 });
383 }
384
385 #[gpui::test]
386 async fn test_diff_view(cx: &mut TestAppContext) {
387 init_test(cx);
388
389 let fs = FakeFs::new(cx.executor());
390 fs.insert_tree(
391 path!("/test"),
392 serde_json::json!({
393 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
394 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
395 }),
396 )
397 .await;
398
399 let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
400
401 let (workspace, cx) =
402 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
403
404 let diff_view = workspace
405 .update_in(cx, |workspace, window, cx| {
406 FileDiffView::open(
407 path!("/test/old_file.txt").into(),
408 path!("/test/new_file.txt").into(),
409 workspace,
410 window,
411 cx,
412 )
413 })
414 .await
415 .unwrap();
416
417 // Verify initial diff
418 assert_state_with_diff(
419 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
420 cx,
421 &unindent(
422 "
423 - old line 1
424 + ˇnew line 1
425 line 2
426 - old line 3
427 + new line 3
428 line 4
429 ",
430 ),
431 );
432
433 // Modify the new file on disk
434 fs.save(
435 path!("/test/new_file.txt").as_ref(),
436 &unindent(
437 "
438 new line 1
439 line 2
440 new line 3
441 line 4
442 new line 5
443 ",
444 )
445 .into(),
446 Default::default(),
447 )
448 .await
449 .unwrap();
450
451 // The diff now reflects the changes to the new file
452 cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
453 assert_state_with_diff(
454 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
455 cx,
456 &unindent(
457 "
458 - old line 1
459 + ˇnew line 1
460 line 2
461 - old line 3
462 + new line 3
463 line 4
464 + new line 5
465 ",
466 ),
467 );
468
469 // Modify the old file on disk
470 fs.save(
471 path!("/test/old_file.txt").as_ref(),
472 &unindent(
473 "
474 new line 1
475 line 2
476 old line 3
477 line 4
478 ",
479 )
480 .into(),
481 Default::default(),
482 )
483 .await
484 .unwrap();
485
486 // The diff now reflects the changes to the new file
487 cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
488 assert_state_with_diff(
489 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
490 cx,
491 &unindent(
492 "
493 ˇnew line 1
494 line 2
495 - old line 3
496 + new line 3
497 line 4
498 + new line 5
499 ",
500 ),
501 );
502
503 diff_view.read_with(cx, |diff_view, cx| {
504 assert_eq!(
505 diff_view.tab_content_text(0, cx),
506 "old_file.txt ↔ new_file.txt"
507 );
508 assert_eq!(
509 diff_view.tab_tooltip_text(cx).unwrap(),
510 format!(
511 "{} ↔ {}",
512 path!("test/old_file.txt"),
513 path!("test/new_file.txt")
514 )
515 );
516 })
517 }
518
519 #[gpui::test]
520 async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) {
521 init_test(cx);
522
523 let fs = FakeFs::new(cx.executor());
524 fs.insert_tree(
525 path!("/test"),
526 serde_json::json!({
527 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
528 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
529 }),
530 )
531 .await;
532
533 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
534
535 let (workspace, cx) =
536 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
537
538 let diff_view = workspace
539 .update_in(cx, |workspace, window, cx| {
540 FileDiffView::open(
541 PathBuf::from(path!("/test/old_file.txt")),
542 PathBuf::from(path!("/test/new_file.txt")),
543 workspace,
544 window,
545 cx,
546 )
547 })
548 .await
549 .unwrap();
550
551 diff_view.update_in(cx, |diff_view, window, cx| {
552 diff_view.editor.update(cx, |editor, cx| {
553 editor.insert("modified ", window, cx);
554 });
555 });
556
557 diff_view.update_in(cx, |diff_view, _, cx| {
558 let buffer = diff_view.new_buffer.read(cx);
559 assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
560 });
561
562 let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
563 workspace::Item::save(
564 diff_view,
565 workspace::item::SaveOptions::default(),
566 project.clone(),
567 window,
568 cx,
569 )
570 });
571
572 save_task.await.expect("Save should succeed");
573
574 let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
575 assert_eq!(
576 saved_content,
577 "modified new line 1\nline 2\nnew line 3\nline 4\n"
578 );
579
580 diff_view.update_in(cx, |diff_view, _, cx| {
581 let buffer = diff_view.new_buffer.read(cx);
582 assert!(!buffer.is_dirty(), "Buffer should not be dirty after save");
583 });
584 }
585}