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