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