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