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