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