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