1use collections::{BTreeMap, HashMap};
2use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
3use git::{
4 BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
5 parse_git_remote_url,
6 repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath},
7 status::{FileStatus, StatusCode, TrackedStatus},
8};
9use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon};
10use gpui::{
11 AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent,
12 ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels,
13 Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task,
14 UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
15 px, uniform_list,
16};
17use language::line_diff;
18use menu::{Cancel, SelectNext, SelectPrevious};
19use project::{
20 Project,
21 git_store::{
22 CommitDataState, GitGraphEvent, GitStoreEvent, GraphDataResponse, Repository,
23 RepositoryEvent, RepositoryId,
24 },
25};
26use settings::Settings;
27use smallvec::{SmallVec, smallvec};
28use std::{
29 cell::Cell,
30 ops::Range,
31 rc::Rc,
32 sync::Arc,
33 sync::OnceLock,
34 time::{Duration, Instant},
35};
36use theme::{AccentColors, ThemeSettings};
37use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
38use ui::{
39 ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle,
40 Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar,
41 prelude::*,
42};
43use workspace::{
44 Workspace,
45 item::{Item, ItemEvent, SerializableItem, TabTooltipContent},
46};
47
48const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);
49const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
50const LANE_WIDTH: Pixels = px(16.0);
51const LEFT_PADDING: Pixels = px(12.0);
52const LINE_WIDTH: Pixels = px(1.5);
53const RESIZE_HANDLE_WIDTH: f32 = 8.0;
54const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
55
56struct CopiedState {
57 copied_at: Option<Instant>,
58}
59
60impl CopiedState {
61 fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
62 Self { copied_at: None }
63 }
64
65 fn is_copied(&self) -> bool {
66 self.copied_at
67 .map(|t| t.elapsed() < COPIED_STATE_DURATION)
68 .unwrap_or(false)
69 }
70
71 fn mark_copied(&mut self) {
72 self.copied_at = Some(Instant::now());
73 }
74}
75
76struct DraggedSplitHandle;
77
78#[derive(Clone)]
79struct ChangedFileEntry {
80 status: FileStatus,
81 file_name: SharedString,
82 dir_path: SharedString,
83 repo_path: RepoPath,
84}
85
86impl ChangedFileEntry {
87 fn from_commit_file(file: &CommitFile, _cx: &App) -> Self {
88 let file_name: SharedString = file
89 .path
90 .file_name()
91 .map(|n| n.to_string())
92 .unwrap_or_default()
93 .into();
94 let dir_path: SharedString = file
95 .path
96 .parent()
97 .map(|p| p.as_unix_str().to_string())
98 .unwrap_or_default()
99 .into();
100
101 let status_code = match (&file.old_text, &file.new_text) {
102 (None, Some(_)) => StatusCode::Added,
103 (Some(_), None) => StatusCode::Deleted,
104 _ => StatusCode::Modified,
105 };
106
107 let status = FileStatus::Tracked(TrackedStatus {
108 index_status: status_code,
109 worktree_status: StatusCode::Unmodified,
110 });
111
112 Self {
113 status,
114 file_name,
115 dir_path,
116 repo_path: file.path.clone(),
117 }
118 }
119
120 fn open_in_commit_view(
121 &self,
122 commit_sha: &SharedString,
123 repository: &WeakEntity<Repository>,
124 workspace: &WeakEntity<Workspace>,
125 window: &mut Window,
126 cx: &mut App,
127 ) {
128 CommitView::open(
129 commit_sha.to_string(),
130 repository.clone(),
131 workspace.clone(),
132 None,
133 Some(self.repo_path.clone()),
134 window,
135 cx,
136 );
137 }
138
139 fn render(
140 &self,
141 ix: usize,
142 commit_sha: SharedString,
143 repository: WeakEntity<Repository>,
144 workspace: WeakEntity<Workspace>,
145 _cx: &App,
146 ) -> AnyElement {
147 let file_name = self.file_name.clone();
148 let dir_path = self.dir_path.clone();
149
150 div()
151 .w_full()
152 .child(
153 ButtonLike::new(("changed-file", ix))
154 .child(
155 h_flex()
156 .min_w_0()
157 .w_full()
158 .gap_1()
159 .overflow_hidden()
160 .child(git_status_icon(self.status))
161 .child(
162 Label::new(file_name.clone())
163 .size(LabelSize::Small)
164 .truncate(),
165 )
166 .when(!dir_path.is_empty(), |this| {
167 this.child(
168 Label::new(dir_path.clone())
169 .size(LabelSize::Small)
170 .color(Color::Muted)
171 .truncate_start(),
172 )
173 }),
174 )
175 .tooltip({
176 let meta = if dir_path.is_empty() {
177 file_name
178 } else {
179 format!("{}/{}", dir_path, file_name).into()
180 };
181 move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx)
182 })
183 .on_click({
184 let entry = self.clone();
185 move |_, window, cx| {
186 entry.open_in_commit_view(
187 &commit_sha,
188 &repository,
189 &workspace,
190 window,
191 cx,
192 );
193 }
194 }),
195 )
196 .into_any_element()
197 }
198}
199
200pub struct SplitState {
201 left_ratio: f32,
202 visible_left_ratio: f32,
203}
204
205impl SplitState {
206 pub fn new() -> Self {
207 Self {
208 left_ratio: 1.0,
209 visible_left_ratio: 1.0,
210 }
211 }
212
213 pub fn right_ratio(&self) -> f32 {
214 1.0 - self.visible_left_ratio
215 }
216
217 fn on_drag_move(
218 &mut self,
219 drag_event: &DragMoveEvent<DraggedSplitHandle>,
220 _window: &mut Window,
221 _cx: &mut Context<Self>,
222 ) {
223 let drag_position = drag_event.event.position;
224 let bounds = drag_event.bounds;
225 let bounds_width = bounds.right() - bounds.left();
226
227 let min_ratio = 0.1;
228 let max_ratio = 0.9;
229
230 let new_ratio = (drag_position.x - bounds.left()) / bounds_width;
231 self.visible_left_ratio = new_ratio.clamp(min_ratio, max_ratio);
232 }
233
234 fn commit_ratio(&mut self) {
235 self.left_ratio = self.visible_left_ratio;
236 }
237
238 fn on_double_click(&mut self) {
239 self.left_ratio = 1.0;
240 self.visible_left_ratio = 1.0;
241 }
242}
243
244actions!(
245 git_graph,
246 [
247 /// Opens the commit view for the selected commit.
248 OpenCommitView,
249 ]
250);
251
252fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
253 static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
254 FORMAT.get_or_init(|| {
255 time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
256 .unwrap_or_default()
257 })
258}
259
260fn format_timestamp(timestamp: i64) -> String {
261 let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
262 return "Unknown".to_string();
263 };
264
265 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
266 let local_datetime = datetime.to_offset(local_offset);
267
268 local_datetime
269 .format(timestamp_format())
270 .unwrap_or_default()
271}
272
273fn accent_colors_count(accents: &AccentColors) -> usize {
274 accents.0.len()
275}
276
277#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
278struct BranchId(usize);
279
280#[derive(Debug)]
281enum LaneState {
282 Empty,
283 Active {
284 child: Oid,
285 parent: Oid,
286 branch_id: BranchId,
287 starting_row: usize,
288 starting_col: usize,
289 destination_column: Option<usize>,
290 segments: SmallVec<[CommitLineSegment; 1]>,
291 },
292}
293
294impl LaneState {
295 fn to_commit_lines(
296 &mut self,
297 ending_row: usize,
298 lane_column: usize,
299 parent_column: usize,
300 ) -> Option<CommitLine> {
301 let state = std::mem::replace(self, LaneState::Empty);
302
303 match state {
304 LaneState::Active {
305 #[cfg_attr(not(test), allow(unused_variables))]
306 parent,
307 #[cfg_attr(not(test), allow(unused_variables))]
308 child,
309 branch_id,
310 starting_row,
311 starting_col,
312 destination_column,
313 mut segments,
314 } => {
315 let final_destination = destination_column.unwrap_or(parent_column);
316
317 Some(CommitLine {
318 #[cfg(test)]
319 child,
320 #[cfg(test)]
321 parent,
322 child_column: starting_col,
323 full_interval: starting_row..ending_row,
324 branch_id,
325 segments: {
326 match segments.last_mut() {
327 Some(CommitLineSegment::Straight { to_row })
328 if *to_row == usize::MAX =>
329 {
330 if final_destination != lane_column {
331 *to_row = ending_row - 1;
332
333 let curved_line = CommitLineSegment::Curve {
334 to_column: final_destination,
335 on_row: ending_row,
336 curve_kind: CurveKind::Checkout,
337 };
338
339 if *to_row == starting_row {
340 let last_index = segments.len() - 1;
341 segments[last_index] = curved_line;
342 } else {
343 segments.push(curved_line);
344 }
345 } else {
346 *to_row = ending_row;
347 }
348 }
349 Some(CommitLineSegment::Curve {
350 on_row,
351 to_column,
352 curve_kind,
353 }) if *on_row == usize::MAX => {
354 if *to_column == usize::MAX {
355 *to_column = final_destination;
356 }
357 if matches!(curve_kind, CurveKind::Merge) {
358 *on_row = starting_row + 1;
359 if *on_row < ending_row {
360 if *to_column != final_destination {
361 segments.push(CommitLineSegment::Straight {
362 to_row: ending_row - 1,
363 });
364 segments.push(CommitLineSegment::Curve {
365 to_column: final_destination,
366 on_row: ending_row,
367 curve_kind: CurveKind::Checkout,
368 });
369 } else {
370 segments.push(CommitLineSegment::Straight {
371 to_row: ending_row,
372 });
373 }
374 } else if *to_column != final_destination {
375 segments.push(CommitLineSegment::Curve {
376 to_column: final_destination,
377 on_row: ending_row,
378 curve_kind: CurveKind::Checkout,
379 });
380 }
381 } else {
382 *on_row = ending_row;
383 if *to_column != final_destination {
384 segments.push(CommitLineSegment::Straight {
385 to_row: ending_row,
386 });
387 segments.push(CommitLineSegment::Curve {
388 to_column: final_destination,
389 on_row: ending_row,
390 curve_kind: CurveKind::Checkout,
391 });
392 }
393 }
394 }
395 Some(CommitLineSegment::Curve {
396 on_row, to_column, ..
397 }) => {
398 if *on_row < ending_row {
399 if *to_column != final_destination {
400 segments.push(CommitLineSegment::Straight {
401 to_row: ending_row - 1,
402 });
403 segments.push(CommitLineSegment::Curve {
404 to_column: final_destination,
405 on_row: ending_row,
406 curve_kind: CurveKind::Checkout,
407 });
408 } else {
409 segments.push(CommitLineSegment::Straight {
410 to_row: ending_row,
411 });
412 }
413 } else if *to_column != final_destination {
414 segments.push(CommitLineSegment::Curve {
415 to_column: final_destination,
416 on_row: ending_row,
417 curve_kind: CurveKind::Checkout,
418 });
419 }
420 }
421 _ => {}
422 }
423
424 segments
425 },
426 })
427 }
428 LaneState::Empty => None,
429 }
430 }
431
432 fn branch_id(&self) -> Option<BranchId> {
433 match self {
434 LaneState::Active { branch_id, .. } => Some(*branch_id),
435 LaneState::Empty => None,
436 }
437 }
438
439 fn is_empty(&self) -> bool {
440 match self {
441 LaneState::Empty => true,
442 LaneState::Active { .. } => false,
443 }
444 }
445}
446
447struct CommitEntry {
448 data: Arc<InitialGraphCommitData>,
449 lane: usize,
450 branch_id: BranchId,
451}
452
453type ActiveLaneIdx = usize;
454
455enum AllCommitCount {
456 NotLoaded,
457 Loaded(usize),
458}
459
460#[derive(Debug)]
461enum CurveKind {
462 Merge,
463 Checkout,
464}
465
466#[derive(Debug)]
467enum CommitLineSegment {
468 Straight {
469 to_row: usize,
470 },
471 Curve {
472 to_column: usize,
473 on_row: usize,
474 curve_kind: CurveKind,
475 },
476}
477
478#[derive(Debug)]
479struct CommitLine {
480 #[cfg(test)]
481 child: Oid,
482 #[cfg(test)]
483 parent: Oid,
484 child_column: usize,
485 full_interval: Range<usize>,
486 branch_id: BranchId,
487 segments: SmallVec<[CommitLineSegment; 1]>,
488}
489
490impl CommitLine {
491 fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
492 if first_visible_row > self.full_interval.end {
493 return None;
494 } else if first_visible_row <= self.full_interval.start {
495 return Some((0, self.child_column));
496 }
497
498 let mut current_column = self.child_column;
499
500 for (idx, segment) in self.segments.iter().enumerate() {
501 match segment {
502 CommitLineSegment::Straight { to_row } => {
503 if *to_row >= first_visible_row {
504 return Some((idx, current_column));
505 }
506 }
507 CommitLineSegment::Curve {
508 to_column, on_row, ..
509 } => {
510 if *on_row >= first_visible_row {
511 return Some((idx, current_column));
512 }
513 current_column = *to_column;
514 }
515 }
516 }
517
518 None
519 }
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
523struct CommitLineKey {
524 child: Oid,
525 parent: Oid,
526}
527
528struct GraphData {
529 lane_states: SmallVec<[LaneState; 8]>,
530 parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
531 next_branch_id: BranchId,
532 commits: Vec<Rc<CommitEntry>>,
533 max_commit_count: AllCommitCount,
534 max_lanes: usize,
535 lines: Vec<Rc<CommitLine>>,
536 active_commit_lines: HashMap<CommitLineKey, usize>,
537 active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
538}
539
540impl GraphData {
541 fn new(_accent_colors_count: usize) -> Self {
542 GraphData {
543 lane_states: SmallVec::default(),
544 parent_to_lanes: HashMap::default(),
545 next_branch_id: BranchId(0),
546 commits: Vec::default(),
547 max_commit_count: AllCommitCount::NotLoaded,
548 max_lanes: 0,
549 lines: Vec::default(),
550 active_commit_lines: HashMap::default(),
551 active_commit_lines_by_parent: HashMap::default(),
552 }
553 }
554
555 fn clear(&mut self) {
556 self.lane_states.clear();
557 self.parent_to_lanes.clear();
558 self.commits.clear();
559 self.lines.clear();
560 self.active_commit_lines.clear();
561 self.active_commit_lines_by_parent.clear();
562 self.next_branch_id = BranchId(0);
563 self.max_commit_count = AllCommitCount::NotLoaded;
564 self.max_lanes = 0;
565 }
566
567 fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
568 self.lane_states
569 .iter()
570 .position(LaneState::is_empty)
571 .unwrap_or_else(|| {
572 self.lane_states.push(LaneState::Empty);
573 self.lane_states.len() - 1
574 })
575 }
576
577 fn allocate_branch_id(&mut self) -> BranchId {
578 let branch_id = self.next_branch_id;
579 self.next_branch_id = BranchId(self.next_branch_id.0 + 1);
580 branch_id
581 }
582
583 fn branch_id_for_lane(&self, lane_idx: ActiveLaneIdx) -> Option<BranchId> {
584 self.lane_states
585 .get(lane_idx)
586 .and_then(LaneState::branch_id)
587 }
588
589 fn origin_branch_id_for_parent(&self, parent: Oid) -> Option<BranchId> {
590 self.parent_to_lanes
591 .get(&parent)
592 .and_then(|lane_indices| lane_indices.first().copied())
593 .and_then(|lane_idx| self.branch_id_for_lane(lane_idx))
594 }
595
596 fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
597 self.commits.reserve(commits.len());
598 self.lines.reserve(commits.len() / 2);
599
600 for commit in commits.iter() {
601 let commit_row = self.commits.len();
602
603 let commit_lane = self
604 .parent_to_lanes
605 .get(&commit.sha)
606 .and_then(|lanes| lanes.first().copied());
607
608 let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
609 let commit_branch_id = self
610 .branch_id_for_lane(commit_lane)
611 .unwrap_or_else(|| self.allocate_branch_id());
612
613 if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
614 for lane_column in lanes {
615 let state = &mut self.lane_states[lane_column];
616
617 if let LaneState::Active {
618 starting_row,
619 segments,
620 ..
621 } = state
622 {
623 if let Some(CommitLineSegment::Curve {
624 to_column,
625 curve_kind: CurveKind::Merge,
626 ..
627 }) = segments.first_mut()
628 {
629 let curve_row = *starting_row + 1;
630 let would_overlap =
631 if lane_column != commit_lane && curve_row < commit_row {
632 self.commits[curve_row..commit_row]
633 .iter()
634 .any(|c| c.lane == commit_lane)
635 } else {
636 false
637 };
638
639 if would_overlap {
640 *to_column = lane_column;
641 }
642 }
643 }
644
645 if let Some(commit_line) =
646 state.to_commit_lines(commit_row, lane_column, commit_lane)
647 {
648 self.lines.push(Rc::new(commit_line));
649 }
650 }
651 }
652
653 commit
654 .parents
655 .iter()
656 .enumerate()
657 .for_each(|(parent_idx, parent)| {
658 if parent_idx == 0 {
659 self.lane_states[commit_lane] = LaneState::Active {
660 parent: *parent,
661 child: commit.sha,
662 branch_id: commit_branch_id,
663 starting_col: commit_lane,
664 starting_row: commit_row,
665 destination_column: None,
666 segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
667 };
668
669 self.parent_to_lanes
670 .entry(*parent)
671 .or_default()
672 .push(commit_lane);
673 } else {
674 let new_lane = self.first_empty_lane_idx();
675 let branch_id = self
676 .origin_branch_id_for_parent(*parent)
677 .unwrap_or_else(|| self.allocate_branch_id());
678
679 self.lane_states[new_lane] = LaneState::Active {
680 parent: *parent,
681 child: commit.sha,
682 branch_id,
683 starting_col: commit_lane,
684 starting_row: commit_row,
685 destination_column: None,
686 segments: smallvec![CommitLineSegment::Curve {
687 to_column: usize::MAX,
688 on_row: usize::MAX,
689 curve_kind: CurveKind::Merge,
690 },],
691 };
692
693 self.parent_to_lanes
694 .entry(*parent)
695 .or_default()
696 .push(new_lane);
697 }
698 });
699
700 self.max_lanes = self.max_lanes.max(self.lane_states.len());
701
702 self.commits.push(Rc::new(CommitEntry {
703 data: commit.clone(),
704 lane: commit_lane,
705 branch_id: commit_branch_id,
706 }));
707 }
708
709 self.max_commit_count = AllCommitCount::Loaded(self.commits.len());
710 }
711}
712
713pub fn init(cx: &mut App) {
714 workspace::register_serializable_item::<GitGraph>(cx);
715
716 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
717 workspace.register_action_renderer(|div, workspace, _, cx| {
718 div.when(
719 workspace.project().read(cx).active_repository(cx).is_some()
720 && cx.has_flag::<GitGraphFeatureFlag>(),
721 |div| {
722 let workspace = workspace.weak_handle();
723
724 div.on_action({
725 let workspace = workspace.clone();
726 move |_: &git_ui::git_panel::Open, window, cx| {
727 workspace
728 .update(cx, |workspace, cx| {
729 let existing = workspace.items_of_type::<GitGraph>(cx).next();
730 if let Some(existing) = existing {
731 workspace.activate_item(&existing, true, true, window, cx);
732 return;
733 }
734
735 let project = workspace.project().clone();
736 let workspace_handle = workspace.weak_handle();
737 let git_graph = cx.new(|cx| {
738 GitGraph::new(project, workspace_handle, window, cx)
739 });
740 workspace.add_item_to_active_pane(
741 Box::new(git_graph),
742 None,
743 true,
744 window,
745 cx,
746 );
747 })
748 .ok();
749 }
750 })
751 .on_action(
752 move |action: &git_ui::git_panel::OpenAtCommit, window, cx| {
753 let sha = action.sha.clone();
754 workspace
755 .update(cx, |workspace, cx| {
756 let existing = workspace.items_of_type::<GitGraph>(cx).next();
757 if let Some(existing) = existing {
758 existing.update(cx, |graph, cx| {
759 graph.select_commit_by_sha(&sha, cx);
760 });
761 workspace.activate_item(&existing, true, true, window, cx);
762 return;
763 }
764
765 let project = workspace.project().clone();
766 let workspace_handle = workspace.weak_handle();
767 let git_graph = cx.new(|cx| {
768 let mut graph =
769 GitGraph::new(project, workspace_handle, window, cx);
770 graph.select_commit_by_sha(&sha, cx);
771 graph
772 });
773 workspace.add_item_to_active_pane(
774 Box::new(git_graph),
775 None,
776 true,
777 window,
778 cx,
779 );
780 })
781 .ok();
782 },
783 )
784 },
785 )
786 });
787 })
788 .detach();
789}
790
791fn lane_center_x(bounds: Bounds<Pixels>, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels {
792 bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset
793}
794
795fn to_row_center(
796 to_row: usize,
797 row_height: Pixels,
798 scroll_offset: Pixels,
799 bounds: Bounds<Pixels>,
800) -> Pixels {
801 bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
802}
803
804fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
805 let radius = COMMIT_CIRCLE_RADIUS;
806
807 let mut builder = PathBuilder::fill();
808
809 // Start at the rightmost point of the circle
810 builder.move_to(point(center_x + radius, center_y));
811
812 // Draw the circle using two arc_to calls (top half, then bottom half)
813 builder.arc_to(
814 point(radius, radius),
815 px(0.),
816 false,
817 true,
818 point(center_x - radius, center_y),
819 );
820 builder.arc_to(
821 point(radius, radius),
822 px(0.),
823 false,
824 true,
825 point(center_x + radius, center_y),
826 );
827 builder.close();
828
829 if let Ok(path) = builder.build() {
830 window.paint_path(path, color);
831 }
832}
833
834fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
835 diff.files.iter().fold((0, 0), |(added, removed), file| {
836 let old_text = file.old_text.as_deref().unwrap_or("");
837 let new_text = file.new_text.as_deref().unwrap_or("");
838 let hunks = line_diff(old_text, new_text);
839 hunks
840 .iter()
841 .fold((added, removed), |(a, r), (old_range, new_range)| {
842 (
843 a + (new_range.end - new_range.start) as usize,
844 r + (old_range.end - old_range.start) as usize,
845 )
846 })
847 })
848}
849
850pub struct GitGraph {
851 focus_handle: FocusHandle,
852 graph_data: GraphData,
853 project: Entity<Project>,
854 workspace: WeakEntity<Workspace>,
855 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
856 row_height: Pixels,
857 table_interaction_state: Entity<TableInteractionState>,
858 table_column_widths: Entity<TableColumnWidths>,
859 horizontal_scroll_offset: Pixels,
860 graph_viewport_width: Pixels,
861 selected_entry_idx: Option<usize>,
862 hovered_entry_idx: Option<usize>,
863 graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
864 log_source: LogSource,
865 log_order: LogOrder,
866 selected_commit_diff: Option<CommitDiff>,
867 selected_commit_diff_stats: Option<(usize, usize)>,
868 _commit_diff_task: Option<Task<()>>,
869 commit_details_split_state: Entity<SplitState>,
870 selected_repo_id: Option<RepositoryId>,
871 changed_files_scroll_handle: UniformListScrollHandle,
872 pending_select_sha: Option<Oid>,
873}
874
875impl GitGraph {
876 fn row_height(cx: &App) -> Pixels {
877 let settings = ThemeSettings::get_global(cx);
878 let font_size = settings.buffer_font_size(cx);
879 font_size + px(12.0)
880 }
881
882 fn graph_content_width(&self) -> Pixels {
883 (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
884 }
885
886 pub fn new(
887 project: Entity<Project>,
888 workspace: WeakEntity<Workspace>,
889 window: &mut Window,
890 cx: &mut Context<Self>,
891 ) -> Self {
892 let focus_handle = cx.focus_handle();
893 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
894 .detach();
895
896 let git_store = project.read(cx).git_store().clone();
897 let accent_colors = cx.theme().accents();
898 let graph = GraphData::new(accent_colors_count(accent_colors));
899 let log_source = LogSource::default();
900 let log_order = LogOrder::default();
901
902 cx.subscribe(&git_store, |this, _, event, cx| match event {
903 GitStoreEvent::RepositoryUpdated(updated_repo_id, repo_event, _) => {
904 if this
905 .selected_repo_id
906 .as_ref()
907 .is_some_and(|repo_id| repo_id == updated_repo_id)
908 {
909 if let Some(repository) = this.get_selected_repository(cx) {
910 this.on_repository_event(repository, repo_event, cx);
911 }
912 }
913 }
914 GitStoreEvent::ActiveRepositoryChanged(changed_repo_id) => {
915 // todo(git_graph): Make this selectable from UI so we don't have to always use active repository
916 if this.selected_repo_id != *changed_repo_id {
917 this.selected_repo_id = *changed_repo_id;
918 this.graph_data.clear();
919 cx.notify();
920 }
921 }
922 _ => {}
923 })
924 .detach();
925
926 let active_repository = project
927 .read(cx)
928 .active_repository(cx)
929 .map(|repo| repo.read(cx).id);
930
931 let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
932 let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx));
933 let mut row_height = Self::row_height(cx);
934
935 cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
936 let new_row_height = Self::row_height(cx);
937 if new_row_height != row_height {
938 this.row_height = new_row_height;
939 this.table_interaction_state.update(cx, |state, _cx| {
940 state.scroll_handle.0.borrow_mut().last_item_size = None;
941 });
942 row_height = new_row_height;
943 cx.notify();
944 }
945 })
946 .detach();
947
948 let mut this = GitGraph {
949 focus_handle,
950 project,
951 workspace,
952 graph_data: graph,
953 _commit_diff_task: None,
954 context_menu: None,
955 row_height,
956 table_interaction_state,
957 table_column_widths,
958 horizontal_scroll_offset: px(0.),
959 graph_viewport_width: px(88.),
960 selected_entry_idx: None,
961 hovered_entry_idx: None,
962 graph_canvas_bounds: Rc::new(Cell::new(None)),
963 selected_commit_diff: None,
964 selected_commit_diff_stats: None,
965 log_source,
966 log_order,
967 commit_details_split_state: cx.new(|_cx| SplitState::new()),
968 selected_repo_id: active_repository,
969 changed_files_scroll_handle: UniformListScrollHandle::new(),
970 pending_select_sha: None,
971 };
972
973 this.fetch_initial_graph_data(cx);
974 this
975 }
976
977 fn on_repository_event(
978 &mut self,
979 repository: Entity<Repository>,
980 event: &RepositoryEvent,
981 cx: &mut Context<Self>,
982 ) {
983 match event {
984 RepositoryEvent::GraphEvent((source, order), event)
985 if source == &self.log_source && order == &self.log_order =>
986 {
987 match event {
988 GitGraphEvent::FullyLoaded => {
989 if let Some(pending_sha_index) =
990 self.pending_select_sha.take().and_then(|oid| {
991 repository
992 .read(cx)
993 .get_graph_data(source.clone(), *order)
994 .and_then(|data| data.commit_oid_to_index.get(&oid).copied())
995 })
996 {
997 self.select_entry(pending_sha_index, cx);
998 }
999 }
1000 GitGraphEvent::LoadingError => {
1001 // todo(git_graph): Wire this up with the UI
1002 }
1003 GitGraphEvent::CountUpdated(commit_count) => {
1004 let old_count = self.graph_data.commits.len();
1005
1006 if let Some(pending_selection_index) =
1007 repository.update(cx, |repository, cx| {
1008 let GraphDataResponse {
1009 commits,
1010 is_loading,
1011 error: _,
1012 } = repository.graph_data(
1013 source.clone(),
1014 *order,
1015 old_count..*commit_count,
1016 cx,
1017 );
1018 self.graph_data.add_commits(commits);
1019
1020 let pending_sha_index = self.pending_select_sha.and_then(|oid| {
1021 repository.get_graph_data(source.clone(), *order).and_then(
1022 |data| data.commit_oid_to_index.get(&oid).copied(),
1023 )
1024 });
1025
1026 if !is_loading && pending_sha_index.is_none() {
1027 self.pending_select_sha.take();
1028 }
1029
1030 pending_sha_index
1031 })
1032 {
1033 self.select_entry(pending_selection_index, cx);
1034 self.pending_select_sha.take();
1035 }
1036
1037 cx.notify();
1038 }
1039 }
1040 }
1041 RepositoryEvent::BranchChanged => {
1042 self.pending_select_sha = None;
1043 // Only invalidate if we scanned atleast once,
1044 // meaning we are not inside the initial repo loading state
1045 // NOTE: this fixes an loading performance regression
1046 if repository.read(cx).scan_id > 1 {
1047 self.graph_data.clear();
1048 cx.notify();
1049 }
1050 }
1051 RepositoryEvent::GraphEvent(_, _) => {}
1052 _ => {}
1053 }
1054 }
1055
1056 fn fetch_initial_graph_data(&mut self, cx: &mut App) {
1057 if let Some(repository) = self.get_selected_repository(cx) {
1058 repository.update(cx, |repository, cx| {
1059 let commits = repository
1060 .graph_data(self.log_source.clone(), self.log_order, 0..usize::MAX, cx)
1061 .commits;
1062 self.graph_data.add_commits(commits);
1063 });
1064 }
1065 }
1066
1067 fn get_selected_repository(&self, cx: &App) -> Option<Entity<Repository>> {
1068 let project = self.project.read(cx);
1069 self.selected_repo_id
1070 .as_ref()
1071 .and_then(|repo_id| project.repositories(cx).get(&repo_id).cloned())
1072 }
1073
1074 fn render_chip(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
1075 Chip::new(name.clone())
1076 .label_size(LabelSize::Small)
1077 .bg_color(accent_color.opacity(0.1))
1078 .border_color(accent_color.opacity(0.5))
1079 }
1080
1081 fn render_table_rows(
1082 &mut self,
1083 range: Range<usize>,
1084 _window: &mut Window,
1085 cx: &mut Context<Self>,
1086 ) -> Vec<Vec<AnyElement>> {
1087 let repository = self.get_selected_repository(cx);
1088
1089 let row_height = self.row_height;
1090
1091 // We fetch data outside the visible viewport to avoid loading entries when
1092 // users scroll through the git graph
1093 if let Some(repository) = repository.as_ref() {
1094 const FETCH_RANGE: usize = 100;
1095 repository.update(cx, |repository, cx| {
1096 self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
1097 ..(range.end + FETCH_RANGE)
1098 .min(self.graph_data.commits.len().saturating_sub(1))]
1099 .iter()
1100 .for_each(|commit| {
1101 repository.fetch_commit_data(commit.data.sha, cx);
1102 });
1103 });
1104 }
1105
1106 range
1107 .map(|idx| {
1108 let Some((commit, repository)) =
1109 self.graph_data.commits.get(idx).zip(repository.as_ref())
1110 else {
1111 return vec![
1112 div().h(row_height).into_any_element(),
1113 div().h(row_height).into_any_element(),
1114 div().h(row_height).into_any_element(),
1115 div().h(row_height).into_any_element(),
1116 ];
1117 };
1118
1119 let data = repository.update(cx, |repository, cx| {
1120 repository.fetch_commit_data(commit.data.sha, cx).clone()
1121 });
1122
1123 let short_sha = commit.data.sha.display_short();
1124 let mut formatted_time = String::new();
1125 let subject: SharedString;
1126 let author_name: SharedString;
1127
1128 if let CommitDataState::Loaded(data) = data {
1129 subject = data.subject.clone();
1130 author_name = data.author_name.clone();
1131 formatted_time = format_timestamp(data.commit_timestamp);
1132 } else {
1133 subject = "Loading…".into();
1134 author_name = "".into();
1135 }
1136
1137 let accent_colors = cx.theme().accents();
1138 let accent_color = accent_colors.color_for_index(commit.branch_id.0 as u32);
1139
1140 let is_selected = self.selected_entry_idx == Some(idx);
1141 let column_label = |label: SharedString| {
1142 Label::new(label)
1143 .when(!is_selected, |c| c.color(Color::Muted))
1144 .truncate()
1145 .into_any_element()
1146 };
1147
1148 vec![
1149 div()
1150 .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1151 .overflow_hidden()
1152 .tooltip(Tooltip::text(subject.clone()))
1153 .child(
1154 h_flex()
1155 .gap_2()
1156 .overflow_hidden()
1157 .children((!commit.data.ref_names.is_empty()).then(|| {
1158 h_flex().gap_1().children(
1159 commit
1160 .data
1161 .ref_names
1162 .iter()
1163 .map(|name| self.render_chip(name, accent_color)),
1164 )
1165 }))
1166 .child(column_label(subject)),
1167 )
1168 .into_any_element(),
1169 column_label(formatted_time.into()),
1170 column_label(author_name),
1171 column_label(short_sha.into()),
1172 ]
1173 })
1174 .collect()
1175 }
1176
1177 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1178 self.selected_entry_idx = None;
1179 self.selected_commit_diff = None;
1180 self.selected_commit_diff_stats = None;
1181 cx.notify();
1182 }
1183
1184 fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context<Self>) {
1185 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1186 self.select_entry(selected_entry_idx.saturating_sub(1), cx);
1187 } else {
1188 self.select_entry(0, cx);
1189 }
1190 }
1191
1192 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1193 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1194 self.select_entry(selected_entry_idx.saturating_add(1), cx);
1195 } else {
1196 self.select_prev(&SelectPrevious, window, cx);
1197 }
1198 }
1199
1200 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1201 self.open_selected_commit_view(window, cx);
1202 }
1203
1204 fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
1205 if self.selected_entry_idx == Some(idx) {
1206 return;
1207 }
1208
1209 self.selected_entry_idx = Some(idx);
1210 self.selected_commit_diff = None;
1211 self.selected_commit_diff_stats = None;
1212 self.changed_files_scroll_handle
1213 .scroll_to_item(0, ScrollStrategy::Top);
1214 self.table_interaction_state.update(cx, |state, cx| {
1215 state
1216 .scroll_handle
1217 .scroll_to_item(idx, ScrollStrategy::Nearest);
1218 cx.notify();
1219 });
1220
1221 let Some(commit) = self.graph_data.commits.get(idx) else {
1222 return;
1223 };
1224
1225 let sha = commit.data.sha.to_string();
1226
1227 let Some(repository) = self.get_selected_repository(cx) else {
1228 return;
1229 };
1230
1231 let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1232
1233 self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1234 if let Ok(Ok(diff)) = diff_receiver.await {
1235 this.update(cx, |this, cx| {
1236 let stats = compute_diff_stats(&diff);
1237 this.selected_commit_diff = Some(diff);
1238 this.selected_commit_diff_stats = Some(stats);
1239 cx.notify();
1240 })
1241 .ok();
1242 }
1243 }));
1244
1245 cx.notify();
1246 }
1247
1248 pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context<Self>) {
1249 let Ok(oid) = sha.parse::<Oid>() else {
1250 return;
1251 };
1252
1253 let Some(selected_repository) = self.get_selected_repository(cx) else {
1254 return;
1255 };
1256
1257 let Some(index) = selected_repository
1258 .read(cx)
1259 .get_graph_data(self.log_source.clone(), self.log_order)
1260 .and_then(|data| data.commit_oid_to_index.get(&oid))
1261 .copied()
1262 else {
1263 return;
1264 };
1265
1266 self.select_entry(index, cx);
1267 }
1268
1269 fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1270 let Some(selected_entry_index) = self.selected_entry_idx else {
1271 return;
1272 };
1273
1274 self.open_commit_view(selected_entry_index, window, cx);
1275 }
1276
1277 fn open_commit_view(
1278 &mut self,
1279 entry_index: usize,
1280 window: &mut Window,
1281 cx: &mut Context<Self>,
1282 ) {
1283 let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1284 return;
1285 };
1286
1287 let Some(repository) = self.get_selected_repository(cx) else {
1288 return;
1289 };
1290
1291 CommitView::open(
1292 commit_entry.data.sha.to_string(),
1293 repository.downgrade(),
1294 self.workspace.clone(),
1295 None,
1296 None,
1297 window,
1298 cx,
1299 );
1300 }
1301
1302 fn get_remote(
1303 &self,
1304 repository: &Repository,
1305 _window: &mut Window,
1306 cx: &mut App,
1307 ) -> Option<GitRemote> {
1308 let remote_url = repository.default_remote_url()?;
1309 let provider_registry = GitHostingProviderRegistry::default_global(cx);
1310 let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1311 Some(GitRemote {
1312 host: provider,
1313 owner: parsed.owner.into(),
1314 repo: parsed.repo.into(),
1315 })
1316 }
1317
1318 fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1319 let rems = TextSize::Large.rems(cx);
1320 Icon::new(IconName::LoadCircle)
1321 .size(IconSize::Custom(rems))
1322 .color(Color::Accent)
1323 .with_rotate_animation(3)
1324 .into_any_element()
1325 }
1326
1327 fn render_commit_detail_panel(
1328 &self,
1329 window: &mut Window,
1330 cx: &mut Context<Self>,
1331 ) -> impl IntoElement {
1332 let Some(selected_idx) = self.selected_entry_idx else {
1333 return Empty.into_any_element();
1334 };
1335
1336 let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1337 return Empty.into_any_element();
1338 };
1339
1340 let Some(repository) = self.get_selected_repository(cx) else {
1341 return Empty.into_any_element();
1342 };
1343
1344 let data = repository.update(cx, |repository, cx| {
1345 repository
1346 .fetch_commit_data(commit_entry.data.sha, cx)
1347 .clone()
1348 });
1349
1350 let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1351 let ref_names = commit_entry.data.ref_names.clone();
1352
1353 let accent_colors = cx.theme().accents();
1354 let accent_color = accent_colors.color_for_index(commit_entry.branch_id.0 as u32);
1355
1356 let (author_name, author_email, commit_timestamp, subject) = match &data {
1357 CommitDataState::Loaded(data) => (
1358 data.author_name.clone(),
1359 data.author_email.clone(),
1360 Some(data.commit_timestamp),
1361 data.subject.clone(),
1362 ),
1363 CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
1364 };
1365
1366 let date_string = commit_timestamp
1367 .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1368 .map(|datetime| {
1369 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1370 let local_datetime = datetime.to_offset(local_offset);
1371 let format =
1372 time::format_description::parse("[month repr:short] [day], [year]").ok();
1373 format
1374 .and_then(|f| local_datetime.format(&f).ok())
1375 .unwrap_or_default()
1376 })
1377 .unwrap_or_default();
1378
1379 let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1380
1381 let avatar = {
1382 let author_email_for_avatar = if author_email.is_empty() {
1383 None
1384 } else {
1385 Some(author_email.clone())
1386 };
1387
1388 CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
1389 .size(px(40.))
1390 .render(window, cx)
1391 };
1392
1393 let changed_files_count = self
1394 .selected_commit_diff
1395 .as_ref()
1396 .map(|diff| diff.files.len())
1397 .unwrap_or(0);
1398
1399 let (total_lines_added, total_lines_removed) =
1400 self.selected_commit_diff_stats.unwrap_or((0, 0));
1401
1402 let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1403 self.selected_commit_diff
1404 .as_ref()
1405 .map(|diff| {
1406 let mut files: Vec<_> = diff.files.iter().collect();
1407 files.sort_by_key(|file| file.status());
1408 files
1409 .into_iter()
1410 .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1411 .collect()
1412 })
1413 .unwrap_or_default(),
1414 );
1415
1416 v_flex()
1417 .min_w(px(300.))
1418 .h_full()
1419 .bg(cx.theme().colors().surface_background)
1420 .flex_basis(DefiniteLength::Fraction(
1421 self.commit_details_split_state.read(cx).right_ratio(),
1422 ))
1423 .child(
1424 v_flex()
1425 .relative()
1426 .w_full()
1427 .p_2()
1428 .gap_2()
1429 .child(
1430 div().absolute().top_2().right_2().child(
1431 IconButton::new("close-detail", IconName::Close)
1432 .icon_size(IconSize::Small)
1433 .on_click(cx.listener(move |this, _, _, cx| {
1434 this.selected_entry_idx = None;
1435 this.selected_commit_diff = None;
1436 this.selected_commit_diff_stats = None;
1437 this._commit_diff_task = None;
1438 cx.notify();
1439 })),
1440 ),
1441 )
1442 .child(
1443 v_flex()
1444 .py_1()
1445 .w_full()
1446 .items_center()
1447 .gap_1()
1448 .child(avatar)
1449 .child(
1450 v_flex()
1451 .items_center()
1452 .child(Label::new(author_name))
1453 .child(
1454 Label::new(date_string)
1455 .color(Color::Muted)
1456 .size(LabelSize::Small),
1457 ),
1458 ),
1459 )
1460 .children((!ref_names.is_empty()).then(|| {
1461 h_flex().gap_1().flex_wrap().justify_center().children(
1462 ref_names
1463 .iter()
1464 .map(|name| self.render_chip(name, accent_color)),
1465 )
1466 }))
1467 .child(
1468 v_flex()
1469 .ml_neg_1()
1470 .gap_1p5()
1471 .when(!author_email.is_empty(), |this| {
1472 let copied_state: Entity<CopiedState> = window.use_keyed_state(
1473 "author-email-copy",
1474 cx,
1475 CopiedState::new,
1476 );
1477 let is_copied = copied_state.read(cx).is_copied();
1478
1479 let (icon, icon_color, tooltip_label) = if is_copied {
1480 (IconName::Check, Color::Success, "Email Copied!")
1481 } else {
1482 (IconName::Envelope, Color::Muted, "Copy Email")
1483 };
1484
1485 let copy_email = author_email.clone();
1486 let author_email_for_tooltip = author_email.clone();
1487
1488 this.child(
1489 Button::new("author-email-copy", author_email.clone())
1490 .icon(icon)
1491 .icon_size(IconSize::Small)
1492 .icon_color(icon_color)
1493 .icon_position(IconPosition::Start)
1494 .label_size(LabelSize::Small)
1495 .truncate(true)
1496 .color(Color::Muted)
1497 .tooltip(move |_, cx| {
1498 Tooltip::with_meta(
1499 tooltip_label,
1500 None,
1501 author_email_for_tooltip.clone(),
1502 cx,
1503 )
1504 })
1505 .on_click(move |_, _, cx| {
1506 copied_state.update(cx, |state, _cx| {
1507 state.mark_copied();
1508 });
1509 cx.write_to_clipboard(ClipboardItem::new_string(
1510 copy_email.to_string(),
1511 ));
1512 let state_id = copied_state.entity_id();
1513 cx.spawn(async move |cx| {
1514 cx.background_executor()
1515 .timer(COPIED_STATE_DURATION)
1516 .await;
1517 cx.update(|cx| {
1518 cx.notify(state_id);
1519 })
1520 })
1521 .detach();
1522 }),
1523 )
1524 })
1525 .child({
1526 let copy_sha = full_sha.clone();
1527 let copied_state: Entity<CopiedState> =
1528 window.use_keyed_state("sha-copy", cx, CopiedState::new);
1529 let is_copied = copied_state.read(cx).is_copied();
1530
1531 let (icon, icon_color, tooltip_label) = if is_copied {
1532 (IconName::Check, Color::Success, "Commit SHA Copied!")
1533 } else {
1534 (IconName::Hash, Color::Muted, "Copy Commit SHA")
1535 };
1536
1537 Button::new("sha-button", &full_sha)
1538 .icon(icon)
1539 .icon_size(IconSize::Small)
1540 .icon_color(icon_color)
1541 .icon_position(IconPosition::Start)
1542 .label_size(LabelSize::Small)
1543 .truncate(true)
1544 .color(Color::Muted)
1545 .tooltip({
1546 let full_sha = full_sha.clone();
1547 move |_, cx| {
1548 Tooltip::with_meta(
1549 tooltip_label,
1550 None,
1551 full_sha.clone(),
1552 cx,
1553 )
1554 }
1555 })
1556 .on_click(move |_, _, cx| {
1557 copied_state.update(cx, |state, _cx| {
1558 state.mark_copied();
1559 });
1560 cx.write_to_clipboard(ClipboardItem::new_string(
1561 copy_sha.to_string(),
1562 ));
1563 let state_id = copied_state.entity_id();
1564 cx.spawn(async move |cx| {
1565 cx.background_executor()
1566 .timer(COPIED_STATE_DURATION)
1567 .await;
1568 cx.update(|cx| {
1569 cx.notify(state_id);
1570 })
1571 })
1572 .detach();
1573 })
1574 })
1575 .when_some(remote.clone(), |this, remote| {
1576 let provider_name = remote.host.name();
1577 let icon = match provider_name.as_str() {
1578 "GitHub" => IconName::Github,
1579 _ => IconName::Link,
1580 };
1581 let parsed_remote = ParsedGitRemote {
1582 owner: remote.owner.as_ref().into(),
1583 repo: remote.repo.as_ref().into(),
1584 };
1585 let params = BuildCommitPermalinkParams {
1586 sha: full_sha.as_ref(),
1587 };
1588 let url = remote
1589 .host
1590 .build_commit_permalink(&parsed_remote, params)
1591 .to_string();
1592
1593 this.child(
1594 Button::new(
1595 "view-on-provider",
1596 format!("View on {}", provider_name),
1597 )
1598 .icon(icon)
1599 .icon_size(IconSize::Small)
1600 .icon_color(Color::Muted)
1601 .icon_position(IconPosition::Start)
1602 .label_size(LabelSize::Small)
1603 .truncate(true)
1604 .color(Color::Muted)
1605 .on_click(
1606 move |_, _, cx| {
1607 cx.open_url(&url);
1608 },
1609 ),
1610 )
1611 }),
1612 ),
1613 )
1614 .child(Divider::horizontal())
1615 .child(div().min_w_0().p_2().child(Label::new(subject)))
1616 .child(Divider::horizontal())
1617 .child(
1618 v_flex()
1619 .min_w_0()
1620 .p_2()
1621 .flex_1()
1622 .gap_1()
1623 .child(
1624 h_flex()
1625 .gap_1()
1626 .child(
1627 Label::new(format!("{} Changed Files", changed_files_count))
1628 .size(LabelSize::Small)
1629 .color(Color::Muted),
1630 )
1631 .child(DiffStat::new(
1632 "commit-diff-stat",
1633 total_lines_added,
1634 total_lines_removed,
1635 )),
1636 )
1637 .child(
1638 div()
1639 .id("changed-files-container")
1640 .flex_1()
1641 .min_h_0()
1642 .child({
1643 let entries = sorted_file_entries;
1644 let entry_count = entries.len();
1645 let commit_sha = full_sha.clone();
1646 let repository = repository.downgrade();
1647 let workspace = self.workspace.clone();
1648 uniform_list(
1649 "changed-files-list",
1650 entry_count,
1651 move |range, _window, cx| {
1652 range
1653 .map(|ix| {
1654 entries[ix].render(
1655 ix,
1656 commit_sha.clone(),
1657 repository.clone(),
1658 workspace.clone(),
1659 cx,
1660 )
1661 })
1662 .collect()
1663 },
1664 )
1665 .size_full()
1666 .ml_neg_1()
1667 .track_scroll(&self.changed_files_scroll_handle)
1668 })
1669 .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
1670 ),
1671 )
1672 .child(Divider::horizontal())
1673 .child(
1674 h_flex().p_1p5().w_full().child(
1675 Button::new("view-commit", "View Commit")
1676 .full_width()
1677 .style(ButtonStyle::Outlined)
1678 .on_click(cx.listener(|this, _, window, cx| {
1679 this.open_selected_commit_view(window, cx);
1680 })),
1681 ),
1682 )
1683 .into_any_element()
1684 }
1685
1686 pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
1687 let row_height = self.row_height;
1688 let table_state = self.table_interaction_state.read(cx);
1689 let viewport_height = table_state
1690 .scroll_handle
1691 .0
1692 .borrow()
1693 .last_item_size
1694 .map(|size| size.item.height)
1695 .unwrap_or(px(600.0));
1696 let loaded_commit_count = self.graph_data.commits.len();
1697
1698 let content_height = row_height * loaded_commit_count;
1699 let max_scroll = (content_height - viewport_height).max(px(0.));
1700 let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
1701
1702 let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
1703 let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
1704 let horizontal_scroll_offset = self.horizontal_scroll_offset;
1705
1706 let max_lanes = self.graph_data.max_lanes.max(6);
1707 let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1708 let last_visible_row =
1709 first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
1710
1711 let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
1712 ..(last_visible_row).min(loaded_commit_count);
1713 let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
1714 let commit_lines: Vec<_> = self
1715 .graph_data
1716 .lines
1717 .iter()
1718 .filter(|line| {
1719 line.full_interval.start <= viewport_range.end
1720 && line.full_interval.end >= viewport_range.start
1721 })
1722 .cloned()
1723 .collect();
1724
1725 let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
1726
1727 let hovered_entry_idx = self.hovered_entry_idx;
1728 let selected_entry_idx = self.selected_entry_idx;
1729 let is_focused = self.focus_handle.is_focused(window);
1730 let graph_canvas_bounds = self.graph_canvas_bounds.clone();
1731
1732 gpui::canvas(
1733 move |_bounds, _window, _cx| {},
1734 move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
1735 graph_canvas_bounds.set(Some(bounds));
1736
1737 window.paint_layer(bounds, |window| {
1738 let accent_colors = cx.theme().accents();
1739
1740 let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
1741 let selected_bg = if is_focused {
1742 cx.theme().colors().element_selected
1743 } else {
1744 cx.theme().colors().element_hover
1745 };
1746
1747 for visible_row_idx in 0..rows.len() {
1748 let absolute_row_idx = first_visible_row + visible_row_idx;
1749 let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
1750 let is_selected = selected_entry_idx == Some(absolute_row_idx);
1751
1752 if is_hovered || is_selected {
1753 let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
1754 - vertical_scroll_offset;
1755
1756 let row_bounds = Bounds::new(
1757 point(bounds.origin.x, row_y),
1758 gpui::Size {
1759 width: bounds.size.width,
1760 height: row_height,
1761 },
1762 );
1763
1764 let bg_color = if is_selected { selected_bg } else { hover_bg };
1765 window.paint_quad(gpui::fill(row_bounds, bg_color));
1766 }
1767 }
1768
1769 for (row_idx, row) in rows.into_iter().enumerate() {
1770 let row_color = accent_colors.color_for_index(row.branch_id.0 as u32);
1771 let row_y_center =
1772 bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
1773 - vertical_scroll_offset;
1774
1775 let commit_x =
1776 lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
1777
1778 draw_commit_circle(commit_x, row_y_center, row_color, window);
1779 }
1780
1781 for line in commit_lines {
1782 let Some((start_segment_idx, start_column)) =
1783 line.get_first_visible_segment_idx(first_visible_row)
1784 else {
1785 continue;
1786 };
1787
1788 let line_x =
1789 lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
1790
1791 let start_row = line.full_interval.start as i32 - first_visible_row as i32;
1792
1793 let from_y =
1794 bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
1795 - vertical_scroll_offset
1796 + COMMIT_CIRCLE_RADIUS;
1797
1798 let mut current_row = from_y;
1799 let mut current_column = line_x;
1800
1801 let mut builder = PathBuilder::stroke(LINE_WIDTH);
1802 builder.move_to(point(line_x, from_y));
1803
1804 let segments = &line.segments[start_segment_idx..];
1805
1806 for (segment_idx, segment) in segments.iter().enumerate() {
1807 let is_last = segment_idx + 1 == segments.len();
1808
1809 match segment {
1810 CommitLineSegment::Straight { to_row } => {
1811 let mut dest_row = to_row_center(
1812 to_row - first_visible_row,
1813 row_height,
1814 vertical_scroll_offset,
1815 bounds,
1816 );
1817 if is_last {
1818 dest_row -= COMMIT_CIRCLE_RADIUS;
1819 }
1820
1821 let dest_point = point(current_column, dest_row);
1822
1823 current_row = dest_point.y;
1824 builder.line_to(dest_point);
1825 builder.move_to(dest_point);
1826 }
1827 CommitLineSegment::Curve {
1828 to_column,
1829 on_row,
1830 curve_kind,
1831 } => {
1832 let mut to_column = lane_center_x(
1833 bounds,
1834 *to_column as f32,
1835 horizontal_scroll_offset,
1836 );
1837
1838 let mut to_row = to_row_center(
1839 *on_row - first_visible_row,
1840 row_height,
1841 vertical_scroll_offset,
1842 bounds,
1843 );
1844
1845 // This means that this branch was a checkout
1846 let going_right = to_column > current_column;
1847 let column_shift = if going_right {
1848 COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
1849 } else {
1850 -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
1851 };
1852
1853 let control = match curve_kind {
1854 CurveKind::Checkout => {
1855 if is_last {
1856 to_column -= column_shift;
1857 }
1858 builder.move_to(point(current_column, current_row));
1859 point(current_column, to_row)
1860 }
1861 CurveKind::Merge => {
1862 if is_last {
1863 to_row -= COMMIT_CIRCLE_RADIUS;
1864 }
1865 builder.move_to(point(
1866 current_column + column_shift,
1867 current_row - COMMIT_CIRCLE_RADIUS,
1868 ));
1869 point(to_column, current_row)
1870 }
1871 };
1872
1873 match curve_kind {
1874 CurveKind::Checkout
1875 if (to_row - current_row).abs() > row_height =>
1876 {
1877 let start_curve =
1878 point(current_column, current_row + row_height);
1879 builder.line_to(start_curve);
1880 builder.move_to(start_curve);
1881 }
1882 CurveKind::Merge
1883 if (to_column - current_column).abs() > LANE_WIDTH =>
1884 {
1885 let column_shift =
1886 if going_right { LANE_WIDTH } else { -LANE_WIDTH };
1887
1888 let start_curve = point(
1889 current_column + column_shift,
1890 current_row - COMMIT_CIRCLE_RADIUS,
1891 );
1892
1893 builder.line_to(start_curve);
1894 builder.move_to(start_curve);
1895 }
1896 _ => {}
1897 };
1898
1899 builder.curve_to(point(to_column, to_row), control);
1900 current_row = to_row;
1901 current_column = to_column;
1902 builder.move_to(point(current_column, current_row));
1903 }
1904 }
1905 }
1906
1907 builder.close();
1908 lines.entry(line.branch_id.0).or_default().push(builder);
1909 }
1910
1911 for (branch_id, builders) in lines {
1912 let line_color = accent_colors.color_for_index(branch_id as u32);
1913
1914 for builder in builders {
1915 if let Ok(path) = builder.build() {
1916 // we paint each color on it's own layer to stop overlapping lines
1917 // of different colors changing the color of a line
1918 window.paint_layer(bounds, |window| {
1919 window.paint_path(path, line_color);
1920 });
1921 }
1922 }
1923 }
1924 })
1925 },
1926 )
1927 .w(graph_width)
1928 .h_full()
1929 }
1930
1931 fn row_at_position(&self, position_y: Pixels, cx: &Context<Self>) -> Option<usize> {
1932 let canvas_bounds = self.graph_canvas_bounds.get()?;
1933 let table_state = self.table_interaction_state.read(cx);
1934 let scroll_offset_y = -table_state.scroll_offset().y;
1935
1936 let local_y = position_y - canvas_bounds.origin.y;
1937
1938 if local_y >= px(0.) && local_y < canvas_bounds.size.height {
1939 let row_in_viewport = (local_y / self.row_height).floor() as usize;
1940 let scroll_rows = (scroll_offset_y / self.row_height).floor() as usize;
1941 let absolute_row = scroll_rows + row_in_viewport;
1942
1943 if absolute_row < self.graph_data.commits.len() {
1944 return Some(absolute_row);
1945 }
1946 }
1947
1948 None
1949 }
1950
1951 fn handle_graph_mouse_move(
1952 &mut self,
1953 event: &gpui::MouseMoveEvent,
1954 _window: &mut Window,
1955 cx: &mut Context<Self>,
1956 ) {
1957 if let Some(row) = self.row_at_position(event.position.y, cx) {
1958 if self.hovered_entry_idx != Some(row) {
1959 self.hovered_entry_idx = Some(row);
1960 cx.notify();
1961 }
1962 } else if self.hovered_entry_idx.is_some() {
1963 self.hovered_entry_idx = None;
1964 cx.notify();
1965 }
1966 }
1967
1968 fn handle_graph_click(
1969 &mut self,
1970 event: &ClickEvent,
1971 window: &mut Window,
1972 cx: &mut Context<Self>,
1973 ) {
1974 if let Some(row) = self.row_at_position(event.position().y, cx) {
1975 self.select_entry(row, cx);
1976 if event.click_count() >= 2 {
1977 self.open_commit_view(row, window, cx);
1978 }
1979 }
1980 }
1981
1982 fn handle_graph_scroll(
1983 &mut self,
1984 event: &ScrollWheelEvent,
1985 window: &mut Window,
1986 cx: &mut Context<Self>,
1987 ) {
1988 let line_height = window.line_height();
1989 let delta = event.delta.pixel_delta(line_height);
1990
1991 let table_state = self.table_interaction_state.read(cx);
1992 let current_offset = table_state.scroll_offset();
1993
1994 let viewport_height = table_state.scroll_handle.viewport().size.height;
1995
1996 let commit_count = match self.graph_data.max_commit_count {
1997 AllCommitCount::Loaded(count) => count,
1998 AllCommitCount::NotLoaded => self.graph_data.commits.len(),
1999 };
2000 let content_height = self.row_height * commit_count;
2001 let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
2002
2003 let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
2004 let new_offset = Point::new(current_offset.x, new_y);
2005
2006 let max_lanes = self.graph_data.max_lanes.max(1);
2007 let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
2008 let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
2009
2010 let new_horizontal_offset =
2011 (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
2012
2013 let vertical_changed = new_offset != current_offset;
2014 let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
2015
2016 if vertical_changed {
2017 table_state.set_scroll_offset(new_offset);
2018 }
2019
2020 if horizontal_changed {
2021 self.horizontal_scroll_offset = new_horizontal_offset;
2022 }
2023
2024 if vertical_changed || horizontal_changed {
2025 cx.notify();
2026 }
2027 }
2028
2029 fn render_commit_view_resize_handle(
2030 &self,
2031 _window: &mut Window,
2032 cx: &mut Context<Self>,
2033 ) -> AnyElement {
2034 div()
2035 .id("commit-view-split-resize-container")
2036 .relative()
2037 .h_full()
2038 .flex_shrink_0()
2039 .w(px(1.))
2040 .bg(cx.theme().colors().border_variant)
2041 .child(
2042 div()
2043 .id("commit-view-split-resize-handle")
2044 .absolute()
2045 .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
2046 .w(px(RESIZE_HANDLE_WIDTH))
2047 .h_full()
2048 .cursor_col_resize()
2049 .block_mouse_except_scroll()
2050 .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
2051 if event.click_count() >= 2 {
2052 this.commit_details_split_state.update(cx, |state, _| {
2053 state.on_double_click();
2054 });
2055 }
2056 cx.stop_propagation();
2057 }))
2058 .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
2059 )
2060 .into_any_element()
2061 }
2062}
2063
2064impl Render for GitGraph {
2065 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2066 let description_width_fraction = 0.72;
2067 let date_width_fraction = 0.12;
2068 let author_width_fraction = 0.10;
2069 let commit_width_fraction = 0.06;
2070
2071 let (commit_count, is_loading) = match self.graph_data.max_commit_count {
2072 AllCommitCount::Loaded(count) => (count, true),
2073 AllCommitCount::NotLoaded => {
2074 let (commit_count, is_loading) =
2075 if let Some(repository) = self.get_selected_repository(cx) {
2076 repository.update(cx, |repository, cx| {
2077 // Start loading the graph data if we haven't started already
2078 let GraphDataResponse {
2079 commits,
2080 is_loading,
2081 error: _,
2082 } = repository.graph_data(
2083 self.log_source.clone(),
2084 self.log_order,
2085 0..usize::MAX,
2086 cx,
2087 );
2088 self.graph_data.add_commits(&commits);
2089 (commits.len(), is_loading)
2090 })
2091 } else {
2092 (0, false)
2093 };
2094
2095 (commit_count, is_loading)
2096 }
2097 };
2098
2099 let content = if commit_count == 0 {
2100 let message = if is_loading {
2101 "Loading"
2102 } else {
2103 "No commits found"
2104 };
2105 let label = Label::new(message)
2106 .color(Color::Muted)
2107 .size(LabelSize::Large);
2108 div()
2109 .size_full()
2110 .h_flex()
2111 .gap_1()
2112 .items_center()
2113 .justify_center()
2114 .child(label)
2115 .when(is_loading, |this| {
2116 this.child(self.render_loading_spinner(cx))
2117 })
2118 } else {
2119 div()
2120 .size_full()
2121 .flex()
2122 .flex_row()
2123 .child(
2124 div()
2125 .w(self.graph_content_width())
2126 .h_full()
2127 .flex()
2128 .flex_col()
2129 .child(
2130 div()
2131 .p_2()
2132 .border_b_1()
2133 .whitespace_nowrap()
2134 .border_color(cx.theme().colors().border)
2135 .child(Label::new("Graph").color(Color::Muted)),
2136 )
2137 .child(
2138 div()
2139 .id("graph-canvas")
2140 .flex_1()
2141 .overflow_hidden()
2142 .child(self.render_graph(window, cx))
2143 .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
2144 .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
2145 .on_click(cx.listener(Self::handle_graph_click))
2146 .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
2147 if !is_hovered && this.hovered_entry_idx.is_some() {
2148 this.hovered_entry_idx = None;
2149 cx.notify();
2150 }
2151 })),
2152 ),
2153 )
2154 .child({
2155 let row_height = self.row_height;
2156 let selected_entry_idx = self.selected_entry_idx;
2157 let hovered_entry_idx = self.hovered_entry_idx;
2158 let weak_self = cx.weak_entity();
2159 let focus_handle = self.focus_handle.clone();
2160 div().flex_1().size_full().child(
2161 Table::new(4)
2162 .interactable(&self.table_interaction_state)
2163 .hide_row_borders()
2164 .hide_row_hover()
2165 .header(vec![
2166 Label::new("Description")
2167 .color(Color::Muted)
2168 .into_any_element(),
2169 Label::new("Date").color(Color::Muted).into_any_element(),
2170 Label::new("Author").color(Color::Muted).into_any_element(),
2171 Label::new("Commit").color(Color::Muted).into_any_element(),
2172 ])
2173 .column_widths(
2174 [
2175 DefiniteLength::Fraction(description_width_fraction),
2176 DefiniteLength::Fraction(date_width_fraction),
2177 DefiniteLength::Fraction(author_width_fraction),
2178 DefiniteLength::Fraction(commit_width_fraction),
2179 ]
2180 .to_vec(),
2181 )
2182 .resizable_columns(
2183 vec![
2184 TableResizeBehavior::Resizable,
2185 TableResizeBehavior::Resizable,
2186 TableResizeBehavior::Resizable,
2187 TableResizeBehavior::Resizable,
2188 ],
2189 &self.table_column_widths,
2190 cx,
2191 )
2192 .map_row(move |(index, row), window, cx| {
2193 let is_selected = selected_entry_idx == Some(index);
2194 let is_hovered = hovered_entry_idx == Some(index);
2195 let is_focused = focus_handle.is_focused(window);
2196 let weak = weak_self.clone();
2197 let weak_for_hover = weak.clone();
2198
2199 let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
2200 let selected_bg = if is_focused {
2201 cx.theme().colors().element_selected
2202 } else {
2203 cx.theme().colors().element_hover
2204 };
2205
2206 row.h(row_height)
2207 .when(is_selected, |row| row.bg(selected_bg))
2208 .when(is_hovered && !is_selected, |row| row.bg(hover_bg))
2209 .on_hover(move |&is_hovered, _, cx| {
2210 weak_for_hover
2211 .update(cx, |this, cx| {
2212 if is_hovered {
2213 if this.hovered_entry_idx != Some(index) {
2214 this.hovered_entry_idx = Some(index);
2215 cx.notify();
2216 }
2217 } else if this.hovered_entry_idx == Some(index) {
2218 // Only clear if this row was the hovered one
2219 this.hovered_entry_idx = None;
2220 cx.notify();
2221 }
2222 })
2223 .ok();
2224 })
2225 .on_click(move |event, window, cx| {
2226 let click_count = event.click_count();
2227 weak.update(cx, |this, cx| {
2228 this.select_entry(index, cx);
2229 if click_count >= 2 {
2230 this.open_commit_view(index, window, cx);
2231 }
2232 })
2233 .ok();
2234 })
2235 .into_any_element()
2236 })
2237 .uniform_list(
2238 "git-graph-commits",
2239 commit_count,
2240 cx.processor(Self::render_table_rows),
2241 ),
2242 )
2243 })
2244 .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
2245 this.commit_details_split_state.update(cx, |state, cx| {
2246 state.on_drag_move(event, window, cx);
2247 });
2248 }))
2249 .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
2250 this.commit_details_split_state.update(cx, |state, _cx| {
2251 state.commit_ratio();
2252 });
2253 }))
2254 .when(self.selected_entry_idx.is_some(), |this| {
2255 this.child(self.render_commit_view_resize_handle(window, cx))
2256 .child(self.render_commit_detail_panel(window, cx))
2257 })
2258 };
2259
2260 div()
2261 .key_context("GitGraph")
2262 .track_focus(&self.focus_handle)
2263 .size_full()
2264 .bg(cx.theme().colors().editor_background)
2265 .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
2266 this.open_selected_commit_view(window, cx);
2267 }))
2268 .on_action(cx.listener(Self::cancel))
2269 .on_action(cx.listener(Self::select_prev))
2270 .on_action(cx.listener(Self::select_next))
2271 .on_action(cx.listener(Self::confirm))
2272 .child(content)
2273 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2274 deferred(
2275 anchored()
2276 .position(*position)
2277 .anchor(Corner::TopLeft)
2278 .child(menu.clone()),
2279 )
2280 .with_priority(1)
2281 }))
2282 }
2283}
2284
2285impl EventEmitter<ItemEvent> for GitGraph {}
2286
2287impl Focusable for GitGraph {
2288 fn focus_handle(&self, _cx: &App) -> FocusHandle {
2289 self.focus_handle.clone()
2290 }
2291}
2292
2293impl Item for GitGraph {
2294 type Event = ItemEvent;
2295
2296 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
2297 Some(Icon::new(IconName::GitGraph))
2298 }
2299
2300 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
2301 let repo_name = self.get_selected_repository(cx).and_then(|repo| {
2302 repo.read(cx)
2303 .work_directory_abs_path
2304 .file_name()
2305 .map(|name| name.to_string_lossy().to_string())
2306 });
2307
2308 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
2309 move |_, _| {
2310 v_flex()
2311 .child(Label::new("Git Graph"))
2312 .when_some(repo_name.clone(), |this, name| {
2313 this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
2314 })
2315 .into_any_element()
2316 }
2317 }))))
2318 }
2319
2320 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
2321 self.get_selected_repository(cx)
2322 .and_then(|repo| {
2323 repo.read(cx)
2324 .work_directory_abs_path
2325 .file_name()
2326 .map(|name| name.to_string_lossy().to_string())
2327 })
2328 .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
2329 }
2330
2331 fn show_toolbar(&self) -> bool {
2332 false
2333 }
2334
2335 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
2336 f(*event)
2337 }
2338}
2339
2340impl SerializableItem for GitGraph {
2341 fn serialized_item_kind() -> &'static str {
2342 "GitGraph"
2343 }
2344
2345 fn cleanup(
2346 workspace_id: workspace::WorkspaceId,
2347 alive_items: Vec<workspace::ItemId>,
2348 _window: &mut Window,
2349 cx: &mut App,
2350 ) -> Task<gpui::Result<()>> {
2351 workspace::delete_unloaded_items(
2352 alive_items,
2353 workspace_id,
2354 "git_graphs",
2355 &persistence::GIT_GRAPHS,
2356 cx,
2357 )
2358 }
2359
2360 fn deserialize(
2361 project: Entity<Project>,
2362 workspace: WeakEntity<Workspace>,
2363 workspace_id: workspace::WorkspaceId,
2364 item_id: workspace::ItemId,
2365 window: &mut Window,
2366 cx: &mut App,
2367 ) -> Task<gpui::Result<Entity<Self>>> {
2368 if persistence::GIT_GRAPHS
2369 .get_git_graph(item_id, workspace_id)
2370 .ok()
2371 .is_some_and(|is_open| is_open)
2372 {
2373 let git_graph = cx.new(|cx| GitGraph::new(project, workspace, window, cx));
2374 Task::ready(Ok(git_graph))
2375 } else {
2376 Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
2377 }
2378 }
2379
2380 fn serialize(
2381 &mut self,
2382 workspace: &mut Workspace,
2383 item_id: workspace::ItemId,
2384 _closing: bool,
2385 _window: &mut Window,
2386 cx: &mut Context<Self>,
2387 ) -> Option<Task<gpui::Result<()>>> {
2388 let workspace_id = workspace.database_id()?;
2389 Some(cx.background_spawn(async move {
2390 persistence::GIT_GRAPHS
2391 .save_git_graph(item_id, workspace_id, true)
2392 .await
2393 }))
2394 }
2395
2396 fn should_serialize(&self, event: &Self::Event) -> bool {
2397 event == &ItemEvent::UpdateTab
2398 }
2399}
2400
2401mod persistence {
2402 use db::{
2403 query,
2404 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
2405 sqlez_macros::sql,
2406 };
2407 use workspace::WorkspaceDb;
2408
2409 pub struct GitGraphsDb(ThreadSafeConnection);
2410
2411 impl Domain for GitGraphsDb {
2412 const NAME: &str = stringify!(GitGraphsDb);
2413
2414 const MIGRATIONS: &[&str] = (&[sql!(
2415 CREATE TABLE git_graphs (
2416 workspace_id INTEGER,
2417 item_id INTEGER UNIQUE,
2418 is_open INTEGER DEFAULT FALSE,
2419
2420 PRIMARY KEY(workspace_id, item_id),
2421 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2422 ON DELETE CASCADE
2423 ) STRICT;
2424 )]);
2425 }
2426
2427 db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
2428
2429 impl GitGraphsDb {
2430 query! {
2431 pub async fn save_git_graph(
2432 item_id: workspace::ItemId,
2433 workspace_id: workspace::WorkspaceId,
2434 is_open: bool
2435 ) -> Result<()> {
2436 INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
2437 VALUES (?, ?, ?)
2438 }
2439 }
2440
2441 query! {
2442 pub fn get_git_graph(
2443 item_id: workspace::ItemId,
2444 workspace_id: workspace::WorkspaceId
2445 ) -> Result<bool> {
2446 SELECT is_open
2447 FROM git_graphs
2448 WHERE item_id = ? AND workspace_id = ?
2449 }
2450 }
2451 }
2452}
2453
2454#[cfg(test)]
2455mod tests {
2456 use super::*;
2457 use anyhow::{Context, Result, bail};
2458 use collections::{HashMap, HashSet};
2459 use fs::FakeFs;
2460 use git::Oid;
2461 use git::repository::InitialGraphCommitData;
2462 use gpui::TestAppContext;
2463 use project::Project;
2464 use project::git_store::{GitStoreEvent, RepositoryEvent};
2465 use rand::prelude::*;
2466 use serde_json::json;
2467 use settings::SettingsStore;
2468 use smallvec::{SmallVec, smallvec};
2469 use std::path::Path;
2470 use std::sync::{Arc, Mutex};
2471 use workspace::MultiWorkspace;
2472
2473 fn init_test(cx: &mut TestAppContext) {
2474 cx.update(|cx| {
2475 let settings_store = SettingsStore::test(cx);
2476 cx.set_global(settings_store);
2477 });
2478 }
2479
2480 fn init_test_with_theme(cx: &mut TestAppContext) {
2481 cx.update(|cx| {
2482 let settings_store = SettingsStore::test(cx);
2483 cx.set_global(settings_store);
2484 theme::init(theme::LoadThemes::JustBase, cx);
2485 });
2486 }
2487
2488 /// Generates a random commit DAG suitable for testing git graph rendering.
2489 ///
2490 /// The commits are ordered newest-first (like git log output), so:
2491 /// - Index 0 = most recent commit (HEAD)
2492 /// - Last index = oldest commit (root, has no parents)
2493 /// - Parents of commit at index I must have index > I
2494 ///
2495 /// When `adversarial` is true, generates complex topologies with many branches
2496 /// and octopus merges. Otherwise generates more realistic linear histories
2497 /// with occasional branches.
2498 fn generate_random_commit_dag(
2499 rng: &mut StdRng,
2500 num_commits: usize,
2501 adversarial: bool,
2502 ) -> Vec<Arc<InitialGraphCommitData>> {
2503 if num_commits == 0 {
2504 return Vec::new();
2505 }
2506
2507 let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
2508 let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
2509
2510 for i in 0..num_commits {
2511 let sha = oids[i];
2512
2513 let parents = if i == num_commits - 1 {
2514 smallvec![]
2515 } else {
2516 generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
2517 };
2518
2519 let ref_names = if i == 0 {
2520 vec!["HEAD".into(), "main".into()]
2521 } else if adversarial && rng.random_bool(0.1) {
2522 vec![format!("branch-{}", i).into()]
2523 } else {
2524 Vec::new()
2525 };
2526
2527 commits.push(Arc::new(InitialGraphCommitData {
2528 sha,
2529 parents,
2530 ref_names,
2531 }));
2532 }
2533
2534 commits
2535 }
2536
2537 fn generate_parents_from_oids(
2538 rng: &mut StdRng,
2539 oids: &[Oid],
2540 current_idx: usize,
2541 num_commits: usize,
2542 adversarial: bool,
2543 ) -> SmallVec<[Oid; 1]> {
2544 let remaining = num_commits - current_idx - 1;
2545 if remaining == 0 {
2546 return smallvec![];
2547 }
2548
2549 if adversarial {
2550 let merge_chance = 0.4;
2551 let octopus_chance = 0.15;
2552
2553 if remaining >= 3 && rng.random_bool(octopus_chance) {
2554 let num_parents = rng.random_range(3..=remaining.min(5));
2555 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2556 parent_indices.shuffle(rng);
2557 parent_indices
2558 .into_iter()
2559 .take(num_parents)
2560 .map(|idx| oids[idx])
2561 .collect()
2562 } else if remaining >= 2 && rng.random_bool(merge_chance) {
2563 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2564 parent_indices.shuffle(rng);
2565 parent_indices
2566 .into_iter()
2567 .take(2)
2568 .map(|idx| oids[idx])
2569 .collect()
2570 } else {
2571 let parent_idx = rng.random_range(current_idx + 1..num_commits);
2572 smallvec![oids[parent_idx]]
2573 }
2574 } else {
2575 let merge_chance = 0.15;
2576 let skip_chance = 0.1;
2577
2578 if remaining >= 2 && rng.random_bool(merge_chance) {
2579 let first_parent = current_idx + 1;
2580 let second_parent = rng.random_range(current_idx + 2..num_commits);
2581 smallvec![oids[first_parent], oids[second_parent]]
2582 } else if rng.random_bool(skip_chance) && remaining >= 2 {
2583 let skip = rng.random_range(1..remaining.min(3));
2584 smallvec![oids[current_idx + 1 + skip]]
2585 } else {
2586 smallvec![oids[current_idx + 1]]
2587 }
2588 }
2589 }
2590
2591 fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
2592 graph
2593 .commits
2594 .iter()
2595 .enumerate()
2596 .map(|(idx, entry)| (entry.data.sha, idx))
2597 .collect()
2598 }
2599
2600 fn verify_commit_order(
2601 graph: &GraphData,
2602 commits: &[Arc<InitialGraphCommitData>],
2603 ) -> Result<()> {
2604 if graph.commits.len() != commits.len() {
2605 bail!(
2606 "Commit count mismatch: graph has {} commits, expected {}",
2607 graph.commits.len(),
2608 commits.len()
2609 );
2610 }
2611
2612 for (idx, (graph_commit, expected_commit)) in
2613 graph.commits.iter().zip(commits.iter()).enumerate()
2614 {
2615 if graph_commit.data.sha != expected_commit.sha {
2616 bail!(
2617 "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
2618 idx,
2619 graph_commit.data.sha,
2620 expected_commit.sha
2621 );
2622 }
2623 }
2624
2625 Ok(())
2626 }
2627
2628 fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
2629 for line in &graph.lines {
2630 let child_row = *oid_to_row
2631 .get(&line.child)
2632 .context("Line references non-existent child commit")?;
2633
2634 let parent_row = *oid_to_row
2635 .get(&line.parent)
2636 .context("Line references non-existent parent commit")?;
2637
2638 if child_row >= parent_row {
2639 bail!(
2640 "child_row ({}) must be < parent_row ({})",
2641 child_row,
2642 parent_row
2643 );
2644 }
2645
2646 if line.full_interval.start != child_row {
2647 bail!(
2648 "full_interval.start ({}) != child_row ({})",
2649 line.full_interval.start,
2650 child_row
2651 );
2652 }
2653
2654 if line.full_interval.end != parent_row {
2655 bail!(
2656 "full_interval.end ({}) != parent_row ({})",
2657 line.full_interval.end,
2658 parent_row
2659 );
2660 }
2661
2662 if let Some(last_segment) = line.segments.last() {
2663 let segment_end_row = match last_segment {
2664 CommitLineSegment::Straight { to_row } => *to_row,
2665 CommitLineSegment::Curve { on_row, .. } => *on_row,
2666 };
2667
2668 if segment_end_row != line.full_interval.end {
2669 bail!(
2670 "last segment ends at row {} but full_interval.end is {}",
2671 segment_end_row,
2672 line.full_interval.end
2673 );
2674 }
2675 }
2676 }
2677
2678 Ok(())
2679 }
2680
2681 fn verify_column_correctness(
2682 graph: &GraphData,
2683 oid_to_row: &HashMap<Oid, usize>,
2684 ) -> Result<()> {
2685 for line in &graph.lines {
2686 let child_row = *oid_to_row
2687 .get(&line.child)
2688 .context("Line references non-existent child commit")?;
2689
2690 let parent_row = *oid_to_row
2691 .get(&line.parent)
2692 .context("Line references non-existent parent commit")?;
2693
2694 let child_lane = graph.commits[child_row].lane;
2695 if line.child_column != child_lane {
2696 bail!(
2697 "child_column ({}) != child's lane ({})",
2698 line.child_column,
2699 child_lane
2700 );
2701 }
2702
2703 let mut current_column = line.child_column;
2704 for segment in &line.segments {
2705 if let CommitLineSegment::Curve { to_column, .. } = segment {
2706 current_column = *to_column;
2707 }
2708 }
2709
2710 let parent_lane = graph.commits[parent_row].lane;
2711 if current_column != parent_lane {
2712 bail!(
2713 "ending column ({}) != parent's lane ({})",
2714 current_column,
2715 parent_lane
2716 );
2717 }
2718 }
2719
2720 Ok(())
2721 }
2722
2723 fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
2724 for line in &graph.lines {
2725 if line.segments.is_empty() {
2726 bail!("Line has no segments");
2727 }
2728
2729 let mut current_row = line.full_interval.start;
2730
2731 for (idx, segment) in line.segments.iter().enumerate() {
2732 let segment_end_row = match segment {
2733 CommitLineSegment::Straight { to_row } => *to_row,
2734 CommitLineSegment::Curve { on_row, .. } => *on_row,
2735 };
2736
2737 if segment_end_row < current_row {
2738 bail!(
2739 "segment {} ends at row {} which is before current row {}",
2740 idx,
2741 segment_end_row,
2742 current_row
2743 );
2744 }
2745
2746 current_row = segment_end_row;
2747 }
2748 }
2749
2750 Ok(())
2751 }
2752
2753 fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
2754 for line in &graph.lines {
2755 let child_row = line.full_interval.start;
2756
2757 let mut current_column = line.child_column;
2758 let mut current_row = child_row;
2759
2760 for segment in &line.segments {
2761 match segment {
2762 CommitLineSegment::Straight { to_row } => {
2763 for row in (current_row + 1)..*to_row {
2764 if row < graph.commits.len() {
2765 let commit_at_row = &graph.commits[row];
2766 if commit_at_row.lane == current_column {
2767 bail!(
2768 "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
2769 current_row,
2770 to_row,
2771 current_column,
2772 commit_at_row.data.sha,
2773 row
2774 );
2775 }
2776 }
2777 }
2778 current_row = *to_row;
2779 }
2780 CommitLineSegment::Curve {
2781 to_column, on_row, ..
2782 } => {
2783 current_column = *to_column;
2784 current_row = *on_row;
2785 }
2786 }
2787 }
2788 }
2789
2790 Ok(())
2791 }
2792
2793 fn verify_coverage(graph: &GraphData) -> Result<()> {
2794 let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
2795 for entry in &graph.commits {
2796 for parent in &entry.data.parents {
2797 expected_edges.insert((entry.data.sha, *parent));
2798 }
2799 }
2800
2801 let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
2802 for line in &graph.lines {
2803 let edge = (line.child, line.parent);
2804
2805 if !found_edges.insert(edge) {
2806 bail!(
2807 "Duplicate line found for edge {:?} -> {:?}",
2808 line.child,
2809 line.parent
2810 );
2811 }
2812
2813 if !expected_edges.contains(&edge) {
2814 bail!(
2815 "Orphan line found: {:?} -> {:?} is not in the commit graph",
2816 line.child,
2817 line.parent
2818 );
2819 }
2820 }
2821
2822 for (child, parent) in &expected_edges {
2823 if !found_edges.contains(&(*child, *parent)) {
2824 bail!("Missing line for edge {:?} -> {:?}", child, parent);
2825 }
2826 }
2827
2828 assert_eq!(
2829 expected_edges.symmetric_difference(&found_edges).count(),
2830 0,
2831 "The symmetric difference should be zero"
2832 );
2833
2834 Ok(())
2835 }
2836
2837 fn expected_branch_assignments(
2838 commits: &[Arc<InitialGraphCommitData>],
2839 ) -> (HashMap<Oid, BranchId>, HashMap<(Oid, Oid), BranchId>) {
2840 let mut parent_to_branch_ids: HashMap<Oid, SmallVec<[BranchId; 1]>> = HashMap::default();
2841 let mut commit_branch_ids = HashMap::default();
2842 let mut line_branch_ids = HashMap::default();
2843 let mut next_branch_id = 0usize;
2844
2845 let mut allocate_branch_id = || {
2846 let branch_id = BranchId(next_branch_id);
2847 next_branch_id += 1;
2848 branch_id
2849 };
2850
2851 for commit in commits {
2852 let commit_branch_id = parent_to_branch_ids
2853 .remove(&commit.sha)
2854 .and_then(|branch_ids| branch_ids.first().copied())
2855 .unwrap_or_else(|| allocate_branch_id());
2856
2857 commit_branch_ids.insert(commit.sha, commit_branch_id);
2858
2859 for (parent_idx, parent) in commit.parents.iter().enumerate() {
2860 let branch_id = if parent_idx == 0 {
2861 commit_branch_id
2862 } else {
2863 parent_to_branch_ids
2864 .get(parent)
2865 .and_then(|branch_ids| branch_ids.first().copied())
2866 .unwrap_or_else(|| allocate_branch_id())
2867 };
2868
2869 line_branch_ids.insert((commit.sha, *parent), branch_id);
2870 parent_to_branch_ids
2871 .entry(*parent)
2872 .or_default()
2873 .push(branch_id);
2874 }
2875 }
2876
2877 (commit_branch_ids, line_branch_ids)
2878 }
2879
2880 fn verify_branch_ids(graph: &GraphData, commits: &[Arc<InitialGraphCommitData>]) -> Result<()> {
2881 let (expected_commit_branch_ids, expected_line_branch_ids) =
2882 expected_branch_assignments(commits);
2883 let mut seen_branch_ids = HashSet::default();
2884 let actual_commit_branch_ids: HashMap<Oid, BranchId> = graph
2885 .commits
2886 .iter()
2887 .map(|commit| (commit.data.sha, commit.branch_id))
2888 .collect();
2889 let actual_line_branch_ids: HashMap<(Oid, Oid), BranchId> = graph
2890 .lines
2891 .iter()
2892 .map(|line| ((line.child, line.parent), line.branch_id))
2893 .collect();
2894 let mut parent_to_seen_branch_ids: HashMap<Oid, SmallVec<[BranchId; 1]>> =
2895 HashMap::default();
2896
2897 for commit in &graph.commits {
2898 let expected_branch_id = expected_commit_branch_ids
2899 .get(&commit.data.sha)
2900 .context("Commit is missing an expected branch id")?;
2901
2902 if &commit.branch_id != expected_branch_id {
2903 bail!(
2904 "Commit {:?} has branch_id {:?}, expected {:?}",
2905 commit.data.sha,
2906 commit.branch_id,
2907 expected_branch_id
2908 );
2909 }
2910
2911 seen_branch_ids.insert(commit.branch_id.0);
2912 }
2913
2914 for line in &graph.lines {
2915 let expected_branch_id = expected_line_branch_ids
2916 .get(&(line.child, line.parent))
2917 .context("Line is missing an expected branch id")?;
2918
2919 if &line.branch_id != expected_branch_id {
2920 bail!(
2921 "Line {:?} -> {:?} has branch_id {:?}, expected {:?}",
2922 line.child,
2923 line.parent,
2924 line.branch_id,
2925 expected_branch_id
2926 );
2927 }
2928
2929 seen_branch_ids.insert(line.branch_id.0);
2930 }
2931
2932 for commit in commits {
2933 let commit_branch_id = *actual_commit_branch_ids
2934 .get(&commit.sha)
2935 .context("Commit is missing an actual branch id")?;
2936
2937 for (parent_idx, parent) in commit.parents.iter().enumerate() {
2938 let line_branch_id = *actual_line_branch_ids
2939 .get(&(commit.sha, *parent))
2940 .context("Line is missing an actual branch id")?;
2941
2942 if parent_idx > 0
2943 && let Some(origin_branch_id) = parent_to_seen_branch_ids
2944 .get(parent)
2945 .and_then(|branch_ids| branch_ids.first().copied())
2946 {
2947 if line_branch_id != origin_branch_id {
2948 bail!(
2949 "Line {:?} -> {:?} has branch_id {:?}, expected origin branch id {:?}",
2950 commit.sha,
2951 parent,
2952 line_branch_id,
2953 origin_branch_id
2954 );
2955 }
2956
2957 if line_branch_id == commit_branch_id {
2958 bail!(
2959 "Line {:?} -> {:?} reused merged-into branch id {:?} instead of origin branch id {:?}",
2960 commit.sha,
2961 parent,
2962 commit_branch_id,
2963 origin_branch_id
2964 );
2965 }
2966 }
2967
2968 parent_to_seen_branch_ids
2969 .entry(*parent)
2970 .or_default()
2971 .push(line_branch_id);
2972 }
2973 }
2974
2975 let Some(max_branch_id) = seen_branch_ids.iter().max().copied() else {
2976 return Ok(());
2977 };
2978
2979 for expected_branch_id in 0..=max_branch_id {
2980 if !seen_branch_ids.contains(&expected_branch_id) {
2981 bail!("Missing branch id {}", expected_branch_id);
2982 }
2983 }
2984
2985 Ok(())
2986 }
2987
2988 fn verify_merge_line_optimality(
2989 graph: &GraphData,
2990 oid_to_row: &HashMap<Oid, usize>,
2991 ) -> Result<()> {
2992 for line in &graph.lines {
2993 let first_segment = line.segments.first();
2994 let is_merge_line = matches!(
2995 first_segment,
2996 Some(CommitLineSegment::Curve {
2997 curve_kind: CurveKind::Merge,
2998 ..
2999 })
3000 );
3001
3002 if !is_merge_line {
3003 continue;
3004 }
3005
3006 let child_row = *oid_to_row
3007 .get(&line.child)
3008 .context("Line references non-existent child commit")?;
3009
3010 let parent_row = *oid_to_row
3011 .get(&line.parent)
3012 .context("Line references non-existent parent commit")?;
3013
3014 let parent_lane = graph.commits[parent_row].lane;
3015
3016 let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
3017 continue;
3018 };
3019
3020 let curves_directly_to_parent = *to_column == parent_lane;
3021
3022 if !curves_directly_to_parent {
3023 continue;
3024 }
3025
3026 let curve_row = child_row + 1;
3027 let has_commits_in_path = graph.commits[curve_row..parent_row]
3028 .iter()
3029 .any(|c| c.lane == parent_lane);
3030
3031 if has_commits_in_path {
3032 bail!(
3033 "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
3034 line.child,
3035 line.parent,
3036 parent_lane,
3037 curve_row,
3038 parent_row
3039 );
3040 }
3041
3042 let curve_ends_at_parent = curve_row == parent_row;
3043
3044 if curve_ends_at_parent {
3045 if line.segments.len() != 1 {
3046 bail!(
3047 "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
3048 line.child,
3049 line.parent,
3050 line.segments.len()
3051 );
3052 }
3053 } else {
3054 if line.segments.len() != 2 {
3055 bail!(
3056 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
3057 line.child,
3058 line.parent,
3059 line.segments.len()
3060 );
3061 }
3062
3063 let is_straight_segment = matches!(
3064 line.segments.get(1),
3065 Some(CommitLineSegment::Straight { .. })
3066 );
3067
3068 if !is_straight_segment {
3069 bail!(
3070 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
3071 line.child,
3072 line.parent
3073 );
3074 }
3075 }
3076 }
3077
3078 Ok(())
3079 }
3080
3081 fn verify_all_invariants(
3082 graph: &GraphData,
3083 commits: &[Arc<InitialGraphCommitData>],
3084 ) -> Result<()> {
3085 let oid_to_row = build_oid_to_row_map(graph);
3086
3087 verify_commit_order(graph, commits).context("commit order")?;
3088 verify_branch_ids(graph, commits).context("branch ids")?;
3089 verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
3090 verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
3091 verify_segment_continuity(graph).context("segment continuity")?;
3092 verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
3093 verify_coverage(graph).context("coverage")?;
3094 verify_line_overlaps(graph).context("line overlaps")?;
3095 Ok(())
3096 }
3097
3098 #[test]
3099 fn test_git_graph_merge_commits() {
3100 let mut rng = StdRng::seed_from_u64(42);
3101
3102 let oid1 = Oid::random(&mut rng);
3103 let oid2 = Oid::random(&mut rng);
3104 let oid3 = Oid::random(&mut rng);
3105 let oid4 = Oid::random(&mut rng);
3106
3107 let commits = vec![
3108 Arc::new(InitialGraphCommitData {
3109 sha: oid1,
3110 parents: smallvec![oid2, oid3],
3111 ref_names: vec!["HEAD".into()],
3112 }),
3113 Arc::new(InitialGraphCommitData {
3114 sha: oid2,
3115 parents: smallvec![oid4],
3116 ref_names: vec![],
3117 }),
3118 Arc::new(InitialGraphCommitData {
3119 sha: oid3,
3120 parents: smallvec![oid4],
3121 ref_names: vec![],
3122 }),
3123 Arc::new(InitialGraphCommitData {
3124 sha: oid4,
3125 parents: smallvec![],
3126 ref_names: vec![],
3127 }),
3128 ];
3129
3130 let mut graph_data = GraphData::new(8);
3131 graph_data.add_commits(&commits);
3132
3133 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3134 panic!("Graph invariant violation for merge commits:\n{}", error);
3135 }
3136 }
3137
3138 #[test]
3139 fn test_git_graph_linear_commits() {
3140 let mut rng = StdRng::seed_from_u64(42);
3141
3142 let oid1 = Oid::random(&mut rng);
3143 let oid2 = Oid::random(&mut rng);
3144 let oid3 = Oid::random(&mut rng);
3145
3146 let commits = vec![
3147 Arc::new(InitialGraphCommitData {
3148 sha: oid1,
3149 parents: smallvec![oid2],
3150 ref_names: vec!["HEAD".into()],
3151 }),
3152 Arc::new(InitialGraphCommitData {
3153 sha: oid2,
3154 parents: smallvec![oid3],
3155 ref_names: vec![],
3156 }),
3157 Arc::new(InitialGraphCommitData {
3158 sha: oid3,
3159 parents: smallvec![],
3160 ref_names: vec![],
3161 }),
3162 ];
3163
3164 let mut graph_data = GraphData::new(8);
3165 graph_data.add_commits(&commits);
3166
3167 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3168 panic!("Graph invariant violation for linear commits:\n{}", error);
3169 }
3170 }
3171
3172 #[test]
3173 fn test_git_graph_random_commits() {
3174 for seed in 0..100 {
3175 let mut rng = StdRng::seed_from_u64(seed);
3176
3177 let adversarial = rng.random_bool(0.2);
3178 let num_commits = if adversarial {
3179 rng.random_range(10..100)
3180 } else {
3181 rng.random_range(5..50)
3182 };
3183
3184 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3185
3186 assert_eq!(
3187 num_commits,
3188 commits.len(),
3189 "seed={}: Generate random commit dag didn't generate the correct amount of commits",
3190 seed
3191 );
3192
3193 let mut graph_data = GraphData::new(8);
3194 graph_data.add_commits(&commits);
3195
3196 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3197 panic!(
3198 "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
3199 seed, adversarial, num_commits, error
3200 );
3201 }
3202 }
3203 }
3204
3205 #[test]
3206 fn test_git_graph_random_branch_ids() {
3207 for seed in 0..100 {
3208 let mut rng = StdRng::seed_from_u64(seed);
3209
3210 let adversarial = rng.random_bool(0.2);
3211 let num_commits = if adversarial {
3212 rng.random_range(10..100)
3213 } else {
3214 rng.random_range(5..50)
3215 };
3216
3217 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3218 let mut graph_data = GraphData::new(8);
3219 graph_data.add_commits(&commits);
3220
3221 if let Err(error) = verify_branch_ids(&graph_data, &commits) {
3222 panic!(
3223 "Branch id invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
3224 seed, adversarial, num_commits, error
3225 );
3226 }
3227 }
3228 }
3229
3230 // The full integration test has less iterations because it's significantly slower
3231 // than the random commit test
3232 #[gpui::test(iterations = 10)]
3233 async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
3234 init_test(cx);
3235
3236 let adversarial = rng.random_bool(0.2);
3237 let num_commits = if adversarial {
3238 rng.random_range(10..100)
3239 } else {
3240 rng.random_range(5..50)
3241 };
3242
3243 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3244
3245 let fs = FakeFs::new(cx.executor());
3246 fs.insert_tree(
3247 Path::new("/project"),
3248 json!({
3249 ".git": {},
3250 "file.txt": "content",
3251 }),
3252 )
3253 .await;
3254
3255 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3256
3257 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3258 cx.run_until_parked();
3259
3260 let repository = project.read_with(cx, |project, cx| {
3261 project
3262 .active_repository(cx)
3263 .expect("should have a repository")
3264 });
3265
3266 repository.update(cx, |repo, cx| {
3267 repo.graph_data(
3268 crate::LogSource::default(),
3269 crate::LogOrder::default(),
3270 0..usize::MAX,
3271 cx,
3272 );
3273 });
3274 cx.run_until_parked();
3275
3276 let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
3277 repo.graph_data(
3278 crate::LogSource::default(),
3279 crate::LogOrder::default(),
3280 0..usize::MAX,
3281 cx,
3282 )
3283 .commits
3284 .to_vec()
3285 });
3286
3287 let mut graph_data = GraphData::new(8);
3288 graph_data.add_commits(&graph_commits);
3289
3290 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3291 panic!(
3292 "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
3293 adversarial, num_commits, error
3294 );
3295 }
3296 }
3297
3298 #[gpui::test]
3299 async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
3300 init_test(cx);
3301
3302 let fs = FakeFs::new(cx.executor());
3303 fs.insert_tree(
3304 Path::new("/project"),
3305 json!({
3306 ".git": {},
3307 "file.txt": "content",
3308 }),
3309 )
3310 .await;
3311
3312 let mut rng = StdRng::seed_from_u64(42);
3313 let commits = generate_random_commit_dag(&mut rng, 10, false);
3314 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3315
3316 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3317 let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
3318 project.update(cx, |project, cx| {
3319 let observed_repository_events = observed_repository_events.clone();
3320 cx.subscribe(project.git_store(), move |_, _, event, _| {
3321 if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
3322 observed_repository_events
3323 .lock()
3324 .expect("repository event mutex should be available")
3325 .push(repository_event.clone());
3326 }
3327 })
3328 .detach();
3329 });
3330
3331 let repository = project.read_with(cx, |project, cx| {
3332 project
3333 .active_repository(cx)
3334 .expect("should have a repository")
3335 });
3336
3337 repository.update(cx, |repo, cx| {
3338 repo.graph_data(
3339 crate::LogSource::default(),
3340 crate::LogOrder::default(),
3341 0..usize::MAX,
3342 cx,
3343 );
3344 });
3345
3346 project
3347 .update(cx, |project, cx| project.git_scans_complete(cx))
3348 .await;
3349 cx.run_until_parked();
3350
3351 let observed_repository_events = observed_repository_events
3352 .lock()
3353 .expect("repository event mutex should be available");
3354 assert!(
3355 observed_repository_events
3356 .iter()
3357 .any(|event| matches!(event, RepositoryEvent::BranchChanged)),
3358 "initial repository scan should emit BranchChanged"
3359 );
3360 let commit_count_after = repository.read_with(cx, |repo, _| {
3361 repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
3362 .map(|data| data.commit_data.len())
3363 .unwrap()
3364 });
3365 assert_eq!(
3366 commits.len(),
3367 commit_count_after,
3368 "initial_graph_data should remain populated after events emitted by initial repository scan"
3369 );
3370 }
3371
3372 #[gpui::test]
3373 async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
3374 init_test_with_theme(cx);
3375
3376 let fs = FakeFs::new(cx.executor());
3377 fs.insert_tree(
3378 Path::new("/project_a"),
3379 json!({
3380 ".git": {},
3381 "file.txt": "content",
3382 }),
3383 )
3384 .await;
3385 fs.insert_tree(
3386 Path::new("/project_b"),
3387 json!({
3388 ".git": {},
3389 "other.txt": "content",
3390 }),
3391 )
3392 .await;
3393
3394 let mut rng = StdRng::seed_from_u64(42);
3395 let commits = generate_random_commit_dag(&mut rng, 10, false);
3396 fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
3397
3398 let project = Project::test(
3399 fs.clone(),
3400 [Path::new("/project_a"), Path::new("/project_b")],
3401 cx,
3402 )
3403 .await;
3404 cx.run_until_parked();
3405
3406 let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
3407 let mut first_repository = None;
3408 let mut second_repository = None;
3409
3410 for repository in project.repositories(cx).values() {
3411 let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
3412 if work_directory_abs_path.as_ref() == Path::new("/project_a") {
3413 first_repository = Some(repository.clone());
3414 } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
3415 second_repository = Some(repository.clone());
3416 }
3417 }
3418
3419 (
3420 first_repository.expect("should have repository for /project_a"),
3421 second_repository.expect("should have repository for /project_b"),
3422 )
3423 });
3424 first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
3425 cx.run_until_parked();
3426
3427 let (multi_workspace, cx) =
3428 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3429
3430 let workspace_weak =
3431 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
3432 let git_graph = cx.new_window_entity(|window, cx| {
3433 GitGraph::new(project.clone(), workspace_weak, window, cx)
3434 });
3435 cx.run_until_parked();
3436
3437 // Verify initial graph data is loaded
3438 let initial_commit_count =
3439 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3440 assert!(
3441 initial_commit_count > 0,
3442 "graph data should have been loaded, got 0 commits"
3443 );
3444
3445 second_repository.update(&mut *cx, |repository, cx| {
3446 repository.set_as_active_repository(cx)
3447 });
3448 cx.run_until_parked();
3449
3450 let commit_count_after_clear =
3451 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3452 assert_eq!(
3453 commit_count_after_clear, 0,
3454 "graph_data should be cleared after switching away"
3455 );
3456
3457 first_repository.update(&mut *cx, |repository, cx| {
3458 repository.set_as_active_repository(cx)
3459 });
3460
3461 git_graph.update_in(&mut *cx, |this, window, cx| {
3462 this.render(window, cx);
3463 });
3464 cx.run_until_parked();
3465
3466 let commit_count_after_switch_back =
3467 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3468 assert_eq!(
3469 initial_commit_count, commit_count_after_switch_back,
3470 "graph_data should be repopulated from cache after switching back to the same repo"
3471 );
3472 }
3473}