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 act_as_type<'a>(
267 &'a self,
268 type_id: TypeId,
269 self_handle: &'a Entity<Self>,
270 _: &'a App,
271 ) -> Option<AnyView> {
272 if type_id == TypeId::of::<Self>() {
273 Some(self_handle.to_any())
274 } else if type_id == TypeId::of::<Editor>() {
275 Some(self.editor.to_any())
276 } else {
277 None
278 }
279 }
280
281 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
282 Some(Box::new(self.editor.clone()))
283 }
284
285 fn for_each_project_item(
286 &self,
287 cx: &App,
288 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
289 ) {
290 self.editor.for_each_project_item(cx, f)
291 }
292
293 fn set_nav_history(
294 &mut self,
295 nav_history: ItemNavHistory,
296 _: &mut Window,
297 cx: &mut Context<Self>,
298 ) {
299 self.editor.update(cx, |editor, _| {
300 editor.set_nav_history(Some(nav_history));
301 });
302 }
303
304 fn navigate(
305 &mut self,
306 data: Box<dyn Any>,
307 window: &mut Window,
308 cx: &mut Context<Self>,
309 ) -> bool {
310 self.editor
311 .update(cx, |editor, cx| editor.navigate(data, window, cx))
312 }
313
314 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
315 ToolbarItemLocation::PrimaryLeft
316 }
317
318 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
319 self.editor.breadcrumbs(theme, cx)
320 }
321
322 fn added_to_workspace(
323 &mut self,
324 workspace: &mut Workspace,
325 window: &mut Window,
326 cx: &mut Context<Self>,
327 ) {
328 self.editor.update(cx, |editor, cx| {
329 editor.added_to_workspace(workspace, window, cx)
330 });
331 }
332
333 fn can_save(&self, cx: &App) -> bool {
334 // The editor handles the new buffer, so delegate to it
335 self.editor.read(cx).can_save(cx)
336 }
337
338 fn save(
339 &mut self,
340 options: SaveOptions,
341 project: Entity<Project>,
342 window: &mut Window,
343 cx: &mut Context<Self>,
344 ) -> Task<Result<()>> {
345 // Delegate saving to the editor, which manages the new buffer
346 self.editor
347 .update(cx, |editor, cx| editor.save(options, project, window, cx))
348 }
349}
350
351impl Render for FileDiffView {
352 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
353 self.editor.clone()
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use editor::test::editor_test_context::assert_state_with_diff;
361 use encodings::Encoding;
362 use gpui::TestAppContext;
363 use language::Rope;
364 use project::{FakeFs, Fs, Project};
365 use settings::SettingsStore;
366 use std::path::PathBuf;
367 use unindent::unindent;
368 use util::path;
369 use workspace::Workspace;
370
371 fn init_test(cx: &mut TestAppContext) {
372 cx.update(|cx| {
373 let settings_store = SettingsStore::test(cx);
374 cx.set_global(settings_store);
375 language::init(cx);
376 Project::init_settings(cx);
377 workspace::init_settings(cx);
378 editor::init_settings(cx);
379 theme::init(theme::LoadThemes::JustBase, cx);
380 });
381 }
382
383 #[gpui::test]
384 async fn test_diff_view(cx: &mut TestAppContext) {
385 init_test(cx);
386
387 let fs = FakeFs::new(cx.executor());
388 fs.insert_tree(
389 path!("/test"),
390 serde_json::json!({
391 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
392 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
393 }),
394 )
395 .await;
396
397 let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
398
399 let (workspace, cx) =
400 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
401
402 let diff_view = workspace
403 .update_in(cx, |workspace, window, cx| {
404 FileDiffView::open(
405 path!("/test/old_file.txt").into(),
406 path!("/test/new_file.txt").into(),
407 workspace,
408 window,
409 cx,
410 )
411 })
412 .await
413 .unwrap();
414
415 // Verify initial diff
416 assert_state_with_diff(
417 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
418 cx,
419 &unindent(
420 "
421 - old line 1
422 + ˇnew line 1
423 line 2
424 - old line 3
425 + new line 3
426 line 4
427 ",
428 ),
429 );
430
431 // Modify the new file on disk
432 fs.save(
433 path!("/test/new_file.txt").as_ref(),
434 &Rope::from_str_small(&unindent(
435 "
436 new line 1
437 line 2
438 new line 3
439 line 4
440 new line 5
441 ",
442 )),
443 Default::default(),
444 Encoding::default(),
445 )
446 .await
447 .unwrap();
448
449 // The diff now reflects the changes to the new file
450 cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
451 assert_state_with_diff(
452 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
453 cx,
454 &unindent(
455 "
456 - old line 1
457 + ˇnew line 1
458 line 2
459 - old line 3
460 + new line 3
461 line 4
462 + new line 5
463 ",
464 ),
465 );
466
467 // Modify the old file on disk
468 fs.save(
469 path!("/test/old_file.txt").as_ref(),
470 &Rope::from_str_small(&unindent(
471 "
472 new line 1
473 line 2
474 old line 3
475 line 4
476 ",
477 )),
478 Default::default(),
479 Encoding::default(),
480 )
481 .await
482 .unwrap();
483
484 // The diff now reflects the changes to the new file
485 cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
486 assert_state_with_diff(
487 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
488 cx,
489 &unindent(
490 "
491 ˇnew line 1
492 line 2
493 - old line 3
494 + new line 3
495 line 4
496 + new line 5
497 ",
498 ),
499 );
500
501 diff_view.read_with(cx, |diff_view, cx| {
502 assert_eq!(
503 diff_view.tab_content_text(0, cx),
504 "old_file.txt ↔ new_file.txt"
505 );
506 assert_eq!(
507 diff_view.tab_tooltip_text(cx).unwrap(),
508 format!(
509 "{} ↔ {}",
510 path!("test/old_file.txt"),
511 path!("test/new_file.txt")
512 )
513 );
514 })
515 }
516
517 #[gpui::test]
518 async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) {
519 init_test(cx);
520
521 let fs = FakeFs::new(cx.executor());
522 fs.insert_tree(
523 path!("/test"),
524 serde_json::json!({
525 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
526 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
527 }),
528 )
529 .await;
530
531 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
532
533 let (workspace, cx) =
534 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
535
536 let diff_view = workspace
537 .update_in(cx, |workspace, window, cx| {
538 FileDiffView::open(
539 PathBuf::from(path!("/test/old_file.txt")),
540 PathBuf::from(path!("/test/new_file.txt")),
541 workspace,
542 window,
543 cx,
544 )
545 })
546 .await
547 .unwrap();
548
549 diff_view.update_in(cx, |diff_view, window, cx| {
550 diff_view.editor.update(cx, |editor, cx| {
551 editor.insert("modified ", window, cx);
552 });
553 });
554
555 diff_view.update_in(cx, |diff_view, _, cx| {
556 let buffer = diff_view.new_buffer.read(cx);
557 assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
558 });
559
560 let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
561 workspace::Item::save(
562 diff_view,
563 workspace::item::SaveOptions::default(),
564 project.clone(),
565 window,
566 cx,
567 )
568 });
569
570 save_task.await.expect("Save should succeed");
571
572 let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
573 assert_eq!(
574 saved_content,
575 "modified new line 1\nline 2\nnew line 3\nline 4\n"
576 );
577
578 diff_view.update_in(cx, |diff_view, _, cx| {
579 let buffer = diff_view.new_buffer.read(cx);
580 assert!(!buffer.is_dirty(), "Buffer should not be dirty after save");
581 });
582 }
583}