1//! FileDiffView provides a UI for displaying differences between two buffers.
2
3use anyhow::Result;
4use buffer_diff::{BufferDiff, BufferDiffSnapshot};
5use editor::{Editor, EditorEvent, MultiBuffer};
6use futures::{FutureExt, select_biased};
7use gpui::{
8 AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
9 FocusHandle, Focusable, IntoElement, Render, Task, Window,
10};
11use language::Buffer;
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
56 let buffer_diff = build_buffer_diff(&old_buffer, &new_buffer, cx).await?;
57
58 workspace.update_in(cx, |workspace, window, cx| {
59 let diff_view = cx.new(|cx| {
60 FileDiffView::new(
61 old_buffer,
62 new_buffer,
63 buffer_diff,
64 project.clone(),
65 window,
66 cx,
67 )
68 });
69
70 let pane = workspace.active_pane();
71 pane.update(cx, |pane, cx| {
72 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
73 });
74
75 diff_view
76 })
77 })
78 }
79
80 pub fn new(
81 old_buffer: Entity<Buffer>,
82 new_buffer: Entity<Buffer>,
83 diff: Entity<BufferDiff>,
84 project: Entity<Project>,
85 window: &mut Window,
86 cx: &mut Context<Self>,
87 ) -> Self {
88 let multibuffer = cx.new(|cx| {
89 let mut multibuffer = MultiBuffer::singleton(new_buffer.clone(), cx);
90 multibuffer.add_diff(diff.clone(), cx);
91 multibuffer
92 });
93 let editor = cx.new(|cx| {
94 let mut editor =
95 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
96 editor.start_temporary_diff_override();
97 editor.disable_diagnostics(cx);
98 editor.set_expand_all_diff_hunks(cx);
99 editor.set_render_diff_hunk_controls(
100 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
101 cx,
102 );
103 editor
104 });
105
106 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
107
108 for buffer in [&old_buffer, &new_buffer] {
109 cx.subscribe(buffer, move |this, _, event, _| match event {
110 language::BufferEvent::Edited
111 | language::BufferEvent::LanguageChanged
112 | language::BufferEvent::Reparsed => {
113 this.buffer_changes_tx.send(()).ok();
114 }
115 _ => {}
116 })
117 .detach();
118 }
119
120 Self {
121 editor,
122 buffer_changes_tx,
123 old_buffer,
124 new_buffer,
125 _recalculate_diff_task: cx.spawn(async move |this, cx| {
126 while buffer_changes_rx.recv().await.is_ok() {
127 loop {
128 let mut timer = cx
129 .background_executor()
130 .timer(RECALCULATE_DIFF_DEBOUNCE)
131 .fuse();
132 let mut recv = pin!(buffer_changes_rx.recv().fuse());
133 select_biased! {
134 _ = timer => break,
135 _ = recv => continue,
136 }
137 }
138
139 log::trace!("start recalculating");
140 let (old_snapshot, new_snapshot) = this.update(cx, |this, cx| {
141 (
142 this.old_buffer.read(cx).snapshot(),
143 this.new_buffer.read(cx).snapshot(),
144 )
145 })?;
146 let diff_snapshot = cx
147 .update(|cx| {
148 BufferDiffSnapshot::new_with_base_buffer(
149 new_snapshot.text.clone(),
150 Some(old_snapshot.text().into()),
151 old_snapshot,
152 cx,
153 )
154 })?
155 .await;
156 diff.update(cx, |diff, cx| {
157 diff.set_snapshot(diff_snapshot, &new_snapshot, cx)
158 })?;
159 log::trace!("finish recalculating");
160 }
161 Ok(())
162 }),
163 }
164 }
165}
166
167async fn build_buffer_diff(
168 old_buffer: &Entity<Buffer>,
169 new_buffer: &Entity<Buffer>,
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_snapshot = cx
176 .update(|cx| {
177 BufferDiffSnapshot::new_with_base_buffer(
178 new_buffer_snapshot.text.clone(),
179 Some(old_buffer_snapshot.text().into()),
180 old_buffer_snapshot,
181 cx,
182 )
183 })?
184 .await;
185
186 cx.new(|cx| {
187 let mut diff = BufferDiff::new(&new_buffer_snapshot.text, cx);
188 diff.set_snapshot(diff_snapshot, &new_buffer_snapshot.text, cx);
189 diff
190 })
191}
192
193impl EventEmitter<EditorEvent> for FileDiffView {}
194
195impl Focusable for FileDiffView {
196 fn focus_handle(&self, cx: &App) -> FocusHandle {
197 self.editor.focus_handle(cx)
198 }
199}
200
201impl Item for FileDiffView {
202 type Event = EditorEvent;
203
204 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
205 Some(Icon::new(IconName::Diff).color(Color::Muted))
206 }
207
208 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
209 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
210 .color(if params.selected {
211 Color::Default
212 } else {
213 Color::Muted
214 })
215 .into_any_element()
216 }
217
218 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
219 let title_text = |buffer: &Entity<Buffer>| {
220 buffer
221 .read(cx)
222 .file()
223 .and_then(|file| {
224 Some(
225 file.full_path(cx)
226 .file_name()?
227 .to_string_lossy()
228 .to_string(),
229 )
230 })
231 .unwrap_or_else(|| "untitled".into())
232 };
233 let old_filename = title_text(&self.old_buffer);
234 let new_filename = title_text(&self.new_buffer);
235
236 format!("{old_filename} ↔ {new_filename}").into()
237 }
238
239 fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
240 let path = |buffer: &Entity<Buffer>| {
241 buffer
242 .read(cx)
243 .file()
244 .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
245 .unwrap_or_else(|| "untitled".into())
246 };
247 let old_path = path(&self.old_buffer);
248 let new_path = path(&self.new_buffer);
249
250 Some(format!("{old_path} ↔ {new_path}").into())
251 }
252
253 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
254 Editor::to_item_events(event, f)
255 }
256
257 fn telemetry_event_text(&self) -> Option<&'static str> {
258 Some("Diff View Opened")
259 }
260
261 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
262 self.editor
263 .update(cx, |editor, cx| editor.deactivated(window, cx));
264 }
265
266 fn is_singleton(&self, _: &App) -> bool {
267 false
268 }
269
270 fn act_as_type<'a>(
271 &'a self,
272 type_id: TypeId,
273 self_handle: &'a Entity<Self>,
274 _: &'a App,
275 ) -> Option<AnyView> {
276 if type_id == TypeId::of::<Self>() {
277 Some(self_handle.to_any())
278 } else if type_id == TypeId::of::<Editor>() {
279 Some(self.editor.to_any())
280 } else {
281 None
282 }
283 }
284
285 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
286 Some(Box::new(self.editor.clone()))
287 }
288
289 fn for_each_project_item(
290 &self,
291 cx: &App,
292 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
293 ) {
294 self.editor.for_each_project_item(cx, f)
295 }
296
297 fn set_nav_history(
298 &mut self,
299 nav_history: ItemNavHistory,
300 _: &mut Window,
301 cx: &mut Context<Self>,
302 ) {
303 self.editor.update(cx, |editor, _| {
304 editor.set_nav_history(Some(nav_history));
305 });
306 }
307
308 fn navigate(
309 &mut self,
310 data: Box<dyn Any>,
311 window: &mut Window,
312 cx: &mut Context<Self>,
313 ) -> bool {
314 self.editor
315 .update(cx, |editor, cx| editor.navigate(data, window, cx))
316 }
317
318 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
319 ToolbarItemLocation::PrimaryLeft
320 }
321
322 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
323 self.editor.breadcrumbs(theme, cx)
324 }
325
326 fn added_to_workspace(
327 &mut self,
328 workspace: &mut Workspace,
329 window: &mut Window,
330 cx: &mut Context<Self>,
331 ) {
332 self.editor.update(cx, |editor, cx| {
333 editor.added_to_workspace(workspace, window, cx)
334 });
335 }
336
337 fn can_save(&self, cx: &App) -> bool {
338 // The editor handles the new buffer, so delegate to it
339 self.editor.read(cx).can_save(cx)
340 }
341
342 fn save(
343 &mut self,
344 options: SaveOptions,
345 project: Entity<Project>,
346 window: &mut Window,
347 cx: &mut Context<Self>,
348 ) -> Task<Result<()>> {
349 // Delegate saving to the editor, which manages the new buffer
350 self.editor
351 .update(cx, |editor, cx| editor.save(options, project, window, cx))
352 }
353}
354
355impl Render for FileDiffView {
356 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
357 self.editor.clone()
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use editor::test::editor_test_context::assert_state_with_diff;
365 use gpui::TestAppContext;
366 use project::{FakeFs, Fs, Project};
367 use settings::{Settings, SettingsStore};
368 use std::path::PathBuf;
369 use unindent::unindent;
370 use util::path;
371 use workspace::Workspace;
372
373 fn init_test(cx: &mut TestAppContext) {
374 cx.update(|cx| {
375 let settings_store = SettingsStore::test(cx);
376 cx.set_global(settings_store);
377 language::init(cx);
378 Project::init_settings(cx);
379 workspace::init_settings(cx);
380 editor::init_settings(cx);
381 theme::ThemeSettings::register(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}