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