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