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 gpui::TestAppContext;
362 use project::{FakeFs, Fs, Project};
363 use settings::SettingsStore;
364 use std::path::PathBuf;
365 use unindent::unindent;
366 use util::path;
367 use workspace::Workspace;
368
369 fn init_test(cx: &mut TestAppContext) {
370 cx.update(|cx| {
371 let settings_store = SettingsStore::test(cx);
372 cx.set_global(settings_store);
373 language::init(cx);
374 Project::init_settings(cx);
375 workspace::init_settings(cx);
376 editor::init_settings(cx);
377 theme::init(theme::LoadThemes::JustBase, cx);
378 });
379 }
380
381 #[gpui::test]
382 async fn test_diff_view(cx: &mut TestAppContext) {
383 init_test(cx);
384
385 let fs = FakeFs::new(cx.executor());
386 fs.insert_tree(
387 path!("/test"),
388 serde_json::json!({
389 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
390 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
391 }),
392 )
393 .await;
394
395 let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
396
397 let (workspace, cx) =
398 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
399
400 let diff_view = workspace
401 .update_in(cx, |workspace, window, cx| {
402 FileDiffView::open(
403 path!("/test/old_file.txt").into(),
404 path!("/test/new_file.txt").into(),
405 workspace,
406 window,
407 cx,
408 )
409 })
410 .await
411 .unwrap();
412
413 // Verify initial diff
414 assert_state_with_diff(
415 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
416 cx,
417 &unindent(
418 "
419 - old line 1
420 + ˇnew line 1
421 line 2
422 - old line 3
423 + new line 3
424 line 4
425 ",
426 ),
427 );
428
429 // Modify the new file on disk
430 fs.save(
431 path!("/test/new_file.txt").as_ref(),
432 &unindent(
433 "
434 new line 1
435 line 2
436 new line 3
437 line 4
438 new line 5
439 ",
440 )
441 .into(),
442 Default::default(),
443 )
444 .await
445 .unwrap();
446
447 // The diff now reflects the changes to the new file
448 cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
449 assert_state_with_diff(
450 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
451 cx,
452 &unindent(
453 "
454 - old line 1
455 + ˇnew line 1
456 line 2
457 - old line 3
458 + new line 3
459 line 4
460 + new line 5
461 ",
462 ),
463 );
464
465 // Modify the old file on disk
466 fs.save(
467 path!("/test/old_file.txt").as_ref(),
468 &unindent(
469 "
470 new line 1
471 line 2
472 old line 3
473 line 4
474 ",
475 )
476 .into(),
477 Default::default(),
478 )
479 .await
480 .unwrap();
481
482 // The diff now reflects the changes to the new file
483 cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
484 assert_state_with_diff(
485 &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
486 cx,
487 &unindent(
488 "
489 ˇnew line 1
490 line 2
491 - old line 3
492 + new line 3
493 line 4
494 + new line 5
495 ",
496 ),
497 );
498
499 diff_view.read_with(cx, |diff_view, cx| {
500 assert_eq!(
501 diff_view.tab_content_text(0, cx),
502 "old_file.txt ↔ new_file.txt"
503 );
504 assert_eq!(
505 diff_view.tab_tooltip_text(cx).unwrap(),
506 format!(
507 "{} ↔ {}",
508 path!("test/old_file.txt"),
509 path!("test/new_file.txt")
510 )
511 );
512 })
513 }
514
515 #[gpui::test]
516 async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) {
517 init_test(cx);
518
519 let fs = FakeFs::new(cx.executor());
520 fs.insert_tree(
521 path!("/test"),
522 serde_json::json!({
523 "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
524 "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
525 }),
526 )
527 .await;
528
529 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
530
531 let (workspace, cx) =
532 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
533
534 let diff_view = workspace
535 .update_in(cx, |workspace, window, cx| {
536 FileDiffView::open(
537 PathBuf::from(path!("/test/old_file.txt")),
538 PathBuf::from(path!("/test/new_file.txt")),
539 workspace,
540 window,
541 cx,
542 )
543 })
544 .await
545 .unwrap();
546
547 diff_view.update_in(cx, |diff_view, window, cx| {
548 diff_view.editor.update(cx, |editor, cx| {
549 editor.insert("modified ", window, cx);
550 });
551 });
552
553 diff_view.update_in(cx, |diff_view, _, cx| {
554 let buffer = diff_view.new_buffer.read(cx);
555 assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
556 });
557
558 let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
559 workspace::Item::save(
560 diff_view,
561 workspace::item::SaveOptions::default(),
562 project.clone(),
563 window,
564 cx,
565 )
566 });
567
568 save_task.await.expect("Save should succeed");
569
570 let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
571 assert_eq!(
572 saved_content,
573 "modified new line 1\nline 2\nnew line 3\nline 4\n"
574 );
575
576 diff_view.update_in(cx, |diff_view, _, cx| {
577 let buffer = diff_view.new_buffer.read(cx);
578 assert!(!buffer.is_dirty(), "Buffer should not be dirty after save");
579 });
580 }
581}