project_diff.rs
1use std::{
2 any::{Any, TypeId},
3 cmp::Ordering,
4 collections::HashSet,
5 ops::Range,
6 time::Duration,
7};
8
9use anyhow::Context as _;
10use collections::{BTreeMap, HashMap};
11use feature_flags::FeatureFlagAppExt;
12use git::{
13 diff::{BufferDiff, DiffHunk},
14 repository::GitFileStatus,
15};
16use gpui::{
17 actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView,
18 InteractiveElement, Model, Render, Subscription, Task, View, WeakView,
19};
20use language::{Buffer, BufferRow};
21use multi_buffer::{ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer};
22use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
23use text::{OffsetRangeExt, ToPoint};
24use theme::ActiveTheme;
25use ui::{
26 div, h_flex, Color, Context, FluentBuilder, Icon, IconName, IntoElement, Label, LabelCommon,
27 ParentElement, SharedString, Styled, ViewContext, VisualContext, WindowContext,
28};
29use util::{paths::compare_paths, ResultExt};
30use workspace::{
31 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
32 ItemNavHistory, ToolbarItemLocation, Workspace,
33};
34
35use crate::{Editor, EditorEvent, DEFAULT_MULTIBUFFER_CONTEXT};
36
37actions!(project_diff, [Deploy]);
38
39pub fn init(cx: &mut AppContext) {
40 cx.observe_new_views(ProjectDiffEditor::register).detach();
41}
42
43const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
44
45struct ProjectDiffEditor {
46 buffer_changes: BTreeMap<WorktreeId, HashMap<ProjectEntryId, Changes>>,
47 entry_order: HashMap<WorktreeId, Vec<(ProjectPath, ProjectEntryId)>>,
48 excerpts: Model<MultiBuffer>,
49 editor: View<Editor>,
50
51 project: Model<Project>,
52 workspace: WeakView<Workspace>,
53 focus_handle: FocusHandle,
54 worktree_rescans: HashMap<WorktreeId, Task<()>>,
55 _subscriptions: Vec<Subscription>,
56}
57
58#[derive(Debug)]
59struct Changes {
60 _status: GitFileStatus,
61 buffer: Model<Buffer>,
62 hunks: Vec<DiffHunk>,
63}
64
65impl ProjectDiffEditor {
66 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
67 workspace.register_action(Self::deploy);
68 }
69
70 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
71 if !cx.is_staff() {
72 return;
73 }
74
75 if let Some(existing) = workspace.item_of_type::<Self>(cx) {
76 workspace.activate_item(&existing, true, true, cx);
77 } else {
78 let workspace_handle = cx.view().downgrade();
79 let project_diff =
80 cx.new_view(|cx| Self::new(workspace.project().clone(), workspace_handle, cx));
81 workspace.add_item_to_active_pane(Box::new(project_diff), None, true, cx);
82 }
83 }
84
85 fn new(
86 project: Model<Project>,
87 workspace: WeakView<Workspace>,
88 cx: &mut ViewContext<Self>,
89 ) -> Self {
90 // TODO diff change subscriptions. For that, needed:
91 // * `-20/+50` stats retrieval: some background process that reacts on file changes
92 let focus_handle = cx.focus_handle();
93 let changed_entries_subscription =
94 cx.subscribe(&project, |project_diff_editor, _, e, cx| {
95 let mut worktree_to_rescan = None;
96 match e {
97 project::Event::WorktreeAdded(id) => {
98 worktree_to_rescan = Some(*id);
99 // project_diff_editor
100 // .buffer_changes
101 // .insert(*id, HashMap::default());
102 }
103 project::Event::WorktreeRemoved(id) => {
104 project_diff_editor.buffer_changes.remove(id);
105 }
106 project::Event::WorktreeUpdatedEntries(id, _updated_entries) => {
107 // TODO cannot invalidate buffer entries without invalidating the corresponding excerpts and order entries.
108 worktree_to_rescan = Some(*id);
109 // let entry_changes =
110 // project_diff_editor.buffer_changes.entry(*id).or_default();
111 // for (_, entry_id, change) in updated_entries.iter() {
112 // let changes = entry_changes.entry(*entry_id);
113 // match change {
114 // project::PathChange::Removed => {
115 // if let hash_map::Entry::Occupied(entry) = changes {
116 // entry.remove();
117 // }
118 // }
119 // // TODO understand the invalidation case better: now, we do that but still rescan the entire worktree
120 // // What if we already have the buffer loaded inside the diff multi buffer and it was edited there? We should not do anything.
121 // _ => match changes {
122 // hash_map::Entry::Occupied(mut o) => o.get_mut().invalidate(),
123 // hash_map::Entry::Vacant(v) => {
124 // v.insert(None);
125 // }
126 // },
127 // }
128 // }
129 }
130 project::Event::WorktreeUpdatedGitRepositories(id) => {
131 worktree_to_rescan = Some(*id);
132 // project_diff_editor.buffer_changes.clear();
133 }
134 project::Event::DeletedEntry(id, _entry_id) => {
135 worktree_to_rescan = Some(*id);
136 // if let Some(entries) = project_diff_editor.buffer_changes.get_mut(id) {
137 // entries.remove(entry_id);
138 // }
139 }
140 project::Event::Closed => {
141 project_diff_editor.buffer_changes.clear();
142 }
143 _ => {}
144 }
145
146 if let Some(worktree_to_rescan) = worktree_to_rescan {
147 project_diff_editor.schedule_worktree_rescan(worktree_to_rescan, cx);
148 }
149 });
150
151 let excerpts = cx.new_model(|cx| MultiBuffer::new(project.read(cx).capability()));
152
153 let editor = cx.new_view(|cx| {
154 let mut diff_display_editor =
155 Editor::for_multibuffer(excerpts.clone(), Some(project.clone()), true, cx);
156 diff_display_editor.set_expand_all_diff_hunks();
157 diff_display_editor
158 });
159
160 let mut new_self = Self {
161 project,
162 workspace,
163 buffer_changes: BTreeMap::default(),
164 entry_order: HashMap::default(),
165 worktree_rescans: HashMap::default(),
166 focus_handle,
167 editor,
168 excerpts,
169 _subscriptions: vec![changed_entries_subscription],
170 };
171 new_self.schedule_rescan_all(cx);
172 new_self
173 }
174
175 fn schedule_rescan_all(&mut self, cx: &mut ViewContext<Self>) {
176 let mut current_worktrees = HashSet::<WorktreeId>::default();
177 for worktree in self.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
178 let worktree_id = worktree.read(cx).id();
179 current_worktrees.insert(worktree_id);
180 self.schedule_worktree_rescan(worktree_id, cx);
181 }
182
183 self.worktree_rescans
184 .retain(|worktree_id, _| current_worktrees.contains(worktree_id));
185 self.buffer_changes
186 .retain(|worktree_id, _| current_worktrees.contains(worktree_id));
187 self.entry_order
188 .retain(|worktree_id, _| current_worktrees.contains(worktree_id));
189 }
190
191 fn schedule_worktree_rescan(&mut self, id: WorktreeId, cx: &mut ViewContext<Self>) {
192 let project = self.project.clone();
193 self.worktree_rescans.insert(
194 id,
195 cx.spawn(|project_diff_editor, mut cx| async move {
196 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
197 let open_tasks = project
198 .update(&mut cx, |project, cx| {
199 let worktree = project.worktree_for_id(id, cx)?;
200 let applicable_entries = worktree
201 .read(cx)
202 .entries(false, 0)
203 .filter(|entry| !entry.is_external)
204 .filter(|entry| entry.is_file())
205 .filter_map(|entry| Some((entry.git_status?, entry)))
206 .filter_map(|(git_status, entry)| {
207 Some((git_status, entry.id, project.path_for_entry(entry.id, cx)?))
208 })
209 .collect::<Vec<_>>();
210 Some(
211 applicable_entries
212 .into_iter()
213 .map(|(status, entry_id, entry_path)| {
214 let open_task = project.open_path(entry_path.clone(), cx);
215 (status, entry_id, entry_path, open_task)
216 })
217 .collect::<Vec<_>>(),
218 )
219 })
220 .ok()
221 .flatten()
222 .unwrap_or_default();
223
224 let Some((buffers, mut new_entries, change_sets)) = cx
225 .spawn(|mut cx| async move {
226 let mut new_entries = Vec::new();
227 let mut buffers = HashMap::<
228 ProjectEntryId,
229 (
230 GitFileStatus,
231 text::BufferSnapshot,
232 Model<Buffer>,
233 BufferDiff,
234 ),
235 >::default();
236 let mut change_sets = Vec::new();
237 for (status, entry_id, entry_path, open_task) in open_tasks {
238 let (_, opened_model) = open_task.await.with_context(|| {
239 format!("loading buffer {:?} for git diff", entry_path.path)
240 })?;
241 let buffer = match opened_model.downcast::<Buffer>() {
242 Ok(buffer) => buffer,
243 Err(_model) => anyhow::bail!(
244 "Could not load {:?} as a buffer for git diff",
245 entry_path.path
246 ),
247 };
248
249 let change_set = project
250 .update(&mut cx, |project, cx| {
251 project.open_unstaged_changes(buffer.clone(), cx)
252 })?
253 .await?;
254
255 cx.update(|cx| {
256 buffers.insert(
257 entry_id,
258 (
259 status,
260 buffer.read(cx).text_snapshot(),
261 buffer,
262 change_set.read(cx).diff_to_buffer.clone(),
263 ),
264 );
265 })?;
266 change_sets.push(change_set);
267 new_entries.push((entry_path, entry_id));
268 }
269
270 Ok((buffers, new_entries, change_sets))
271 })
272 .await
273 .log_err()
274 else {
275 return;
276 };
277
278 let (new_changes, new_entry_order) = cx
279 .background_executor()
280 .spawn(async move {
281 let mut new_changes = HashMap::<ProjectEntryId, Changes>::default();
282 for (entry_id, (status, buffer_snapshot, buffer, buffer_diff)) in buffers {
283 new_changes.insert(
284 entry_id,
285 Changes {
286 _status: status,
287 buffer,
288 hunks: buffer_diff
289 .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot)
290 .collect::<Vec<_>>(),
291 },
292 );
293 }
294
295 new_entries.sort_by(|(project_path_a, _), (project_path_b, _)| {
296 compare_paths(
297 (project_path_a.path.as_ref(), true),
298 (project_path_b.path.as_ref(), true),
299 )
300 });
301 (new_changes, new_entries)
302 })
303 .await;
304
305 project_diff_editor
306 .update(&mut cx, |project_diff_editor, cx| {
307 project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx);
308 for change_set in change_sets {
309 project_diff_editor.editor.update(cx, |editor, cx| {
310 editor.diff_map.add_change_set(change_set, cx)
311 });
312 }
313 })
314 .ok();
315 }),
316 );
317 }
318
319 fn update_excerpts(
320 &mut self,
321 worktree_id: WorktreeId,
322 new_changes: HashMap<ProjectEntryId, Changes>,
323 new_entry_order: Vec<(ProjectPath, ProjectEntryId)>,
324 cx: &mut ViewContext<ProjectDiffEditor>,
325 ) {
326 if let Some(current_order) = self.entry_order.get(&worktree_id) {
327 let current_entries = self.buffer_changes.entry(worktree_id).or_default();
328 let mut new_order_entries = new_entry_order.iter().fuse().peekable();
329 let mut excerpts_to_remove = Vec::new();
330 let mut new_excerpt_hunks = BTreeMap::<
331 ExcerptId,
332 Vec<(ProjectPath, Model<Buffer>, Vec<Range<text::Anchor>>)>,
333 >::new();
334 let mut excerpt_to_expand =
335 HashMap::<(u32, ExpandExcerptDirection), Vec<ExcerptId>>::default();
336 let mut latest_excerpt_id = ExcerptId::min();
337
338 for (current_path, current_entry_id) in current_order {
339 let current_changes = match current_entries.get(current_entry_id) {
340 Some(current_changes) => {
341 if current_changes.hunks.is_empty() {
342 continue;
343 }
344 current_changes
345 }
346 None => continue,
347 };
348 let buffer_excerpts = self
349 .excerpts
350 .read(cx)
351 .excerpts_for_buffer(¤t_changes.buffer, cx);
352 let last_current_excerpt_id =
353 buffer_excerpts.last().map(|(excerpt_id, _)| *excerpt_id);
354 let mut current_excerpts = buffer_excerpts.into_iter().fuse().peekable();
355 loop {
356 match new_order_entries.peek() {
357 Some((new_path, new_entry)) => {
358 match compare_paths(
359 (current_path.path.as_ref(), true),
360 (new_path.path.as_ref(), true),
361 ) {
362 Ordering::Less => {
363 excerpts_to_remove
364 .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
365 break;
366 }
367 Ordering::Greater => {
368 if let Some(new_changes) = new_changes.get(new_entry) {
369 if !new_changes.hunks.is_empty() {
370 let hunks = new_excerpt_hunks
371 .entry(latest_excerpt_id)
372 .or_default();
373 match hunks.binary_search_by(|(probe, ..)| {
374 compare_paths(
375 (new_path.path.as_ref(), true),
376 (probe.path.as_ref(), true),
377 )
378 }) {
379 Ok(i) => hunks[i].2.extend(
380 new_changes
381 .hunks
382 .iter()
383 .map(|hunk| hunk.buffer_range.clone()),
384 ),
385 Err(i) => hunks.insert(
386 i,
387 (
388 new_path.clone(),
389 new_changes.buffer.clone(),
390 new_changes
391 .hunks
392 .iter()
393 .map(|hunk| hunk.buffer_range.clone())
394 .collect(),
395 ),
396 ),
397 }
398 }
399 };
400 let _ = new_order_entries.next();
401 }
402 Ordering::Equal => {
403 match new_changes.get(new_entry) {
404 Some(new_changes) => {
405 let buffer_snapshot =
406 new_changes.buffer.read(cx).snapshot();
407 let mut current_hunks =
408 current_changes.hunks.iter().fuse().peekable();
409 let mut new_hunks_unchanged =
410 Vec::with_capacity(new_changes.hunks.len());
411 let mut new_hunks_with_updates =
412 Vec::with_capacity(new_changes.hunks.len());
413 'new_changes: for new_hunk in &new_changes.hunks {
414 loop {
415 match current_hunks.peek() {
416 Some(current_hunk) => {
417 match (
418 current_hunk
419 .buffer_range
420 .start
421 .cmp(
422 &new_hunk
423 .buffer_range
424 .start,
425 &buffer_snapshot,
426 ),
427 current_hunk.buffer_range.end.cmp(
428 &new_hunk.buffer_range.end,
429 &buffer_snapshot,
430 ),
431 ) {
432 (
433 Ordering::Equal,
434 Ordering::Equal,
435 ) => {
436 new_hunks_unchanged
437 .push(new_hunk);
438 let _ = current_hunks.next();
439 continue 'new_changes;
440 }
441 (Ordering::Equal, _)
442 | (_, Ordering::Equal) => {
443 new_hunks_with_updates
444 .push(new_hunk);
445 continue 'new_changes;
446 }
447 (
448 Ordering::Less,
449 Ordering::Greater,
450 )
451 | (
452 Ordering::Greater,
453 Ordering::Less,
454 ) => {
455 new_hunks_with_updates
456 .push(new_hunk);
457 continue 'new_changes;
458 }
459 (
460 Ordering::Less,
461 Ordering::Less,
462 ) => {
463 if current_hunk
464 .buffer_range
465 .start
466 .cmp(
467 &new_hunk
468 .buffer_range
469 .end,
470 &buffer_snapshot,
471 )
472 .is_le()
473 {
474 new_hunks_with_updates
475 .push(new_hunk);
476 continue 'new_changes;
477 } else {
478 let _ =
479 current_hunks.next();
480 }
481 }
482 (
483 Ordering::Greater,
484 Ordering::Greater,
485 ) => {
486 if current_hunk
487 .buffer_range
488 .end
489 .cmp(
490 &new_hunk
491 .buffer_range
492 .start,
493 &buffer_snapshot,
494 )
495 .is_ge()
496 {
497 new_hunks_with_updates
498 .push(new_hunk);
499 continue 'new_changes;
500 } else {
501 let _ =
502 current_hunks.next();
503 }
504 }
505 }
506 }
507 None => {
508 new_hunks_with_updates.push(new_hunk);
509 continue 'new_changes;
510 }
511 }
512 }
513 }
514
515 let mut excerpts_with_new_changes =
516 HashSet::<ExcerptId>::default();
517 'new_hunks: for new_hunk in new_hunks_with_updates {
518 loop {
519 match current_excerpts.peek() {
520 Some((
521 current_excerpt_id,
522 current_excerpt_range,
523 )) => {
524 match (
525 current_excerpt_range
526 .context
527 .start
528 .cmp(
529 &new_hunk
530 .buffer_range
531 .start,
532 &buffer_snapshot,
533 ),
534 current_excerpt_range
535 .context
536 .end
537 .cmp(
538 &new_hunk.buffer_range.end,
539 &buffer_snapshot,
540 ),
541 ) {
542 (
543 Ordering::Less
544 | Ordering::Equal,
545 Ordering::Greater
546 | Ordering::Equal,
547 ) => {
548 excerpts_with_new_changes
549 .insert(
550 *current_excerpt_id,
551 );
552 continue 'new_hunks;
553 }
554 (
555 Ordering::Greater
556 | Ordering::Equal,
557 Ordering::Less
558 | Ordering::Equal,
559 ) => {
560 let expand_up = current_excerpt_range
561 .context
562 .start
563 .to_point(&buffer_snapshot)
564 .row
565 .saturating_sub(
566 new_hunk
567 .buffer_range
568 .start
569 .to_point(&buffer_snapshot)
570 .row,
571 );
572 let expand_down = new_hunk
573 .buffer_range
574 .end
575 .to_point(&buffer_snapshot)
576 .row
577 .saturating_sub(
578 current_excerpt_range
579 .context
580 .end
581 .to_point(
582 &buffer_snapshot,
583 )
584 .row,
585 );
586 excerpt_to_expand.entry((expand_up.max(expand_down).max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::UpAndDown)).or_default().push(*current_excerpt_id);
587 excerpts_with_new_changes
588 .insert(
589 *current_excerpt_id,
590 );
591 continue 'new_hunks;
592 }
593 (
594 Ordering::Less,
595 Ordering::Less,
596 ) => {
597 if current_excerpt_range
598 .context
599 .start
600 .cmp(
601 &new_hunk
602 .buffer_range
603 .end,
604 &buffer_snapshot,
605 )
606 .is_le()
607 {
608 let expand_up = current_excerpt_range
609 .context
610 .start
611 .to_point(&buffer_snapshot)
612 .row
613 .saturating_sub(
614 new_hunk.buffer_range
615 .start
616 .to_point(
617 &buffer_snapshot,
618 )
619 .row,
620 );
621 excerpt_to_expand.entry((expand_up.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Up)).or_default().push(*current_excerpt_id);
622 excerpts_with_new_changes
623 .insert(
624 *current_excerpt_id,
625 );
626 continue 'new_hunks;
627 } else {
628 if !new_changes
629 .hunks
630 .is_empty()
631 {
632 let hunks = new_excerpt_hunks
633 .entry(latest_excerpt_id)
634 .or_default();
635 match hunks.binary_search_by(|(probe, ..)| {
636 compare_paths(
637 (new_path.path.as_ref(), true),
638 (probe.path.as_ref(), true),
639 )
640 }) {
641 Ok(i) => hunks[i].2.extend(
642 new_changes
643 .hunks
644 .iter()
645 .map(|hunk| hunk.buffer_range.clone()),
646 ),
647 Err(i) => hunks.insert(
648 i,
649 (
650 new_path.clone(),
651 new_changes.buffer.clone(),
652 new_changes
653 .hunks
654 .iter()
655 .map(|hunk| hunk.buffer_range.clone())
656 .collect(),
657 ),
658 ),
659 }
660 }
661 continue 'new_hunks;
662 }
663 }
664 /* TODO remove or leave?
665 [ ><<<<<<<<new_e
666 ----[---->--]----<--
667 cur_s > cur_e <
668 > <
669 new_s>>>>>>>><
670 */
671 (
672 Ordering::Greater,
673 Ordering::Greater,
674 ) => {
675 if current_excerpt_range
676 .context
677 .end
678 .cmp(
679 &new_hunk
680 .buffer_range
681 .start,
682 &buffer_snapshot,
683 )
684 .is_ge()
685 {
686 let expand_down = new_hunk
687 .buffer_range
688 .end
689 .to_point(&buffer_snapshot)
690 .row
691 .saturating_sub(
692 current_excerpt_range
693 .context
694 .end
695 .to_point(
696 &buffer_snapshot,
697 )
698 .row,
699 );
700 excerpt_to_expand.entry((expand_down.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Down)).or_default().push(*current_excerpt_id);
701 excerpts_with_new_changes
702 .insert(
703 *current_excerpt_id,
704 );
705 continue 'new_hunks;
706 } else {
707 latest_excerpt_id =
708 *current_excerpt_id;
709 let _ =
710 current_excerpts.next();
711 }
712 }
713 }
714 }
715 None => {
716 let hunks = new_excerpt_hunks
717 .entry(latest_excerpt_id)
718 .or_default();
719 match hunks.binary_search_by(
720 |(probe, ..)| {
721 compare_paths(
722 (
723 new_path.path.as_ref(),
724 true,
725 ),
726 (probe.path.as_ref(), true),
727 )
728 },
729 ) {
730 Ok(i) => hunks[i].2.extend(
731 new_changes.hunks.iter().map(
732 |hunk| {
733 hunk.buffer_range
734 .clone()
735 },
736 ),
737 ),
738 Err(i) => hunks.insert(
739 i,
740 (
741 new_path.clone(),
742 new_changes.buffer.clone(),
743 new_changes
744 .hunks
745 .iter()
746 .map(|hunk| {
747 hunk.buffer_range
748 .clone()
749 })
750 .collect(),
751 ),
752 ),
753 }
754 continue 'new_hunks;
755 }
756 }
757 }
758 }
759
760 for (excerpt_id, excerpt_range) in current_excerpts {
761 if !excerpts_with_new_changes.contains(&excerpt_id)
762 && !new_hunks_unchanged.iter().any(|hunk| {
763 excerpt_range
764 .context
765 .start
766 .cmp(
767 &hunk.buffer_range.end,
768 &buffer_snapshot,
769 )
770 .is_le()
771 && excerpt_range
772 .context
773 .end
774 .cmp(
775 &hunk.buffer_range.start,
776 &buffer_snapshot,
777 )
778 .is_ge()
779 })
780 {
781 excerpts_to_remove.push(excerpt_id);
782 }
783 latest_excerpt_id = excerpt_id;
784 }
785 }
786 None => excerpts_to_remove.extend(
787 current_excerpts.map(|(excerpt_id, _)| excerpt_id),
788 ),
789 }
790 let _ = new_order_entries.next();
791 break;
792 }
793 }
794 }
795 None => {
796 excerpts_to_remove
797 .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
798 break;
799 }
800 }
801 }
802 latest_excerpt_id = last_current_excerpt_id.unwrap_or(latest_excerpt_id);
803 }
804
805 for (path, project_entry_id) in new_order_entries {
806 if let Some(changes) = new_changes.get(project_entry_id) {
807 if !changes.hunks.is_empty() {
808 let hunks = new_excerpt_hunks.entry(latest_excerpt_id).or_default();
809 match hunks.binary_search_by(|(probe, ..)| {
810 compare_paths((path.path.as_ref(), true), (probe.path.as_ref(), true))
811 }) {
812 Ok(i) => hunks[i]
813 .2
814 .extend(changes.hunks.iter().map(|hunk| hunk.buffer_range.clone())),
815 Err(i) => hunks.insert(
816 i,
817 (
818 path.clone(),
819 changes.buffer.clone(),
820 changes
821 .hunks
822 .iter()
823 .map(|hunk| hunk.buffer_range.clone())
824 .collect(),
825 ),
826 ),
827 }
828 }
829 }
830 }
831
832 self.excerpts.update(cx, |multi_buffer, cx| {
833 for (mut after_excerpt_id, excerpts_to_add) in new_excerpt_hunks {
834 for (_, buffer, hunk_ranges) in excerpts_to_add {
835 let buffer_snapshot = buffer.read(cx).snapshot();
836 let max_point = buffer_snapshot.max_point();
837 let new_excerpts = multi_buffer.insert_excerpts_after(
838 after_excerpt_id,
839 buffer,
840 hunk_ranges.into_iter().map(|range| {
841 let mut extended_point_range = range.to_point(&buffer_snapshot);
842 extended_point_range.start.row = extended_point_range
843 .start
844 .row
845 .saturating_sub(DEFAULT_MULTIBUFFER_CONTEXT);
846 extended_point_range.end.row = (extended_point_range.end.row
847 + DEFAULT_MULTIBUFFER_CONTEXT)
848 .min(max_point.row);
849 ExcerptRange {
850 context: extended_point_range,
851 primary: None,
852 }
853 }),
854 cx,
855 );
856 after_excerpt_id = new_excerpts.last().copied().unwrap_or(after_excerpt_id);
857 }
858 }
859 multi_buffer.remove_excerpts(excerpts_to_remove, cx);
860 for ((line_count, direction), excerpts) in excerpt_to_expand {
861 multi_buffer.expand_excerpts(excerpts, line_count, direction, cx);
862 }
863 });
864 } else {
865 self.excerpts.update(cx, |multi_buffer, cx| {
866 for new_changes in new_entry_order
867 .iter()
868 .filter_map(|(_, entry_id)| new_changes.get(entry_id))
869 {
870 multi_buffer.push_excerpts_with_context_lines(
871 new_changes.buffer.clone(),
872 new_changes
873 .hunks
874 .iter()
875 .map(|hunk| hunk.buffer_range.clone())
876 .collect(),
877 DEFAULT_MULTIBUFFER_CONTEXT,
878 cx,
879 );
880 }
881 });
882 };
883
884 let mut new_changes = new_changes;
885 let mut new_entry_order = new_entry_order;
886 std::mem::swap(
887 self.buffer_changes.entry(worktree_id).or_default(),
888 &mut new_changes,
889 );
890 std::mem::swap(
891 self.entry_order.entry(worktree_id).or_default(),
892 &mut new_entry_order,
893 );
894 }
895}
896
897impl EventEmitter<EditorEvent> for ProjectDiffEditor {}
898
899impl FocusableView for ProjectDiffEditor {
900 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
901 self.focus_handle.clone()
902 }
903}
904
905impl Item for ProjectDiffEditor {
906 type Event = EditorEvent;
907
908 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
909 Editor::to_item_events(event, f)
910 }
911
912 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
913 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
914 }
915
916 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
917 self.editor
918 .update(cx, |editor, cx| editor.navigate(data, cx))
919 }
920
921 fn tab_tooltip_text(&self, _: &AppContext) -> Option<SharedString> {
922 Some("Project Diff".into())
923 }
924
925 fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
926 if self.buffer_changes.is_empty() {
927 Label::new("No changes")
928 .color(if params.selected {
929 Color::Default
930 } else {
931 Color::Muted
932 })
933 .into_any_element()
934 } else {
935 h_flex()
936 .gap_1()
937 .when(true, |then| {
938 then.child(
939 h_flex()
940 .gap_1()
941 .child(Icon::new(IconName::XCircle).color(Color::Error))
942 .child(Label::new(self.buffer_changes.len().to_string()).color(
943 if params.selected {
944 Color::Default
945 } else {
946 Color::Muted
947 },
948 )),
949 )
950 })
951 .when(true, |then| {
952 then.child(
953 h_flex()
954 .gap_1()
955 .child(Icon::new(IconName::Indicator).color(Color::Warning))
956 .child(Label::new(self.buffer_changes.len().to_string()).color(
957 if params.selected {
958 Color::Default
959 } else {
960 Color::Muted
961 },
962 )),
963 )
964 })
965 .into_any_element()
966 }
967 }
968
969 fn telemetry_event_text(&self) -> Option<&'static str> {
970 Some("project diagnostics")
971 }
972
973 fn for_each_project_item(
974 &self,
975 cx: &AppContext,
976 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
977 ) {
978 self.editor.for_each_project_item(cx, f)
979 }
980
981 fn is_singleton(&self, _: &AppContext) -> bool {
982 false
983 }
984
985 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
986 self.editor.update(cx, |editor, _| {
987 editor.set_nav_history(Some(nav_history));
988 });
989 }
990
991 fn clone_on_split(
992 &self,
993 _workspace_id: Option<workspace::WorkspaceId>,
994 cx: &mut ViewContext<Self>,
995 ) -> Option<View<Self>>
996 where
997 Self: Sized,
998 {
999 Some(cx.new_view(|cx| {
1000 ProjectDiffEditor::new(self.project.clone(), self.workspace.clone(), cx)
1001 }))
1002 }
1003
1004 fn is_dirty(&self, cx: &AppContext) -> bool {
1005 self.excerpts.read(cx).is_dirty(cx)
1006 }
1007
1008 fn has_conflict(&self, cx: &AppContext) -> bool {
1009 self.excerpts.read(cx).has_conflict(cx)
1010 }
1011
1012 fn can_save(&self, _: &AppContext) -> bool {
1013 true
1014 }
1015
1016 fn save(
1017 &mut self,
1018 format: bool,
1019 project: Model<Project>,
1020 cx: &mut ViewContext<Self>,
1021 ) -> Task<anyhow::Result<()>> {
1022 self.editor.save(format, project, cx)
1023 }
1024
1025 fn save_as(
1026 &mut self,
1027 _: Model<Project>,
1028 _: ProjectPath,
1029 _: &mut ViewContext<Self>,
1030 ) -> Task<anyhow::Result<()>> {
1031 unreachable!()
1032 }
1033
1034 fn reload(
1035 &mut self,
1036 project: Model<Project>,
1037 cx: &mut ViewContext<Self>,
1038 ) -> Task<anyhow::Result<()>> {
1039 self.editor.reload(project, cx)
1040 }
1041
1042 fn act_as_type<'a>(
1043 &'a self,
1044 type_id: TypeId,
1045 self_handle: &'a View<Self>,
1046 _: &'a AppContext,
1047 ) -> Option<AnyView> {
1048 if type_id == TypeId::of::<Self>() {
1049 Some(self_handle.to_any())
1050 } else if type_id == TypeId::of::<Editor>() {
1051 Some(self.editor.to_any())
1052 } else {
1053 None
1054 }
1055 }
1056
1057 fn breadcrumb_location(&self, _: &AppContext) -> ToolbarItemLocation {
1058 ToolbarItemLocation::PrimaryLeft
1059 }
1060
1061 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
1062 self.editor.breadcrumbs(theme, cx)
1063 }
1064
1065 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
1066 self.editor
1067 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
1068 }
1069}
1070
1071impl Render for ProjectDiffEditor {
1072 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1073 let child = if self.buffer_changes.is_empty() {
1074 div()
1075 .bg(cx.theme().colors().editor_background)
1076 .flex()
1077 .items_center()
1078 .justify_center()
1079 .size_full()
1080 .child(Label::new("No changes in the workspace"))
1081 } else {
1082 div().size_full().child(self.editor.clone())
1083 };
1084
1085 div()
1086 .track_focus(&self.focus_handle)
1087 .size_full()
1088 .child(child)
1089 }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
1095 use project::buffer_store::BufferChangeSet;
1096 use serde_json::json;
1097 use settings::SettingsStore;
1098 use std::{
1099 ops::Deref as _,
1100 path::{Path, PathBuf},
1101 };
1102
1103 use super::*;
1104
1105 // TODO finish
1106 // #[gpui::test]
1107 // async fn randomized_tests(cx: &mut TestAppContext) {
1108 // // Create a new project (how?? temp fs?),
1109 // let fs = FakeFs::new(cx.executor());
1110 // let project = Project::test(fs, [], cx).await;
1111
1112 // // create random files with random content
1113
1114 // // Commit it into git somehow (technically can do with "real" fs in a temp dir)
1115 // //
1116 // // Apply randomized changes to the project: select a random file, random change and apply to buffers
1117 // }
1118
1119 #[gpui::test(iterations = 30)]
1120 async fn simple_edit_test(cx: &mut TestAppContext) {
1121 cx.executor().allow_parking();
1122 init_test(cx);
1123
1124 let fs = fs::FakeFs::new(cx.executor().clone());
1125 fs.insert_tree(
1126 "/root",
1127 json!({
1128 ".git": {},
1129 "file_a": "This is file_a",
1130 "file_b": "This is file_b",
1131 }),
1132 )
1133 .await;
1134
1135 let project = Project::test(fs.clone(), [Path::new("/root")], cx).await;
1136 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1137 let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
1138
1139 let file_a_editor = workspace
1140 .update(cx, |workspace, cx| {
1141 let file_a_editor =
1142 workspace.open_abs_path(PathBuf::from("/root/file_a"), true, cx);
1143 ProjectDiffEditor::deploy(workspace, &Deploy, cx);
1144 file_a_editor
1145 })
1146 .unwrap()
1147 .await
1148 .expect("did not open an item at all")
1149 .downcast::<Editor>()
1150 .expect("did not open an editor for file_a");
1151 let project_diff_editor = workspace
1152 .update(cx, |workspace, cx| {
1153 workspace
1154 .active_pane()
1155 .read(cx)
1156 .items()
1157 .find_map(|item| item.downcast::<ProjectDiffEditor>())
1158 })
1159 .unwrap()
1160 .expect("did not find a ProjectDiffEditor");
1161 project_diff_editor.update(cx, |project_diff_editor, cx| {
1162 assert!(
1163 project_diff_editor.editor.read(cx).text(cx).is_empty(),
1164 "Should have no changes after opening the diff on no git changes"
1165 );
1166 });
1167
1168 let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx));
1169 let change = "an edit after git add";
1170 file_a_editor
1171 .update(cx, |file_a_editor, cx| {
1172 file_a_editor.insert(change, cx);
1173 file_a_editor.save(false, project.clone(), cx)
1174 })
1175 .await
1176 .expect("failed to save a file");
1177 file_a_editor.update(cx, |file_a_editor, cx| {
1178 let change_set = cx.new_model(|cx| {
1179 BufferChangeSet::new_with_base_text(
1180 old_text.clone(),
1181 file_a_editor
1182 .buffer()
1183 .read(cx)
1184 .as_singleton()
1185 .unwrap()
1186 .read(cx)
1187 .text_snapshot(),
1188 cx,
1189 )
1190 });
1191 file_a_editor
1192 .diff_map
1193 .add_change_set(change_set.clone(), cx);
1194 project.update(cx, |project, cx| {
1195 project.buffer_store().update(cx, |buffer_store, cx| {
1196 buffer_store.set_change_set(
1197 file_a_editor
1198 .buffer()
1199 .read(cx)
1200 .as_singleton()
1201 .unwrap()
1202 .read(cx)
1203 .remote_id(),
1204 change_set,
1205 );
1206 });
1207 });
1208 });
1209 fs.set_status_for_repo_via_git_operation(
1210 Path::new("/root/.git"),
1211 &[(Path::new("file_a"), GitFileStatus::Modified)],
1212 );
1213 cx.executor()
1214 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
1215 cx.run_until_parked();
1216
1217 project_diff_editor.update(cx, |project_diff_editor, cx| {
1218 assert_eq!(
1219 // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added)
1220 project_diff_editor.editor.read(cx).text(cx),
1221 format!("{change}{old_text}"),
1222 "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards"
1223 );
1224 });
1225 }
1226
1227 fn init_test(cx: &mut gpui::TestAppContext) {
1228 if std::env::var("RUST_LOG").is_ok() {
1229 env_logger::try_init().ok();
1230 }
1231
1232 cx.update(|cx| {
1233 assets::Assets.load_test_fonts(cx);
1234 let settings_store = SettingsStore::test(cx);
1235 cx.set_global(settings_store);
1236 theme::init(theme::LoadThemes::JustBase, cx);
1237 release_channel::init(SemanticVersion::default(), cx);
1238 client::init_settings(cx);
1239 language::init(cx);
1240 Project::init_settings(cx);
1241 workspace::init_settings(cx);
1242 crate::init(cx);
1243 cx.set_staff(true);
1244 });
1245 }
1246}