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