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, 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.fetch_commit_data(commit.data.sha, cx).clone()
1274 });
1275
1276 let short_sha = commit.data.sha.display_short();
1277 let mut formatted_time = String::new();
1278 let subject: SharedString;
1279 let author_name: SharedString;
1280
1281 if let CommitDataState::Loaded(data) = data {
1282 subject = data.subject.clone();
1283 author_name = data.author_name.clone();
1284 formatted_time = format_timestamp(data.commit_timestamp);
1285 } else {
1286 subject = "Loading…".into();
1287 author_name = "".into();
1288 }
1289
1290 let accent_colors = cx.theme().accents();
1291 let accent_color = accent_colors
1292 .0
1293 .get(commit.color_idx)
1294 .copied()
1295 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1296
1297 let is_selected = self.selected_entry_idx == Some(idx);
1298 let is_matched = self.search_state.matches.contains(&commit.data.sha);
1299 let column_label = |label: SharedString| {
1300 Label::new(label)
1301 .when(!is_selected, |c| c.color(Color::Muted))
1302 .truncate()
1303 .into_any_element()
1304 };
1305
1306 let subject_label = if is_matched {
1307 let query = match &self.search_state.state {
1308 QueryState::Confirmed((query, _)) => Some(query.clone()),
1309 _ => None,
1310 };
1311 let highlight_ranges = query
1312 .and_then(|q| {
1313 let ranges = if self.search_state.case_sensitive {
1314 subject
1315 .match_indices(q.as_str())
1316 .map(|(start, matched)| start..start + matched.len())
1317 .collect::<Vec<_>>()
1318 } else {
1319 let q = q.to_lowercase();
1320 let subject_lower = subject.to_lowercase();
1321
1322 subject_lower
1323 .match_indices(&q)
1324 .filter_map(|(start, matched)| {
1325 let end = start + matched.len();
1326 subject.is_char_boundary(start).then_some(()).and_then(
1327 |_| subject.is_char_boundary(end).then_some(start..end),
1328 )
1329 })
1330 .collect::<Vec<_>>()
1331 };
1332
1333 (!ranges.is_empty()).then_some(ranges)
1334 })
1335 .unwrap_or_default();
1336 HighlightedLabel::from_ranges(subject.clone(), highlight_ranges)
1337 .when(!is_selected, |c| c.color(Color::Muted))
1338 .truncate()
1339 .into_any_element()
1340 } else {
1341 column_label(subject.clone())
1342 };
1343
1344 vec![
1345 div()
1346 .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1347 .overflow_hidden()
1348 .tooltip(Tooltip::text(subject))
1349 .child(
1350 h_flex()
1351 .gap_2()
1352 .overflow_hidden()
1353 .children((!commit.data.ref_names.is_empty()).then(|| {
1354 h_flex().gap_1().children(commit.data.ref_names.iter().map(
1355 |name| {
1356 let is_head =
1357 Self::is_head_ref(name.as_ref(), &head_branch_name);
1358 self.render_chip(name, accent_color, is_head)
1359 },
1360 ))
1361 }))
1362 .child(subject_label),
1363 )
1364 .into_any_element(),
1365 column_label(formatted_time.into()),
1366 column_label(author_name),
1367 column_label(short_sha.into()),
1368 ]
1369 })
1370 .collect()
1371 }
1372
1373 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1374 self.selected_entry_idx = None;
1375 self.selected_commit_diff = None;
1376 self.selected_commit_diff_stats = None;
1377 cx.notify();
1378 }
1379
1380 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1381 self.select_entry(0, ScrollStrategy::Nearest, cx);
1382 }
1383
1384 fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1385 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1386 self.select_entry(
1387 selected_entry_idx.saturating_sub(1),
1388 ScrollStrategy::Nearest,
1389 cx,
1390 );
1391 } else {
1392 self.select_first(&SelectFirst, window, cx);
1393 }
1394 }
1395
1396 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1397 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1398 self.select_entry(
1399 selected_entry_idx
1400 .saturating_add(1)
1401 .min(self.graph_data.commits.len().saturating_sub(1)),
1402 ScrollStrategy::Nearest,
1403 cx,
1404 );
1405 } else {
1406 self.select_prev(&SelectPrevious, window, cx);
1407 }
1408 }
1409
1410 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1411 self.select_entry(
1412 self.graph_data.commits.len().saturating_sub(1),
1413 ScrollStrategy::Nearest,
1414 cx,
1415 );
1416 }
1417
1418 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1419 self.open_selected_commit_view(window, cx);
1420 }
1421
1422 fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
1423 let Some(repo) = self.get_repository(cx) else {
1424 return;
1425 };
1426
1427 self.search_state.matches.clear();
1428 self.search_state.selected_index = None;
1429 self.search_state.editor.update(cx, |editor, _cx| {
1430 editor.set_text_style_refinement(Default::default());
1431 });
1432
1433 if query.as_str().is_empty() {
1434 self.search_state.state = QueryState::Empty;
1435 cx.notify();
1436 return;
1437 }
1438
1439 let (request_tx, request_rx) = smol::channel::unbounded::<Oid>();
1440
1441 repo.update(cx, |repo, cx| {
1442 repo.search_commits(
1443 self.log_source.clone(),
1444 SearchCommitArgs {
1445 query: query.clone(),
1446 case_sensitive: self.search_state.case_sensitive,
1447 },
1448 request_tx,
1449 cx,
1450 );
1451 });
1452
1453 let search_task = cx.spawn(async move |this, cx| {
1454 while let Ok(first_oid) = request_rx.recv().await {
1455 let mut pending_oids = vec![first_oid];
1456 while let Ok(oid) = request_rx.try_recv() {
1457 pending_oids.push(oid);
1458 }
1459
1460 this.update(cx, |this, cx| {
1461 if this.search_state.selected_index.is_none() {
1462 this.search_state.selected_index = Some(0);
1463 this.select_commit_by_sha(first_oid, cx);
1464 }
1465
1466 this.search_state.matches.extend(pending_oids);
1467 cx.notify();
1468 })
1469 .ok();
1470 }
1471
1472 this.update(cx, |this, cx| {
1473 if this.search_state.matches.is_empty() {
1474 this.search_state.editor.update(cx, |editor, cx| {
1475 editor.set_text_style_refinement(TextStyleRefinement {
1476 color: Some(Color::Error.color(cx)),
1477 ..Default::default()
1478 });
1479 });
1480 }
1481 })
1482 .ok();
1483 });
1484
1485 self.search_state.state = QueryState::Confirmed((query, search_task));
1486 }
1487
1488 fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
1489 let query = self.search_state.editor.read(cx).text(cx).into();
1490 self.search(query, cx);
1491 }
1492
1493 fn select_entry(
1494 &mut self,
1495 idx: usize,
1496 scroll_strategy: ScrollStrategy,
1497 cx: &mut Context<Self>,
1498 ) {
1499 if self.selected_entry_idx == Some(idx) {
1500 return;
1501 }
1502
1503 self.selected_entry_idx = Some(idx);
1504 self.selected_commit_diff = None;
1505 self.selected_commit_diff_stats = None;
1506 self.changed_files_scroll_handle
1507 .scroll_to_item(0, ScrollStrategy::Top);
1508 self.table_interaction_state.update(cx, |state, cx| {
1509 state.scroll_handle.scroll_to_item(idx, scroll_strategy);
1510 cx.notify();
1511 });
1512
1513 let Some(commit) = self.graph_data.commits.get(idx) else {
1514 return;
1515 };
1516
1517 let sha = commit.data.sha.to_string();
1518
1519 let Some(repository) = self.get_repository(cx) else {
1520 return;
1521 };
1522
1523 let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1524
1525 self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1526 if let Ok(Ok(diff)) = diff_receiver.await {
1527 this.update(cx, |this, cx| {
1528 let stats = compute_diff_stats(&diff);
1529 this.selected_commit_diff = Some(diff);
1530 this.selected_commit_diff_stats = Some(stats);
1531 cx.notify();
1532 })
1533 .ok();
1534 }
1535 }));
1536
1537 cx.notify();
1538 }
1539
1540 fn select_previous_match(&mut self, cx: &mut Context<Self>) {
1541 if self.search_state.matches.is_empty() {
1542 return;
1543 }
1544
1545 let mut prev_selection = self.search_state.selected_index.unwrap_or_default();
1546
1547 if prev_selection == 0 {
1548 prev_selection = self.search_state.matches.len() - 1;
1549 } else {
1550 prev_selection -= 1;
1551 }
1552
1553 let Some(&oid) = self.search_state.matches.get_index(prev_selection) else {
1554 return;
1555 };
1556
1557 self.search_state.selected_index = Some(prev_selection);
1558 self.select_commit_by_sha(oid, cx);
1559 }
1560
1561 fn select_next_match(&mut self, cx: &mut Context<Self>) {
1562 if self.search_state.matches.is_empty() {
1563 return;
1564 }
1565
1566 let mut next_selection = self
1567 .search_state
1568 .selected_index
1569 .map(|index| index + 1)
1570 .unwrap_or_default();
1571
1572 if next_selection >= self.search_state.matches.len() {
1573 next_selection = 0;
1574 }
1575
1576 let Some(&oid) = self.search_state.matches.get_index(next_selection) else {
1577 return;
1578 };
1579
1580 self.search_state.selected_index = Some(next_selection);
1581 self.select_commit_by_sha(oid, cx);
1582 }
1583
1584 pub fn set_repo_id(&mut self, repo_id: RepositoryId, cx: &mut Context<Self>) {
1585 if repo_id != self.repo_id
1586 && self
1587 .git_store
1588 .read(cx)
1589 .repositories()
1590 .contains_key(&repo_id)
1591 {
1592 self.repo_id = repo_id;
1593 self.invalidate_state(cx);
1594 }
1595 }
1596
1597 pub fn select_commit_by_sha(&mut self, sha: impl TryInto<Oid>, cx: &mut Context<Self>) {
1598 fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
1599 let Some(selected_repository) = this.get_repository(cx) else {
1600 return;
1601 };
1602
1603 let Some(index) = selected_repository
1604 .read(cx)
1605 .get_graph_data(this.log_source.clone(), this.log_order)
1606 .and_then(|data| data.commit_oid_to_index.get(&oid))
1607 .copied()
1608 else {
1609 return;
1610 };
1611
1612 this.select_entry(index, ScrollStrategy::Center, cx);
1613 }
1614
1615 if let Ok(oid) = sha.try_into() {
1616 inner(self, oid, cx);
1617 }
1618 }
1619
1620 fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1621 let Some(selected_entry_index) = self.selected_entry_idx else {
1622 return;
1623 };
1624
1625 self.open_commit_view(selected_entry_index, window, cx);
1626 }
1627
1628 fn open_commit_view(
1629 &mut self,
1630 entry_index: usize,
1631 window: &mut Window,
1632 cx: &mut Context<Self>,
1633 ) {
1634 let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1635 return;
1636 };
1637
1638 let Some(repository) = self.get_repository(cx) else {
1639 return;
1640 };
1641
1642 CommitView::open(
1643 commit_entry.data.sha.to_string(),
1644 repository.downgrade(),
1645 self.workspace.clone(),
1646 None,
1647 None,
1648 window,
1649 cx,
1650 );
1651 }
1652
1653 fn get_remote(
1654 &self,
1655 repository: &Repository,
1656 _window: &mut Window,
1657 cx: &mut App,
1658 ) -> Option<GitRemote> {
1659 let remote_url = repository.default_remote_url()?;
1660 let provider_registry = GitHostingProviderRegistry::default_global(cx);
1661 let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1662 Some(GitRemote {
1663 host: provider,
1664 owner: parsed.owner.into(),
1665 repo: parsed.repo.into(),
1666 })
1667 }
1668
1669 fn render_search_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1670 let color = cx.theme().colors();
1671 let query_focus_handle = self.search_state.editor.focus_handle(cx);
1672 let search_options = {
1673 let mut options = SearchOptions::NONE;
1674 options.set(
1675 SearchOptions::CASE_SENSITIVE,
1676 self.search_state.case_sensitive,
1677 );
1678 options
1679 };
1680
1681 h_flex()
1682 .w_full()
1683 .p_1p5()
1684 .gap_1p5()
1685 .border_b_1()
1686 .border_color(color.border_variant)
1687 .child(
1688 h_flex()
1689 .h_8()
1690 .flex_1()
1691 .min_w_0()
1692 .px_1p5()
1693 .gap_1()
1694 .border_1()
1695 .border_color(color.border_variant)
1696 .rounded_md()
1697 .bg(color.toolbar_background)
1698 .on_action(cx.listener(Self::confirm_search))
1699 .child(self.search_state.editor.clone())
1700 .child(SearchOption::CaseSensitive.as_button(
1701 search_options,
1702 SearchSource::Buffer,
1703 query_focus_handle,
1704 )),
1705 )
1706 .child(
1707 h_flex()
1708 .min_w_64()
1709 .gap_1()
1710 .child({
1711 let focus_handle = self.focus_handle.clone();
1712 IconButton::new("git-graph-search-prev", IconName::ChevronLeft)
1713 .shape(ui::IconButtonShape::Square)
1714 .icon_size(IconSize::Small)
1715 .tooltip(move |_, cx| {
1716 Tooltip::for_action_in(
1717 "Select Previous Match",
1718 &SelectPreviousMatch,
1719 &focus_handle,
1720 cx,
1721 )
1722 })
1723 .map(|this| {
1724 if self.search_state.matches.is_empty() {
1725 this.disabled(true)
1726 } else {
1727 this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
1728 this.select_previous_match(cx);
1729 }))
1730 }
1731 })
1732 })
1733 .child({
1734 let focus_handle = self.focus_handle.clone();
1735 IconButton::new("git-graph-search-next", IconName::ChevronRight)
1736 .shape(ui::IconButtonShape::Square)
1737 .icon_size(IconSize::Small)
1738 .tooltip(move |_, cx| {
1739 Tooltip::for_action_in(
1740 "Select Next Match",
1741 &SelectNextMatch,
1742 &focus_handle,
1743 cx,
1744 )
1745 })
1746 .map(|this| {
1747 if self.search_state.matches.is_empty() {
1748 this.disabled(true)
1749 } else {
1750 this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
1751 this.select_next_match(cx);
1752 }))
1753 }
1754 })
1755 })
1756 .child(
1757 h_flex()
1758 .gap_1p5()
1759 .child(
1760 Label::new(format!(
1761 "{}/{}",
1762 self.search_state
1763 .selected_index
1764 .map(|index| index + 1)
1765 .unwrap_or(0),
1766 self.search_state.matches.len()
1767 ))
1768 .size(LabelSize::Small)
1769 .when(self.search_state.matches.is_empty(), |this| {
1770 this.color(Color::Disabled)
1771 }),
1772 )
1773 .when(
1774 matches!(
1775 &self.search_state.state,
1776 QueryState::Confirmed((_, task)) if !task.is_ready()
1777 ),
1778 |this| {
1779 this.child(
1780 Icon::new(IconName::ArrowCircle)
1781 .color(Color::Accent)
1782 .size(IconSize::Small)
1783 .with_rotate_animation(2)
1784 .into_any_element(),
1785 )
1786 },
1787 ),
1788 ),
1789 )
1790 }
1791
1792 fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1793 let rems = TextSize::Large.rems(cx);
1794 Icon::new(IconName::LoadCircle)
1795 .size(IconSize::Custom(rems))
1796 .color(Color::Accent)
1797 .with_rotate_animation(3)
1798 .into_any_element()
1799 }
1800
1801 fn render_commit_detail_panel(
1802 &self,
1803 window: &mut Window,
1804 cx: &mut Context<Self>,
1805 ) -> impl IntoElement {
1806 let Some(selected_idx) = self.selected_entry_idx else {
1807 return Empty.into_any_element();
1808 };
1809
1810 let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1811 return Empty.into_any_element();
1812 };
1813
1814 let Some(repository) = self.get_repository(cx) else {
1815 return Empty.into_any_element();
1816 };
1817
1818 let data = repository.update(cx, |repository, cx| {
1819 repository
1820 .fetch_commit_data(commit_entry.data.sha, cx)
1821 .clone()
1822 });
1823
1824 let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1825 let ref_names = commit_entry.data.ref_names.clone();
1826
1827 let head_branch_name: Option<SharedString> = repository
1828 .read(cx)
1829 .snapshot()
1830 .branch
1831 .as_ref()
1832 .map(|branch| SharedString::from(branch.name().to_string()));
1833
1834 let accent_colors = cx.theme().accents();
1835 let accent_color = accent_colors
1836 .0
1837 .get(commit_entry.color_idx)
1838 .copied()
1839 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1840
1841 // todo(git graph): We should use the full commit message here
1842 let (author_name, author_email, commit_timestamp, commit_message) = match &data {
1843 CommitDataState::Loaded(data) => (
1844 data.author_name.clone(),
1845 data.author_email.clone(),
1846 Some(data.commit_timestamp),
1847 data.subject.clone(),
1848 ),
1849 CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
1850 };
1851
1852 let date_string = commit_timestamp
1853 .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1854 .map(|datetime| {
1855 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1856 let local_datetime = datetime.to_offset(local_offset);
1857 let format =
1858 time::format_description::parse("[month repr:short] [day], [year]").ok();
1859 format
1860 .and_then(|f| local_datetime.format(&f).ok())
1861 .unwrap_or_default()
1862 })
1863 .unwrap_or_default();
1864
1865 let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1866
1867 let avatar = {
1868 let author_email_for_avatar = if author_email.is_empty() {
1869 None
1870 } else {
1871 Some(author_email.clone())
1872 };
1873
1874 CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
1875 .size(px(40.))
1876 .render(window, cx)
1877 };
1878
1879 let changed_files_count = self
1880 .selected_commit_diff
1881 .as_ref()
1882 .map(|diff| diff.files.len())
1883 .unwrap_or(0);
1884
1885 let (total_lines_added, total_lines_removed) =
1886 self.selected_commit_diff_stats.unwrap_or((0, 0));
1887
1888 let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1889 self.selected_commit_diff
1890 .as_ref()
1891 .map(|diff| {
1892 let mut files: Vec<_> = diff.files.iter().collect();
1893 files.sort_by_key(|file| file.status());
1894 files
1895 .into_iter()
1896 .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1897 .collect()
1898 })
1899 .unwrap_or_default(),
1900 );
1901
1902 v_flex()
1903 .min_w(px(300.))
1904 .h_full()
1905 .bg(cx.theme().colors().editor_background)
1906 .flex_basis(DefiniteLength::Fraction(
1907 self.commit_details_split_state.read(cx).right_ratio(),
1908 ))
1909 .child(
1910 v_flex()
1911 .relative()
1912 .w_full()
1913 .p_2()
1914 .gap_2()
1915 .child(
1916 div().absolute().top_2().right_2().child(
1917 IconButton::new("close-detail", IconName::Close)
1918 .icon_size(IconSize::Small)
1919 .on_click(cx.listener(move |this, _, _, cx| {
1920 this.selected_entry_idx = None;
1921 this.selected_commit_diff = None;
1922 this.selected_commit_diff_stats = None;
1923 this._commit_diff_task = None;
1924 cx.notify();
1925 })),
1926 ),
1927 )
1928 .child(
1929 v_flex()
1930 .py_1()
1931 .w_full()
1932 .items_center()
1933 .gap_1()
1934 .child(avatar)
1935 .child(
1936 v_flex()
1937 .items_center()
1938 .child(Label::new(author_name))
1939 .child(
1940 Label::new(date_string)
1941 .color(Color::Muted)
1942 .size(LabelSize::Small),
1943 ),
1944 ),
1945 )
1946 .children((!ref_names.is_empty()).then(|| {
1947 h_flex().gap_1().flex_wrap().justify_center().children(
1948 ref_names.iter().map(|name| {
1949 let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name);
1950 self.render_chip(name, accent_color, is_head)
1951 }),
1952 )
1953 }))
1954 .child(
1955 v_flex()
1956 .ml_neg_1()
1957 .gap_1p5()
1958 .when(!author_email.is_empty(), |this| {
1959 let copied_state: Entity<CopiedState> = window.use_keyed_state(
1960 "author-email-copy",
1961 cx,
1962 CopiedState::new,
1963 );
1964 let is_copied = copied_state.read(cx).is_copied();
1965
1966 let (icon, icon_color, tooltip_label) = if is_copied {
1967 (IconName::Check, Color::Success, "Email Copied!")
1968 } else {
1969 (IconName::Envelope, Color::Muted, "Copy Email")
1970 };
1971
1972 let copy_email = author_email.clone();
1973 let author_email_for_tooltip = author_email.clone();
1974
1975 this.child(
1976 Button::new("author-email-copy", author_email.clone())
1977 .start_icon(
1978 Icon::new(icon).size(IconSize::Small).color(icon_color),
1979 )
1980 .label_size(LabelSize::Small)
1981 .truncate(true)
1982 .color(Color::Muted)
1983 .tooltip(move |_, cx| {
1984 Tooltip::with_meta(
1985 tooltip_label,
1986 None,
1987 author_email_for_tooltip.clone(),
1988 cx,
1989 )
1990 })
1991 .on_click(move |_, _, cx| {
1992 copied_state.update(cx, |state, _cx| {
1993 state.mark_copied();
1994 });
1995 cx.write_to_clipboard(ClipboardItem::new_string(
1996 copy_email.to_string(),
1997 ));
1998 let state_id = copied_state.entity_id();
1999 cx.spawn(async move |cx| {
2000 cx.background_executor()
2001 .timer(COPIED_STATE_DURATION)
2002 .await;
2003 cx.update(|cx| {
2004 cx.notify(state_id);
2005 })
2006 })
2007 .detach();
2008 }),
2009 )
2010 })
2011 .child({
2012 let copy_sha = full_sha.clone();
2013 let copied_state: Entity<CopiedState> =
2014 window.use_keyed_state("sha-copy", cx, CopiedState::new);
2015 let is_copied = copied_state.read(cx).is_copied();
2016
2017 let (icon, icon_color, tooltip_label) = if is_copied {
2018 (IconName::Check, Color::Success, "Commit SHA Copied!")
2019 } else {
2020 (IconName::Hash, Color::Muted, "Copy Commit SHA")
2021 };
2022
2023 Button::new("sha-button", &full_sha)
2024 .start_icon(
2025 Icon::new(icon).size(IconSize::Small).color(icon_color),
2026 )
2027 .label_size(LabelSize::Small)
2028 .truncate(true)
2029 .color(Color::Muted)
2030 .tooltip({
2031 let full_sha = full_sha.clone();
2032 move |_, cx| {
2033 Tooltip::with_meta(
2034 tooltip_label,
2035 None,
2036 full_sha.clone(),
2037 cx,
2038 )
2039 }
2040 })
2041 .on_click(move |_, _, cx| {
2042 copied_state.update(cx, |state, _cx| {
2043 state.mark_copied();
2044 });
2045 cx.write_to_clipboard(ClipboardItem::new_string(
2046 copy_sha.to_string(),
2047 ));
2048 let state_id = copied_state.entity_id();
2049 cx.spawn(async move |cx| {
2050 cx.background_executor()
2051 .timer(COPIED_STATE_DURATION)
2052 .await;
2053 cx.update(|cx| {
2054 cx.notify(state_id);
2055 })
2056 })
2057 .detach();
2058 })
2059 })
2060 .when_some(remote.clone(), |this, remote| {
2061 let provider_name = remote.host.name();
2062 let icon = match provider_name.as_str() {
2063 "GitHub" => IconName::Github,
2064 _ => IconName::Link,
2065 };
2066 let parsed_remote = ParsedGitRemote {
2067 owner: remote.owner.as_ref().into(),
2068 repo: remote.repo.as_ref().into(),
2069 };
2070 let params = BuildCommitPermalinkParams {
2071 sha: full_sha.as_ref(),
2072 };
2073 let url = remote
2074 .host
2075 .build_commit_permalink(&parsed_remote, params)
2076 .to_string();
2077
2078 this.child(
2079 Button::new(
2080 "view-on-provider",
2081 format!("View on {}", provider_name),
2082 )
2083 .start_icon(
2084 Icon::new(icon).size(IconSize::Small).color(Color::Muted),
2085 )
2086 .label_size(LabelSize::Small)
2087 .truncate(true)
2088 .color(Color::Muted)
2089 .on_click(
2090 move |_, _, cx| {
2091 cx.open_url(&url);
2092 },
2093 ),
2094 )
2095 }),
2096 ),
2097 )
2098 .child(Divider::horizontal())
2099 .child(div().p_2().child(Label::new(commit_message)))
2100 .child(Divider::horizontal())
2101 .child(
2102 v_flex()
2103 .min_w_0()
2104 .p_2()
2105 .flex_1()
2106 .gap_1()
2107 .child(
2108 h_flex()
2109 .gap_1()
2110 .w_full()
2111 .justify_between()
2112 .child(
2113 Label::new(format!(
2114 "{} Changed {}",
2115 changed_files_count,
2116 if changed_files_count == 1 {
2117 "File"
2118 } else {
2119 "Files"
2120 }
2121 ))
2122 .size(LabelSize::Small)
2123 .color(Color::Muted),
2124 )
2125 .child(DiffStat::new(
2126 "commit-diff-stat",
2127 total_lines_added,
2128 total_lines_removed,
2129 )),
2130 )
2131 .child(
2132 div()
2133 .id("changed-files-container")
2134 .flex_1()
2135 .min_h_0()
2136 .child({
2137 let entries = sorted_file_entries;
2138 let entry_count = entries.len();
2139 let commit_sha = full_sha.clone();
2140 let repository = repository.downgrade();
2141 let workspace = self.workspace.clone();
2142 uniform_list(
2143 "changed-files-list",
2144 entry_count,
2145 move |range, _window, cx| {
2146 range
2147 .map(|ix| {
2148 entries[ix].render(
2149 ix,
2150 commit_sha.clone(),
2151 repository.clone(),
2152 workspace.clone(),
2153 cx,
2154 )
2155 })
2156 .collect()
2157 },
2158 )
2159 .size_full()
2160 .ml_neg_1()
2161 .track_scroll(&self.changed_files_scroll_handle)
2162 })
2163 .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
2164 ),
2165 )
2166 .child(Divider::horizontal())
2167 .child(
2168 h_flex().p_1p5().w_full().child(
2169 Button::new("view-commit", "View Commit")
2170 .full_width()
2171 .style(ButtonStyle::OutlinedGhost)
2172 .on_click(cx.listener(|this, _, window, cx| {
2173 this.open_selected_commit_view(window, cx);
2174 })),
2175 ),
2176 )
2177 .into_any_element()
2178 }
2179
2180 pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
2181 let row_height = Self::row_height(window, cx);
2182 let table_state = self.table_interaction_state.read(cx);
2183 let viewport_height = table_state
2184 .scroll_handle
2185 .0
2186 .borrow()
2187 .last_item_size
2188 .map(|size| size.item.height)
2189 .unwrap_or(window.viewport_size().height);
2190 let loaded_commit_count = self.graph_data.commits.len();
2191
2192 let content_height = row_height * loaded_commit_count;
2193 let max_scroll = (content_height - viewport_height).max(px(0.));
2194 let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
2195
2196 let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
2197 let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
2198
2199 let graph_viewport_width = self.graph_viewport_width(window, cx);
2200 let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
2201 self.graph_canvas_content_width()
2202 } else {
2203 graph_viewport_width
2204 };
2205 let last_visible_row =
2206 first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
2207
2208 let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
2209 ..(last_visible_row).min(loaded_commit_count);
2210 let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
2211 let commit_lines: Vec<_> = self
2212 .graph_data
2213 .lines
2214 .iter()
2215 .filter(|line| {
2216 line.full_interval.start <= viewport_range.end
2217 && line.full_interval.end >= viewport_range.start
2218 })
2219 .cloned()
2220 .collect();
2221
2222 let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
2223
2224 let hovered_entry_idx = self.hovered_entry_idx;
2225 let selected_entry_idx = self.selected_entry_idx;
2226 let is_focused = self.focus_handle.is_focused(window);
2227 let graph_canvas_bounds = self.graph_canvas_bounds.clone();
2228
2229 gpui::canvas(
2230 move |_bounds, _window, _cx| {},
2231 move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
2232 graph_canvas_bounds.set(Some(bounds));
2233
2234 window.paint_layer(bounds, |window| {
2235 let accent_colors = cx.theme().accents();
2236
2237 let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
2238 let selected_bg = if is_focused {
2239 cx.theme().colors().element_selected
2240 } else {
2241 cx.theme().colors().element_hover
2242 };
2243
2244 for visible_row_idx in 0..rows.len() {
2245 let absolute_row_idx = first_visible_row + visible_row_idx;
2246 let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
2247 let is_selected = selected_entry_idx == Some(absolute_row_idx);
2248
2249 if is_hovered || is_selected {
2250 let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
2251 - vertical_scroll_offset;
2252
2253 let row_bounds = Bounds::new(
2254 point(bounds.origin.x, row_y),
2255 gpui::Size {
2256 width: bounds.size.width,
2257 height: row_height,
2258 },
2259 );
2260
2261 let bg_color = if is_selected { selected_bg } else { hover_bg };
2262 window.paint_quad(gpui::fill(row_bounds, bg_color));
2263 }
2264 }
2265
2266 for (row_idx, row) in rows.into_iter().enumerate() {
2267 let row_color = accent_colors.color_for_index(row.color_idx as u32);
2268 let row_y_center =
2269 bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
2270 - vertical_scroll_offset;
2271
2272 let commit_x = lane_center_x(bounds, row.lane as f32);
2273
2274 draw_commit_circle(commit_x, row_y_center, row_color, window);
2275 }
2276
2277 for line in commit_lines {
2278 let Some((start_segment_idx, start_column)) =
2279 line.get_first_visible_segment_idx(first_visible_row)
2280 else {
2281 continue;
2282 };
2283
2284 let line_x = lane_center_x(bounds, start_column as f32);
2285
2286 let start_row = line.full_interval.start as i32 - first_visible_row as i32;
2287
2288 let from_y =
2289 bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
2290 - vertical_scroll_offset
2291 + COMMIT_CIRCLE_RADIUS;
2292
2293 let mut current_row = from_y;
2294 let mut current_column = line_x;
2295
2296 let mut builder = PathBuilder::stroke(LINE_WIDTH);
2297 builder.move_to(point(line_x, from_y));
2298
2299 let segments = &line.segments[start_segment_idx..];
2300 let desired_curve_height = row_height / 3.0;
2301 let desired_curve_width = LANE_WIDTH / 3.0;
2302
2303 for (segment_idx, segment) in segments.iter().enumerate() {
2304 let is_last = segment_idx + 1 == segments.len();
2305
2306 match segment {
2307 CommitLineSegment::Straight { to_row } => {
2308 let mut dest_row = to_row_center(
2309 to_row - first_visible_row,
2310 row_height,
2311 vertical_scroll_offset,
2312 bounds,
2313 );
2314 if is_last {
2315 dest_row -= COMMIT_CIRCLE_RADIUS;
2316 }
2317
2318 let dest_point = point(current_column, dest_row);
2319
2320 current_row = dest_point.y;
2321 builder.line_to(dest_point);
2322 builder.move_to(dest_point);
2323 }
2324 CommitLineSegment::Curve {
2325 to_column,
2326 on_row,
2327 curve_kind,
2328 } => {
2329 let mut to_column = lane_center_x(bounds, *to_column as f32);
2330
2331 let mut to_row = to_row_center(
2332 *on_row - first_visible_row,
2333 row_height,
2334 vertical_scroll_offset,
2335 bounds,
2336 );
2337
2338 // This means that this branch was a checkout
2339 let going_right = to_column > current_column;
2340 let column_shift = if going_right {
2341 COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
2342 } else {
2343 -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
2344 };
2345
2346 match curve_kind {
2347 CurveKind::Checkout => {
2348 if is_last {
2349 to_column -= column_shift;
2350 }
2351
2352 let available_curve_width =
2353 (to_column - current_column).abs();
2354 let available_curve_height =
2355 (to_row - current_row).abs();
2356 let curve_width =
2357 desired_curve_width.min(available_curve_width);
2358 let curve_height =
2359 desired_curve_height.min(available_curve_height);
2360 let signed_curve_width = if going_right {
2361 curve_width
2362 } else {
2363 -curve_width
2364 };
2365 let curve_start =
2366 point(current_column, to_row - curve_height);
2367 let curve_end =
2368 point(current_column + signed_curve_width, to_row);
2369 let curve_control = point(current_column, to_row);
2370
2371 builder.move_to(point(current_column, current_row));
2372 builder.line_to(curve_start);
2373 builder.move_to(curve_start);
2374 builder.curve_to(curve_end, curve_control);
2375 builder.move_to(curve_end);
2376 builder.line_to(point(to_column, to_row));
2377 }
2378 CurveKind::Merge => {
2379 if is_last {
2380 to_row -= COMMIT_CIRCLE_RADIUS;
2381 }
2382
2383 let merge_start = point(
2384 current_column + column_shift,
2385 current_row - COMMIT_CIRCLE_RADIUS,
2386 );
2387 let available_curve_width =
2388 (to_column - merge_start.x).abs();
2389 let available_curve_height =
2390 (to_row - merge_start.y).abs();
2391 let curve_width =
2392 desired_curve_width.min(available_curve_width);
2393 let curve_height =
2394 desired_curve_height.min(available_curve_height);
2395 let signed_curve_width = if going_right {
2396 curve_width
2397 } else {
2398 -curve_width
2399 };
2400 let curve_start = point(
2401 to_column - signed_curve_width,
2402 merge_start.y,
2403 );
2404 let curve_end =
2405 point(to_column, merge_start.y + curve_height);
2406 let curve_control = point(to_column, merge_start.y);
2407
2408 builder.move_to(merge_start);
2409 builder.line_to(curve_start);
2410 builder.move_to(curve_start);
2411 builder.curve_to(curve_end, curve_control);
2412 builder.move_to(curve_end);
2413 builder.line_to(point(to_column, to_row));
2414 }
2415 }
2416 current_row = to_row;
2417 current_column = to_column;
2418 builder.move_to(point(current_column, current_row));
2419 }
2420 }
2421 }
2422
2423 builder.close();
2424 lines.entry(line.color_idx).or_default().push(builder);
2425 }
2426
2427 for (color_idx, builders) in lines {
2428 let line_color = accent_colors.color_for_index(color_idx as u32);
2429
2430 for builder in builders {
2431 if let Ok(path) = builder.build() {
2432 // we paint each color on it's own layer to stop overlapping lines
2433 // of different colors changing the color of a line
2434 window.paint_layer(bounds, |window| {
2435 window.paint_path(path, line_color);
2436 });
2437 }
2438 }
2439 }
2440 })
2441 },
2442 )
2443 .w(graph_width)
2444 .h_full()
2445 }
2446
2447 fn row_at_position(
2448 &self,
2449 position_y: Pixels,
2450 window: &Window,
2451 cx: &Context<Self>,
2452 ) -> Option<usize> {
2453 let canvas_bounds = self.graph_canvas_bounds.get()?;
2454 let table_state = self.table_interaction_state.read(cx);
2455 let scroll_offset_y = -table_state.scroll_offset().y;
2456
2457 let local_y = position_y - canvas_bounds.origin.y;
2458
2459 if local_y >= px(0.) && local_y < canvas_bounds.size.height {
2460 let absolute_y = local_y + scroll_offset_y;
2461 let row_height = Self::row_height(window, cx);
2462 let absolute_row = (absolute_y / row_height).floor() as usize;
2463
2464 if absolute_row < self.graph_data.commits.len() {
2465 return Some(absolute_row);
2466 }
2467 }
2468
2469 None
2470 }
2471
2472 fn handle_graph_mouse_move(
2473 &mut self,
2474 event: &gpui::MouseMoveEvent,
2475 window: &mut Window,
2476 cx: &mut Context<Self>,
2477 ) {
2478 if let Some(row) = self.row_at_position(event.position.y, window, cx) {
2479 if self.hovered_entry_idx != Some(row) {
2480 self.hovered_entry_idx = Some(row);
2481 cx.notify();
2482 }
2483 } else if self.hovered_entry_idx.is_some() {
2484 self.hovered_entry_idx = None;
2485 cx.notify();
2486 }
2487 }
2488
2489 fn handle_graph_click(
2490 &mut self,
2491 event: &ClickEvent,
2492 window: &mut Window,
2493 cx: &mut Context<Self>,
2494 ) {
2495 if let Some(row) = self.row_at_position(event.position().y, window, cx) {
2496 self.select_entry(row, ScrollStrategy::Nearest, cx);
2497 if event.click_count() >= 2 {
2498 self.open_commit_view(row, window, cx);
2499 }
2500 }
2501 }
2502
2503 fn handle_graph_scroll(
2504 &mut self,
2505 event: &ScrollWheelEvent,
2506 window: &mut Window,
2507 cx: &mut Context<Self>,
2508 ) {
2509 let line_height = window.line_height();
2510 let delta = event.delta.pixel_delta(line_height);
2511
2512 let table_state = self.table_interaction_state.read(cx);
2513 let current_offset = table_state.scroll_offset();
2514
2515 let viewport_height = table_state.scroll_handle.viewport().size.height;
2516
2517 let commit_count = match self.graph_data.max_commit_count {
2518 AllCommitCount::Loaded(count) => count,
2519 AllCommitCount::NotLoaded => self.graph_data.commits.len(),
2520 };
2521 let content_height = Self::row_height(window, cx) * commit_count;
2522 let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
2523
2524 let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
2525 let new_offset = Point::new(current_offset.x, new_y);
2526
2527 if new_offset != current_offset {
2528 table_state.set_scroll_offset(new_offset);
2529 cx.notify();
2530 }
2531 }
2532
2533 fn render_commit_view_resize_handle(
2534 &self,
2535 _window: &mut Window,
2536 cx: &mut Context<Self>,
2537 ) -> AnyElement {
2538 div()
2539 .id("commit-view-split-resize-container")
2540 .relative()
2541 .h_full()
2542 .flex_shrink_0()
2543 .w(px(1.))
2544 .bg(cx.theme().colors().border_variant)
2545 .child(
2546 div()
2547 .id("commit-view-split-resize-handle")
2548 .absolute()
2549 .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
2550 .w(px(RESIZE_HANDLE_WIDTH))
2551 .h_full()
2552 .cursor_col_resize()
2553 .block_mouse_except_scroll()
2554 .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
2555 if event.click_count() >= 2 {
2556 this.commit_details_split_state.update(cx, |state, _| {
2557 state.on_double_click();
2558 });
2559 }
2560 cx.stop_propagation();
2561 }))
2562 .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
2563 )
2564 .into_any_element()
2565 }
2566}
2567
2568impl Render for GitGraph {
2569 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2570 // This happens when we changed branches, we should refresh our search as well
2571 if let QueryState::Pending(query) = &mut self.search_state.state {
2572 let query = std::mem::take(query);
2573 self.search_state.state = QueryState::Empty;
2574 self.search(query, cx);
2575 }
2576 let (commit_count, is_loading) = match self.graph_data.max_commit_count {
2577 AllCommitCount::Loaded(count) => (count, true),
2578 AllCommitCount::NotLoaded => {
2579 let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) {
2580 repository.update(cx, |repository, cx| {
2581 // Start loading the graph data if we haven't started already
2582 let GraphDataResponse {
2583 commits,
2584 is_loading,
2585 error: _,
2586 } = repository.graph_data(
2587 self.log_source.clone(),
2588 self.log_order,
2589 0..usize::MAX,
2590 cx,
2591 );
2592 self.graph_data.add_commits(&commits);
2593 (commits.len(), is_loading)
2594 })
2595 } else {
2596 (0, false)
2597 };
2598
2599 (commit_count, is_loading)
2600 }
2601 };
2602
2603 let error = self.get_repository(cx).and_then(|repo| {
2604 repo.read(cx)
2605 .get_graph_data(self.log_source.clone(), self.log_order)
2606 .and_then(|data| data.error.clone())
2607 });
2608
2609 let content = if commit_count == 0 {
2610 let message = if let Some(error) = &error {
2611 format!("Error loading: {}", error)
2612 } else if is_loading {
2613 "Loading".to_string()
2614 } else {
2615 "No commits found".to_string()
2616 };
2617 let label = Label::new(message)
2618 .color(Color::Muted)
2619 .size(LabelSize::Large);
2620 div()
2621 .size_full()
2622 .h_flex()
2623 .gap_1()
2624 .items_center()
2625 .justify_center()
2626 .child(label)
2627 .when(is_loading && error.is_none(), |this| {
2628 this.child(self.render_loading_spinner(cx))
2629 })
2630 } else {
2631 let header_resize_info =
2632 HeaderResizeInfo::from_redistributable(&self.column_widths, cx);
2633 let header_context = TableRenderContext::for_column_widths(
2634 Some(self.column_widths.read(cx).widths_to_render()),
2635 true,
2636 );
2637 let [
2638 graph_fraction,
2639 description_fraction,
2640 date_fraction,
2641 author_fraction,
2642 commit_fraction,
2643 ] = self.preview_column_fractions(window, cx);
2644 let table_fraction =
2645 description_fraction + date_fraction + author_fraction + commit_fraction;
2646 let table_width_config = self.table_column_width_config(window, cx);
2647
2648 h_flex()
2649 .size_full()
2650 .child(
2651 div()
2652 .flex_1()
2653 .min_w_0()
2654 .size_full()
2655 .flex()
2656 .flex_col()
2657 .child(render_table_header(
2658 TableRow::from_vec(
2659 vec![
2660 Label::new("Graph")
2661 .color(Color::Muted)
2662 .truncate()
2663 .into_any_element(),
2664 Label::new("Description")
2665 .color(Color::Muted)
2666 .into_any_element(),
2667 Label::new("Date").color(Color::Muted).into_any_element(),
2668 Label::new("Author").color(Color::Muted).into_any_element(),
2669 Label::new("Commit").color(Color::Muted).into_any_element(),
2670 ],
2671 5,
2672 ),
2673 header_context,
2674 Some(header_resize_info),
2675 Some(self.column_widths.entity_id()),
2676 cx,
2677 ))
2678 .child({
2679 let row_height = Self::row_height(window, cx);
2680 let selected_entry_idx = self.selected_entry_idx;
2681 let hovered_entry_idx = self.hovered_entry_idx;
2682 let weak_self = cx.weak_entity();
2683 let focus_handle = self.focus_handle.clone();
2684
2685 bind_redistributable_columns(
2686 div()
2687 .relative()
2688 .flex_1()
2689 .w_full()
2690 .overflow_hidden()
2691 .child(
2692 h_flex()
2693 .size_full()
2694 .child(
2695 div()
2696 .w(DefiniteLength::Fraction(graph_fraction))
2697 .h_full()
2698 .min_w_0()
2699 .overflow_hidden()
2700 .child(
2701 div()
2702 .id("graph-canvas")
2703 .size_full()
2704 .overflow_hidden()
2705 .child(
2706 div()
2707 .size_full()
2708 .child(self.render_graph(window, cx)),
2709 )
2710 .on_scroll_wheel(
2711 cx.listener(Self::handle_graph_scroll),
2712 )
2713 .on_mouse_move(
2714 cx.listener(Self::handle_graph_mouse_move),
2715 )
2716 .on_click(cx.listener(Self::handle_graph_click))
2717 .on_hover(cx.listener(
2718 |this, &is_hovered: &bool, _, cx| {
2719 if !is_hovered
2720 && this.hovered_entry_idx.is_some()
2721 {
2722 this.hovered_entry_idx = None;
2723 cx.notify();
2724 }
2725 },
2726 )),
2727 ),
2728 )
2729 .child(
2730 div()
2731 .w(DefiniteLength::Fraction(table_fraction))
2732 .h_full()
2733 .min_w_0()
2734 .child(
2735 Table::new(4)
2736 .interactable(&self.table_interaction_state)
2737 .hide_row_borders()
2738 .hide_row_hover()
2739 .width_config(table_width_config)
2740 .map_row(move |(index, row), window, cx| {
2741 let is_selected =
2742 selected_entry_idx == Some(index);
2743 let is_hovered =
2744 hovered_entry_idx == Some(index);
2745 let is_focused =
2746 focus_handle.is_focused(window);
2747 let weak = weak_self.clone();
2748 let weak_for_hover = weak.clone();
2749
2750 let hover_bg = cx
2751 .theme()
2752 .colors()
2753 .element_hover
2754 .opacity(0.6);
2755 let selected_bg = if is_focused {
2756 cx.theme().colors().element_selected
2757 } else {
2758 cx.theme().colors().element_hover
2759 };
2760
2761 row.h(row_height)
2762 .when(is_selected, |row| row.bg(selected_bg))
2763 .when(
2764 is_hovered && !is_selected,
2765 |row| row.bg(hover_bg),
2766 )
2767 .on_hover(move |&is_hovered, _, cx| {
2768 weak_for_hover
2769 .update(cx, |this, cx| {
2770 if is_hovered {
2771 if this.hovered_entry_idx
2772 != Some(index)
2773 {
2774 this.hovered_entry_idx =
2775 Some(index);
2776 cx.notify();
2777 }
2778 } else if this
2779 .hovered_entry_idx
2780 == Some(index)
2781 {
2782 this.hovered_entry_idx =
2783 None;
2784 cx.notify();
2785 }
2786 })
2787 .ok();
2788 })
2789 .on_click(move |event, window, cx| {
2790 let click_count = event.click_count();
2791 weak.update(cx, |this, cx| {
2792 this.select_entry(
2793 index,
2794 ScrollStrategy::Center,
2795 cx,
2796 );
2797 if click_count >= 2 {
2798 this.open_commit_view(
2799 index,
2800 window,
2801 cx,
2802 );
2803 }
2804 })
2805 .ok();
2806 })
2807 .into_any_element()
2808 })
2809 .uniform_list(
2810 "git-graph-commits",
2811 commit_count,
2812 cx.processor(Self::render_table_rows),
2813 ),
2814 ),
2815 ),
2816 )
2817 .child(render_redistributable_columns_resize_handles(
2818 &self.column_widths,
2819 window,
2820 cx,
2821 )),
2822 self.column_widths.clone(),
2823 )
2824 }),
2825 )
2826 .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
2827 this.commit_details_split_state.update(cx, |state, cx| {
2828 state.on_drag_move(event, window, cx);
2829 });
2830 }))
2831 .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
2832 this.commit_details_split_state.update(cx, |state, _cx| {
2833 state.commit_ratio();
2834 });
2835 }))
2836 .when(self.selected_entry_idx.is_some(), |this| {
2837 this.child(self.render_commit_view_resize_handle(window, cx))
2838 .child(self.render_commit_detail_panel(window, cx))
2839 })
2840 };
2841
2842 div()
2843 .key_context("GitGraph")
2844 .track_focus(&self.focus_handle)
2845 .size_full()
2846 .bg(cx.theme().colors().editor_background)
2847 .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
2848 this.open_selected_commit_view(window, cx);
2849 }))
2850 .on_action(cx.listener(Self::cancel))
2851 .on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
2852 this.search_state
2853 .editor
2854 .update(cx, |editor, cx| editor.focus_handle(cx).focus(window, cx));
2855 }))
2856 .on_action(cx.listener(Self::select_first))
2857 .on_action(cx.listener(Self::select_prev))
2858 .on_action(cx.listener(Self::select_next))
2859 .on_action(cx.listener(Self::select_last))
2860 .on_action(cx.listener(Self::confirm))
2861 .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| {
2862 this.select_next_match(cx);
2863 }))
2864 .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| {
2865 this.select_previous_match(cx);
2866 }))
2867 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| {
2868 this.search_state.case_sensitive = !this.search_state.case_sensitive;
2869 this.search_state.state.next_state();
2870 cx.notify();
2871 }))
2872 .child(
2873 v_flex()
2874 .size_full()
2875 .child(self.render_search_bar(cx))
2876 .child(div().flex_1().child(content)),
2877 )
2878 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2879 deferred(
2880 anchored()
2881 .position(*position)
2882 .anchor(Anchor::TopLeft)
2883 .child(menu.clone()),
2884 )
2885 .with_priority(1)
2886 }))
2887 .on_action(cx.listener(|_, _: &buffer_search::Deploy, window, cx| {
2888 window.dispatch_action(Box::new(FocusSearch), cx);
2889 cx.stop_propagation();
2890 }))
2891 }
2892}
2893
2894impl EventEmitter<ItemEvent> for GitGraph {}
2895
2896impl Focusable for GitGraph {
2897 fn focus_handle(&self, _cx: &App) -> FocusHandle {
2898 self.focus_handle.clone()
2899 }
2900}
2901
2902impl Item for GitGraph {
2903 type Event = ItemEvent;
2904
2905 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
2906 Some(Icon::new(IconName::GitGraph))
2907 }
2908
2909 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
2910 let repo_name = self.get_repository(cx).and_then(|repo| {
2911 repo.read(cx)
2912 .work_directory_abs_path
2913 .file_name()
2914 .map(|name| name.to_string_lossy().to_string())
2915 });
2916
2917 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
2918 move |_, _| {
2919 v_flex()
2920 .child(Label::new("Git Graph"))
2921 .when_some(repo_name.clone(), |this, name| {
2922 this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
2923 })
2924 .into_any_element()
2925 }
2926 }))))
2927 }
2928
2929 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
2930 self.get_repository(cx)
2931 .and_then(|repo| {
2932 repo.read(cx)
2933 .work_directory_abs_path
2934 .file_name()
2935 .map(|name| name.to_string_lossy().to_string())
2936 })
2937 .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
2938 }
2939
2940 fn show_toolbar(&self) -> bool {
2941 false
2942 }
2943
2944 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
2945 f(*event)
2946 }
2947}
2948
2949impl workspace::SerializableItem for GitGraph {
2950 fn serialized_item_kind() -> &'static str {
2951 "GitGraph"
2952 }
2953
2954 fn cleanup(
2955 workspace_id: workspace::WorkspaceId,
2956 alive_items: Vec<workspace::ItemId>,
2957 _window: &mut Window,
2958 cx: &mut App,
2959 ) -> Task<gpui::Result<()>> {
2960 workspace::delete_unloaded_items(
2961 alive_items,
2962 workspace_id,
2963 "git_graphs",
2964 &persistence::GitGraphsDb::global(cx),
2965 cx,
2966 )
2967 }
2968
2969 fn deserialize(
2970 project: Entity<project::Project>,
2971 workspace: WeakEntity<Workspace>,
2972 workspace_id: workspace::WorkspaceId,
2973 item_id: workspace::ItemId,
2974 window: &mut Window,
2975 cx: &mut App,
2976 ) -> Task<gpui::Result<Entity<Self>>> {
2977 let db = persistence::GitGraphsDb::global(cx);
2978 let Some(repo_work_path) = db.get_git_graph(item_id, workspace_id).ok().flatten() else {
2979 return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")));
2980 };
2981
2982 let window_handle = window.window_handle();
2983 let project = project.read(cx);
2984 let git_store = project.git_store().clone();
2985 let wait = project.wait_for_initial_scan(cx);
2986
2987 cx.spawn(async move |cx| {
2988 wait.await;
2989
2990 cx.update_window(window_handle, |_, window, cx| {
2991 let path = repo_work_path.as_path();
2992
2993 let repositories = git_store.read(cx).repositories();
2994 let repo_id = repositories.iter().find_map(|(&repo_id, repo)| {
2995 if repo.read(cx).snapshot().work_directory_abs_path.as_ref() == path {
2996 Some(repo_id)
2997 } else {
2998 None
2999 }
3000 });
3001
3002 let Some(repo_id) = repo_id else {
3003 return Err(anyhow::anyhow!("Repository not found for path: {:?}", path));
3004 };
3005
3006 Ok(cx.new(|cx| GitGraph::new(repo_id, git_store, workspace, window, cx)))
3007 })?
3008 })
3009 }
3010
3011 fn serialize(
3012 &mut self,
3013 workspace: &mut Workspace,
3014 item_id: workspace::ItemId,
3015 _closing: bool,
3016 _window: &mut Window,
3017 cx: &mut Context<Self>,
3018 ) -> Option<Task<gpui::Result<()>>> {
3019 let workspace_id = workspace.database_id()?;
3020 let repo = self.get_repository(cx)?;
3021 let repo_working_path = repo
3022 .read(cx)
3023 .snapshot()
3024 .work_directory_abs_path
3025 .to_string_lossy()
3026 .to_string();
3027
3028 let db = persistence::GitGraphsDb::global(cx);
3029 Some(cx.background_spawn(async move {
3030 db.save_git_graph(item_id, workspace_id, repo_working_path)
3031 .await
3032 }))
3033 }
3034
3035 fn should_serialize(&self, event: &Self::Event) -> bool {
3036 event == &ItemEvent::UpdateTab
3037 }
3038}
3039
3040mod persistence {
3041 use std::path::PathBuf;
3042
3043 use db::{
3044 query,
3045 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
3046 sqlez_macros::sql,
3047 };
3048 use workspace::WorkspaceDb;
3049
3050 pub struct GitGraphsDb(ThreadSafeConnection);
3051
3052 impl Domain for GitGraphsDb {
3053 const NAME: &str = stringify!(GitGraphsDb);
3054
3055 const MIGRATIONS: &[&str] = &[
3056 sql!(
3057 CREATE TABLE git_graphs (
3058 workspace_id INTEGER,
3059 item_id INTEGER UNIQUE,
3060 is_open INTEGER DEFAULT FALSE,
3061
3062 PRIMARY KEY(workspace_id, item_id),
3063 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
3064 ON DELETE CASCADE
3065 ) STRICT;
3066 ),
3067 sql!(
3068 ALTER TABLE git_graphs ADD COLUMN repo_working_path TEXT;
3069 ),
3070 ];
3071 }
3072
3073 db::static_connection!(GitGraphsDb, [WorkspaceDb]);
3074
3075 impl GitGraphsDb {
3076 query! {
3077 pub async fn save_git_graph(
3078 item_id: workspace::ItemId,
3079 workspace_id: workspace::WorkspaceId,
3080 repo_working_path: String
3081 ) -> Result<()> {
3082 INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, repo_working_path)
3083 VALUES (?, ?, ?)
3084 }
3085 }
3086
3087 query! {
3088 pub fn get_git_graph(
3089 item_id: workspace::ItemId,
3090 workspace_id: workspace::WorkspaceId
3091 ) -> Result<Option<PathBuf>> {
3092 SELECT repo_working_path
3093 FROM git_graphs
3094 WHERE item_id = ? AND workspace_id = ?
3095 }
3096 }
3097 }
3098}
3099
3100#[cfg(test)]
3101mod tests {
3102 use super::*;
3103 use anyhow::{Context, Result, bail};
3104 use collections::{HashMap, HashSet};
3105 use fs::FakeFs;
3106 use git::Oid;
3107 use git::repository::InitialGraphCommitData;
3108 use gpui::{TestAppContext, UpdateGlobal};
3109 use project::Project;
3110 use project::git_store::{GitStoreEvent, RepositoryEvent};
3111 use rand::prelude::*;
3112 use serde_json::json;
3113 use settings::{SettingsStore, ThemeSettingsContent};
3114 use smallvec::{SmallVec, smallvec};
3115 use std::path::Path;
3116 use std::sync::{Arc, Mutex};
3117
3118 fn init_test(cx: &mut TestAppContext) {
3119 cx.update(|cx| {
3120 let settings_store = SettingsStore::test(cx);
3121 cx.set_global(settings_store);
3122 theme_settings::init(theme::LoadThemes::JustBase, cx);
3123 });
3124 }
3125
3126 /// Generates a random commit DAG suitable for testing git graph rendering.
3127 ///
3128 /// The commits are ordered newest-first (like git log output), so:
3129 /// - Index 0 = most recent commit (HEAD)
3130 /// - Last index = oldest commit (root, has no parents)
3131 /// - Parents of commit at index I must have index > I
3132 ///
3133 /// When `adversarial` is true, generates complex topologies with many branches
3134 /// and octopus merges. Otherwise generates more realistic linear histories
3135 /// with occasional branches.
3136 fn generate_random_commit_dag(
3137 rng: &mut StdRng,
3138 num_commits: usize,
3139 adversarial: bool,
3140 ) -> Vec<Arc<InitialGraphCommitData>> {
3141 if num_commits == 0 {
3142 return Vec::new();
3143 }
3144
3145 let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
3146 let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
3147
3148 for i in 0..num_commits {
3149 let sha = oids[i];
3150
3151 let parents = if i == num_commits - 1 {
3152 smallvec![]
3153 } else {
3154 generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
3155 };
3156
3157 let ref_names = if i == 0 {
3158 vec!["HEAD".into(), "main".into()]
3159 } else if adversarial && rng.random_bool(0.1) {
3160 vec![format!("branch-{}", i).into()]
3161 } else {
3162 Vec::new()
3163 };
3164
3165 commits.push(Arc::new(InitialGraphCommitData {
3166 sha,
3167 parents,
3168 ref_names,
3169 }));
3170 }
3171
3172 commits
3173 }
3174
3175 fn generate_parents_from_oids(
3176 rng: &mut StdRng,
3177 oids: &[Oid],
3178 current_idx: usize,
3179 num_commits: usize,
3180 adversarial: bool,
3181 ) -> SmallVec<[Oid; 1]> {
3182 let remaining = num_commits - current_idx - 1;
3183 if remaining == 0 {
3184 return smallvec![];
3185 }
3186
3187 if adversarial {
3188 let merge_chance = 0.4;
3189 let octopus_chance = 0.15;
3190
3191 if remaining >= 3 && rng.random_bool(octopus_chance) {
3192 let num_parents = rng.random_range(3..=remaining.min(5));
3193 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
3194 parent_indices.shuffle(rng);
3195 parent_indices
3196 .into_iter()
3197 .take(num_parents)
3198 .map(|idx| oids[idx])
3199 .collect()
3200 } else if remaining >= 2 && rng.random_bool(merge_chance) {
3201 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
3202 parent_indices.shuffle(rng);
3203 parent_indices
3204 .into_iter()
3205 .take(2)
3206 .map(|idx| oids[idx])
3207 .collect()
3208 } else {
3209 let parent_idx = rng.random_range(current_idx + 1..num_commits);
3210 smallvec![oids[parent_idx]]
3211 }
3212 } else {
3213 let merge_chance = 0.15;
3214 let skip_chance = 0.1;
3215
3216 if remaining >= 2 && rng.random_bool(merge_chance) {
3217 let first_parent = current_idx + 1;
3218 let second_parent = rng.random_range(current_idx + 2..num_commits);
3219 smallvec![oids[first_parent], oids[second_parent]]
3220 } else if rng.random_bool(skip_chance) && remaining >= 2 {
3221 let skip = rng.random_range(1..remaining.min(3));
3222 smallvec![oids[current_idx + 1 + skip]]
3223 } else {
3224 smallvec![oids[current_idx + 1]]
3225 }
3226 }
3227 }
3228
3229 fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
3230 graph
3231 .commits
3232 .iter()
3233 .enumerate()
3234 .map(|(idx, entry)| (entry.data.sha, idx))
3235 .collect()
3236 }
3237
3238 fn verify_commit_order(
3239 graph: &GraphData,
3240 commits: &[Arc<InitialGraphCommitData>],
3241 ) -> Result<()> {
3242 if graph.commits.len() != commits.len() {
3243 bail!(
3244 "Commit count mismatch: graph has {} commits, expected {}",
3245 graph.commits.len(),
3246 commits.len()
3247 );
3248 }
3249
3250 for (idx, (graph_commit, expected_commit)) in
3251 graph.commits.iter().zip(commits.iter()).enumerate()
3252 {
3253 if graph_commit.data.sha != expected_commit.sha {
3254 bail!(
3255 "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
3256 idx,
3257 graph_commit.data.sha,
3258 expected_commit.sha
3259 );
3260 }
3261 }
3262
3263 Ok(())
3264 }
3265
3266 fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
3267 for line in &graph.lines {
3268 let child_row = *oid_to_row
3269 .get(&line.child)
3270 .context("Line references non-existent child commit")?;
3271
3272 let parent_row = *oid_to_row
3273 .get(&line.parent)
3274 .context("Line references non-existent parent commit")?;
3275
3276 if child_row >= parent_row {
3277 bail!(
3278 "child_row ({}) must be < parent_row ({})",
3279 child_row,
3280 parent_row
3281 );
3282 }
3283
3284 if line.full_interval.start != child_row {
3285 bail!(
3286 "full_interval.start ({}) != child_row ({})",
3287 line.full_interval.start,
3288 child_row
3289 );
3290 }
3291
3292 if line.full_interval.end != parent_row {
3293 bail!(
3294 "full_interval.end ({}) != parent_row ({})",
3295 line.full_interval.end,
3296 parent_row
3297 );
3298 }
3299
3300 if let Some(last_segment) = line.segments.last() {
3301 let segment_end_row = match last_segment {
3302 CommitLineSegment::Straight { to_row } => *to_row,
3303 CommitLineSegment::Curve { on_row, .. } => *on_row,
3304 };
3305
3306 if segment_end_row != line.full_interval.end {
3307 bail!(
3308 "last segment ends at row {} but full_interval.end is {}",
3309 segment_end_row,
3310 line.full_interval.end
3311 );
3312 }
3313 }
3314 }
3315
3316 Ok(())
3317 }
3318
3319 fn verify_column_correctness(
3320 graph: &GraphData,
3321 oid_to_row: &HashMap<Oid, usize>,
3322 ) -> Result<()> {
3323 for line in &graph.lines {
3324 let child_row = *oid_to_row
3325 .get(&line.child)
3326 .context("Line references non-existent child commit")?;
3327
3328 let parent_row = *oid_to_row
3329 .get(&line.parent)
3330 .context("Line references non-existent parent commit")?;
3331
3332 let child_lane = graph.commits[child_row].lane;
3333 if line.child_column != child_lane {
3334 bail!(
3335 "child_column ({}) != child's lane ({})",
3336 line.child_column,
3337 child_lane
3338 );
3339 }
3340
3341 let mut current_column = line.child_column;
3342 for segment in &line.segments {
3343 if let CommitLineSegment::Curve { to_column, .. } = segment {
3344 current_column = *to_column;
3345 }
3346 }
3347
3348 let parent_lane = graph.commits[parent_row].lane;
3349 if current_column != parent_lane {
3350 bail!(
3351 "ending column ({}) != parent's lane ({})",
3352 current_column,
3353 parent_lane
3354 );
3355 }
3356 }
3357
3358 Ok(())
3359 }
3360
3361 fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
3362 for line in &graph.lines {
3363 if line.segments.is_empty() {
3364 bail!("Line has no segments");
3365 }
3366
3367 let mut current_row = line.full_interval.start;
3368
3369 for (idx, segment) in line.segments.iter().enumerate() {
3370 let segment_end_row = match segment {
3371 CommitLineSegment::Straight { to_row } => *to_row,
3372 CommitLineSegment::Curve { on_row, .. } => *on_row,
3373 };
3374
3375 if segment_end_row < current_row {
3376 bail!(
3377 "segment {} ends at row {} which is before current row {}",
3378 idx,
3379 segment_end_row,
3380 current_row
3381 );
3382 }
3383
3384 current_row = segment_end_row;
3385 }
3386 }
3387
3388 Ok(())
3389 }
3390
3391 fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
3392 for line in &graph.lines {
3393 let child_row = line.full_interval.start;
3394
3395 let mut current_column = line.child_column;
3396 let mut current_row = child_row;
3397
3398 for segment in &line.segments {
3399 match segment {
3400 CommitLineSegment::Straight { to_row } => {
3401 for row in (current_row + 1)..*to_row {
3402 if row < graph.commits.len() {
3403 let commit_at_row = &graph.commits[row];
3404 if commit_at_row.lane == current_column {
3405 bail!(
3406 "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
3407 current_row,
3408 to_row,
3409 current_column,
3410 commit_at_row.data.sha,
3411 row
3412 );
3413 }
3414 }
3415 }
3416 current_row = *to_row;
3417 }
3418 CommitLineSegment::Curve {
3419 to_column, on_row, ..
3420 } => {
3421 current_column = *to_column;
3422 current_row = *on_row;
3423 }
3424 }
3425 }
3426 }
3427
3428 Ok(())
3429 }
3430
3431 fn verify_coverage(graph: &GraphData) -> Result<()> {
3432 let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
3433 for entry in &graph.commits {
3434 for parent in &entry.data.parents {
3435 expected_edges.insert((entry.data.sha, *parent));
3436 }
3437 }
3438
3439 let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
3440 for line in &graph.lines {
3441 let edge = (line.child, line.parent);
3442
3443 if !found_edges.insert(edge) {
3444 bail!(
3445 "Duplicate line found for edge {:?} -> {:?}",
3446 line.child,
3447 line.parent
3448 );
3449 }
3450
3451 if !expected_edges.contains(&edge) {
3452 bail!(
3453 "Orphan line found: {:?} -> {:?} is not in the commit graph",
3454 line.child,
3455 line.parent
3456 );
3457 }
3458 }
3459
3460 for (child, parent) in &expected_edges {
3461 if !found_edges.contains(&(*child, *parent)) {
3462 bail!("Missing line for edge {:?} -> {:?}", child, parent);
3463 }
3464 }
3465
3466 assert_eq!(
3467 expected_edges.symmetric_difference(&found_edges).count(),
3468 0,
3469 "The symmetric difference should be zero"
3470 );
3471
3472 Ok(())
3473 }
3474
3475 fn verify_merge_line_optimality(
3476 graph: &GraphData,
3477 oid_to_row: &HashMap<Oid, usize>,
3478 ) -> Result<()> {
3479 for line in &graph.lines {
3480 let first_segment = line.segments.first();
3481 let is_merge_line = matches!(
3482 first_segment,
3483 Some(CommitLineSegment::Curve {
3484 curve_kind: CurveKind::Merge,
3485 ..
3486 })
3487 );
3488
3489 if !is_merge_line {
3490 continue;
3491 }
3492
3493 let child_row = *oid_to_row
3494 .get(&line.child)
3495 .context("Line references non-existent child commit")?;
3496
3497 let parent_row = *oid_to_row
3498 .get(&line.parent)
3499 .context("Line references non-existent parent commit")?;
3500
3501 let parent_lane = graph.commits[parent_row].lane;
3502
3503 let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
3504 continue;
3505 };
3506
3507 let curves_directly_to_parent = *to_column == parent_lane;
3508
3509 if !curves_directly_to_parent {
3510 continue;
3511 }
3512
3513 let curve_row = child_row + 1;
3514 let has_commits_in_path = graph.commits[curve_row..parent_row]
3515 .iter()
3516 .any(|c| c.lane == parent_lane);
3517
3518 if has_commits_in_path {
3519 bail!(
3520 "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
3521 line.child,
3522 line.parent,
3523 parent_lane,
3524 curve_row,
3525 parent_row
3526 );
3527 }
3528
3529 let curve_ends_at_parent = curve_row == parent_row;
3530
3531 if curve_ends_at_parent {
3532 if line.segments.len() != 1 {
3533 bail!(
3534 "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
3535 line.child,
3536 line.parent,
3537 line.segments.len()
3538 );
3539 }
3540 } else {
3541 if line.segments.len() != 2 {
3542 bail!(
3543 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
3544 line.child,
3545 line.parent,
3546 line.segments.len()
3547 );
3548 }
3549
3550 let is_straight_segment = matches!(
3551 line.segments.get(1),
3552 Some(CommitLineSegment::Straight { .. })
3553 );
3554
3555 if !is_straight_segment {
3556 bail!(
3557 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
3558 line.child,
3559 line.parent
3560 );
3561 }
3562 }
3563 }
3564
3565 Ok(())
3566 }
3567
3568 fn verify_all_invariants(
3569 graph: &GraphData,
3570 commits: &[Arc<InitialGraphCommitData>],
3571 ) -> Result<()> {
3572 let oid_to_row = build_oid_to_row_map(graph);
3573
3574 verify_commit_order(graph, commits).context("commit order")?;
3575 verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
3576 verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
3577 verify_segment_continuity(graph).context("segment continuity")?;
3578 verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
3579 verify_coverage(graph).context("coverage")?;
3580 verify_line_overlaps(graph).context("line overlaps")?;
3581 Ok(())
3582 }
3583
3584 #[test]
3585 fn test_git_graph_merge_commits() {
3586 let mut rng = StdRng::seed_from_u64(42);
3587
3588 let oid1 = Oid::random(&mut rng);
3589 let oid2 = Oid::random(&mut rng);
3590 let oid3 = Oid::random(&mut rng);
3591 let oid4 = Oid::random(&mut rng);
3592
3593 let commits = vec![
3594 Arc::new(InitialGraphCommitData {
3595 sha: oid1,
3596 parents: smallvec![oid2, oid3],
3597 ref_names: vec!["HEAD".into()],
3598 }),
3599 Arc::new(InitialGraphCommitData {
3600 sha: oid2,
3601 parents: smallvec![oid4],
3602 ref_names: vec![],
3603 }),
3604 Arc::new(InitialGraphCommitData {
3605 sha: oid3,
3606 parents: smallvec![oid4],
3607 ref_names: vec![],
3608 }),
3609 Arc::new(InitialGraphCommitData {
3610 sha: oid4,
3611 parents: smallvec![],
3612 ref_names: vec![],
3613 }),
3614 ];
3615
3616 let mut graph_data = GraphData::new(8);
3617 graph_data.add_commits(&commits);
3618
3619 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3620 panic!("Graph invariant violation for merge commits:\n{}", error);
3621 }
3622 }
3623
3624 #[test]
3625 fn test_git_graph_linear_commits() {
3626 let mut rng = StdRng::seed_from_u64(42);
3627
3628 let oid1 = Oid::random(&mut rng);
3629 let oid2 = Oid::random(&mut rng);
3630 let oid3 = Oid::random(&mut rng);
3631
3632 let commits = vec![
3633 Arc::new(InitialGraphCommitData {
3634 sha: oid1,
3635 parents: smallvec![oid2],
3636 ref_names: vec!["HEAD".into()],
3637 }),
3638 Arc::new(InitialGraphCommitData {
3639 sha: oid2,
3640 parents: smallvec![oid3],
3641 ref_names: vec![],
3642 }),
3643 Arc::new(InitialGraphCommitData {
3644 sha: oid3,
3645 parents: smallvec![],
3646 ref_names: vec![],
3647 }),
3648 ];
3649
3650 let mut graph_data = GraphData::new(8);
3651 graph_data.add_commits(&commits);
3652
3653 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3654 panic!("Graph invariant violation for linear commits:\n{}", error);
3655 }
3656 }
3657
3658 #[test]
3659 fn test_git_graph_random_commits() {
3660 for seed in 0..100 {
3661 let mut rng = StdRng::seed_from_u64(seed);
3662
3663 let adversarial = rng.random_bool(0.2);
3664 let num_commits = if adversarial {
3665 rng.random_range(10..100)
3666 } else {
3667 rng.random_range(5..50)
3668 };
3669
3670 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3671
3672 assert_eq!(
3673 num_commits,
3674 commits.len(),
3675 "seed={}: Generate random commit dag didn't generate the correct amount of commits",
3676 seed
3677 );
3678
3679 let mut graph_data = GraphData::new(8);
3680 graph_data.add_commits(&commits);
3681
3682 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3683 panic!(
3684 "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
3685 seed, adversarial, num_commits, error
3686 );
3687 }
3688 }
3689 }
3690
3691 // The full integration test has less iterations because it's significantly slower
3692 // than the random commit test
3693 #[gpui::test(iterations = 10)]
3694 async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
3695 init_test(cx);
3696
3697 let adversarial = rng.random_bool(0.2);
3698 let num_commits = if adversarial {
3699 rng.random_range(10..100)
3700 } else {
3701 rng.random_range(5..50)
3702 };
3703
3704 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3705
3706 let fs = FakeFs::new(cx.executor());
3707 fs.insert_tree(
3708 Path::new("/project"),
3709 json!({
3710 ".git": {},
3711 "file.txt": "content",
3712 }),
3713 )
3714 .await;
3715
3716 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3717
3718 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3719 cx.run_until_parked();
3720
3721 let repository = project.read_with(cx, |project, cx| {
3722 project
3723 .active_repository(cx)
3724 .expect("should have a repository")
3725 });
3726
3727 repository.update(cx, |repo, cx| {
3728 repo.graph_data(
3729 crate::LogSource::default(),
3730 crate::LogOrder::default(),
3731 0..usize::MAX,
3732 cx,
3733 );
3734 });
3735 cx.run_until_parked();
3736
3737 let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
3738 repo.graph_data(
3739 crate::LogSource::default(),
3740 crate::LogOrder::default(),
3741 0..usize::MAX,
3742 cx,
3743 )
3744 .commits
3745 .to_vec()
3746 });
3747
3748 let mut graph_data = GraphData::new(8);
3749 graph_data.add_commits(&graph_commits);
3750
3751 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3752 panic!(
3753 "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
3754 adversarial, num_commits, error
3755 );
3756 }
3757 }
3758
3759 #[gpui::test]
3760 async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
3761 init_test(cx);
3762
3763 let fs = FakeFs::new(cx.executor());
3764 fs.insert_tree(
3765 Path::new("/project"),
3766 json!({
3767 ".git": {},
3768 "file.txt": "content",
3769 }),
3770 )
3771 .await;
3772
3773 let mut rng = StdRng::seed_from_u64(42);
3774 let commits = generate_random_commit_dag(&mut rng, 10, false);
3775 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3776
3777 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3778 let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
3779 project.update(cx, |project, cx| {
3780 let observed_repository_events = observed_repository_events.clone();
3781 cx.subscribe(project.git_store(), move |_, _, event, _| {
3782 if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
3783 observed_repository_events
3784 .lock()
3785 .expect("repository event mutex should be available")
3786 .push(repository_event.clone());
3787 }
3788 })
3789 .detach();
3790 });
3791
3792 let repository = project.read_with(cx, |project, cx| {
3793 project
3794 .active_repository(cx)
3795 .expect("should have a repository")
3796 });
3797
3798 repository.update(cx, |repo, cx| {
3799 repo.graph_data(
3800 crate::LogSource::default(),
3801 crate::LogOrder::default(),
3802 0..usize::MAX,
3803 cx,
3804 );
3805 });
3806
3807 project
3808 .update(cx, |project, cx| project.git_scans_complete(cx))
3809 .await;
3810 cx.run_until_parked();
3811
3812 let observed_repository_events = observed_repository_events
3813 .lock()
3814 .expect("repository event mutex should be available");
3815 assert!(
3816 observed_repository_events
3817 .iter()
3818 .any(|event| matches!(event, RepositoryEvent::HeadChanged)),
3819 "initial repository scan should emit HeadChanged"
3820 );
3821 let commit_count_after = repository.read_with(cx, |repo, _| {
3822 repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
3823 .map(|data| data.commit_data.len())
3824 .unwrap()
3825 });
3826 assert_eq!(
3827 commits.len(),
3828 commit_count_after,
3829 "initial_graph_data should remain populated after events emitted by initial repository scan"
3830 );
3831 }
3832
3833 #[gpui::test]
3834 async fn test_initial_graph_data_propagates_error(cx: &mut TestAppContext) {
3835 init_test(cx);
3836
3837 let fs = FakeFs::new(cx.executor());
3838 fs.insert_tree(
3839 Path::new("/project"),
3840 json!({
3841 ".git": {},
3842 "file.txt": "content",
3843 }),
3844 )
3845 .await;
3846
3847 fs.set_graph_error(
3848 Path::new("/project/.git"),
3849 Some("fatal: bad default revision 'HEAD'".to_string()),
3850 );
3851
3852 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3853
3854 let repository = project.read_with(cx, |project, cx| {
3855 project
3856 .active_repository(cx)
3857 .expect("should have a repository")
3858 });
3859
3860 repository.update(cx, |repo, cx| {
3861 repo.graph_data(
3862 crate::LogSource::default(),
3863 crate::LogOrder::default(),
3864 0..usize::MAX,
3865 cx,
3866 );
3867 });
3868
3869 cx.run_until_parked();
3870
3871 let error = repository.read_with(cx, |repo, _| {
3872 repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
3873 .and_then(|data| data.error.clone())
3874 });
3875
3876 assert!(
3877 error.is_some(),
3878 "graph data should contain an error after initial_graph_data fails"
3879 );
3880 let error_message = error.unwrap();
3881 assert!(
3882 error_message.contains("bad default revision"),
3883 "error should contain the git error message, got: {}",
3884 error_message
3885 );
3886 }
3887
3888 #[gpui::test]
3889 async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
3890 init_test(cx);
3891
3892 let fs = FakeFs::new(cx.executor());
3893 fs.insert_tree(
3894 Path::new("/project_a"),
3895 json!({
3896 ".git": {},
3897 "file.txt": "content",
3898 }),
3899 )
3900 .await;
3901 fs.insert_tree(
3902 Path::new("/project_b"),
3903 json!({
3904 ".git": {},
3905 "other.txt": "content",
3906 }),
3907 )
3908 .await;
3909
3910 let mut rng = StdRng::seed_from_u64(42);
3911 let commits = generate_random_commit_dag(&mut rng, 10, false);
3912 fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
3913
3914 let project = Project::test(
3915 fs.clone(),
3916 [Path::new("/project_a"), Path::new("/project_b")],
3917 cx,
3918 )
3919 .await;
3920 cx.run_until_parked();
3921
3922 let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
3923 let mut first_repository = None;
3924 let mut second_repository = None;
3925
3926 for repository in project.repositories(cx).values() {
3927 let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
3928 if work_directory_abs_path.as_ref() == Path::new("/project_a") {
3929 first_repository = Some(repository.clone());
3930 } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
3931 second_repository = Some(repository.clone());
3932 }
3933 }
3934
3935 (
3936 first_repository.expect("should have repository for /project_a"),
3937 second_repository.expect("should have repository for /project_b"),
3938 )
3939 });
3940 first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
3941 cx.run_until_parked();
3942
3943 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
3944 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
3945 });
3946
3947 let workspace_weak =
3948 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
3949 let git_graph = cx.new_window_entity(|window, cx| {
3950 GitGraph::new(
3951 first_repository.read(cx).id,
3952 project.read(cx).git_store().clone(),
3953 workspace_weak,
3954 window,
3955 cx,
3956 )
3957 });
3958 cx.run_until_parked();
3959
3960 // Verify initial graph data is loaded
3961 let initial_commit_count =
3962 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3963 assert!(
3964 initial_commit_count > 0,
3965 "graph data should have been loaded, got 0 commits"
3966 );
3967
3968 git_graph.update(cx, |graph, cx| {
3969 graph.set_repo_id(second_repository.read(cx).id, cx)
3970 });
3971 cx.run_until_parked();
3972
3973 let commit_count_after_clear =
3974 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3975 assert_eq!(
3976 commit_count_after_clear, 0,
3977 "graph_data should be cleared after switching away"
3978 );
3979
3980 git_graph.update(cx, |graph, cx| {
3981 graph.set_repo_id(first_repository.read(cx).id, cx)
3982 });
3983 cx.run_until_parked();
3984
3985 cx.draw(
3986 point(px(0.), px(0.)),
3987 gpui::size(px(1200.), px(800.)),
3988 |_, _| git_graph.clone().into_any_element(),
3989 );
3990 cx.run_until_parked();
3991
3992 // Verify graph data is reloaded from repository cache on switch back
3993 let reloaded_commit_count =
3994 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3995 assert_eq!(
3996 reloaded_commit_count,
3997 commits.len(),
3998 "graph data should be reloaded after switching back"
3999 );
4000 }
4001
4002 #[gpui::test]
4003 async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) {
4004 init_test(cx);
4005
4006 let fs = FakeFs::new(cx.executor());
4007 fs.insert_tree(
4008 Path::new("/project"),
4009 json!({
4010 ".git": {},
4011 "file.txt": "content",
4012 }),
4013 )
4014 .await;
4015
4016 let initial_head = Oid::from_bytes(&[1; 20]).unwrap();
4017 let initial_stash = Oid::from_bytes(&[2; 20]).unwrap();
4018 let updated_head = Oid::from_bytes(&[3; 20]).unwrap();
4019 let updated_stash = Oid::from_bytes(&[4; 20]).unwrap();
4020
4021 fs.set_graph_commits(
4022 Path::new("/project/.git"),
4023 vec![
4024 Arc::new(InitialGraphCommitData {
4025 sha: initial_head,
4026 parents: smallvec![initial_stash],
4027 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4028 }),
4029 Arc::new(InitialGraphCommitData {
4030 sha: initial_stash,
4031 parents: smallvec![],
4032 ref_names: vec!["refs/stash".into()],
4033 }),
4034 ],
4035 );
4036 fs.with_git_state(Path::new("/project/.git"), true, |state| {
4037 state.stash_entries = git::stash::GitStash {
4038 entries: vec![git::stash::StashEntry {
4039 index: 0,
4040 oid: initial_stash,
4041 message: "initial stash".to_string(),
4042 branch: Some("main".to_string()),
4043 timestamp: 1,
4044 }]
4045 .into(),
4046 };
4047 })
4048 .unwrap();
4049
4050 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4051 cx.run_until_parked();
4052
4053 let repository = project.read_with(cx, |project, cx| {
4054 project
4055 .active_repository(cx)
4056 .expect("should have a repository")
4057 });
4058
4059 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4060 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4061 });
4062 let workspace_weak =
4063 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4064 let git_graph = cx.new_window_entity(|window, cx| {
4065 GitGraph::new(
4066 repository.read(cx).id,
4067 project.read(cx).git_store().clone(),
4068 workspace_weak,
4069 window,
4070 cx,
4071 )
4072 });
4073 cx.run_until_parked();
4074
4075 let initial_shas = git_graph.read_with(&*cx, |graph, _| {
4076 graph
4077 .graph_data
4078 .commits
4079 .iter()
4080 .map(|commit| commit.data.sha)
4081 .collect::<Vec<_>>()
4082 });
4083 assert_eq!(initial_shas, vec![initial_head, initial_stash]);
4084
4085 fs.set_graph_commits(
4086 Path::new("/project/.git"),
4087 vec![
4088 Arc::new(InitialGraphCommitData {
4089 sha: updated_head,
4090 parents: smallvec![updated_stash],
4091 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4092 }),
4093 Arc::new(InitialGraphCommitData {
4094 sha: updated_stash,
4095 parents: smallvec![],
4096 ref_names: vec!["refs/stash".into()],
4097 }),
4098 ],
4099 );
4100 fs.with_git_state(Path::new("/project/.git"), true, |state| {
4101 state.stash_entries = git::stash::GitStash {
4102 entries: vec![git::stash::StashEntry {
4103 index: 0,
4104 oid: updated_stash,
4105 message: "updated stash".to_string(),
4106 branch: Some("main".to_string()),
4107 timestamp: 1,
4108 }]
4109 .into(),
4110 };
4111 })
4112 .unwrap();
4113
4114 project
4115 .update(cx, |project, cx| project.git_scans_complete(cx))
4116 .await;
4117 cx.run_until_parked();
4118
4119 cx.draw(
4120 point(px(0.), px(0.)),
4121 gpui::size(px(1200.), px(800.)),
4122 |_, _| git_graph.clone().into_any_element(),
4123 );
4124 cx.run_until_parked();
4125
4126 let reloaded_shas = git_graph.read_with(&*cx, |graph, _| {
4127 graph
4128 .graph_data
4129 .commits
4130 .iter()
4131 .map(|commit| commit.data.sha)
4132 .collect::<Vec<_>>()
4133 });
4134 assert_eq!(reloaded_shas, vec![updated_head, updated_stash]);
4135 }
4136
4137 #[gpui::test]
4138 async fn test_git_graph_row_at_position_rounding(cx: &mut TestAppContext) {
4139 init_test(cx);
4140
4141 let fs = FakeFs::new(cx.executor());
4142 fs.insert_tree(
4143 Path::new("/project"),
4144 serde_json::json!({
4145 ".git": {},
4146 "file.txt": "content",
4147 }),
4148 )
4149 .await;
4150
4151 let mut rng = StdRng::seed_from_u64(42);
4152 let commits = generate_random_commit_dag(&mut rng, 10, false);
4153 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
4154
4155 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4156 cx.run_until_parked();
4157
4158 let repository = project.read_with(cx, |project, cx| {
4159 project
4160 .active_repository(cx)
4161 .expect("should have a repository")
4162 });
4163
4164 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4165 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4166 });
4167
4168 let workspace_weak =
4169 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4170
4171 let git_graph = cx.new_window_entity(|window, cx| {
4172 GitGraph::new(
4173 repository.read(cx).id,
4174 project.read(cx).git_store().clone(),
4175 workspace_weak,
4176 window,
4177 cx,
4178 )
4179 });
4180 cx.run_until_parked();
4181
4182 git_graph.update_in(cx, |graph, window, cx| {
4183 assert!(
4184 graph.graph_data.commits.len() >= 10,
4185 "graph should load dummy commits"
4186 );
4187
4188 let row_height = GitGraph::row_height(window, cx);
4189 let origin_y = px(100.0);
4190 graph.graph_canvas_bounds.set(Some(Bounds {
4191 origin: point(px(0.0), origin_y),
4192 size: gpui::size(px(100.0), row_height * 50.0),
4193 }));
4194
4195 // Scroll down by half a row so the row under a position near the
4196 // top of the canvas is row 1 rather than row 0.
4197 let scroll_offset = row_height * 0.75;
4198 graph.table_interaction_state.update(cx, |state, _| {
4199 state.set_scroll_offset(point(px(0.0), -scroll_offset))
4200 });
4201 let pos_y = origin_y + row_height * 0.5;
4202 let absolute_calc_row = graph.row_at_position(pos_y, window, cx);
4203
4204 assert_eq!(
4205 absolute_calc_row,
4206 Some(1),
4207 "Row calculation should yield absolute row exactly"
4208 );
4209 });
4210 }
4211
4212 #[gpui::test]
4213 async fn test_row_height_matches_uniform_list_item_height(cx: &mut TestAppContext) {
4214 init_test(cx);
4215
4216 cx.update(|cx| {
4217 SettingsStore::update_global(cx, |store, cx| {
4218 store.update_user_settings(cx, |settings| {
4219 *settings.theme = ThemeSettingsContent {
4220 ui_font_size: Some(12.7.into()),
4221 ..Default::default()
4222 }
4223 });
4224 })
4225 });
4226
4227 let fs = FakeFs::new(cx.executor());
4228 fs.insert_tree(
4229 Path::new("/project"),
4230 serde_json::json!({
4231 ".git": {},
4232 "file.txt": "content",
4233 }),
4234 )
4235 .await;
4236
4237 let mut rng = StdRng::seed_from_u64(99);
4238 let commits = generate_random_commit_dag(&mut rng, 20, false);
4239 fs.set_graph_commits(Path::new("/project/.git"), commits);
4240
4241 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4242 cx.run_until_parked();
4243
4244 let repository = project.read_with(cx, |project, cx| {
4245 project
4246 .active_repository(cx)
4247 .expect("should have a repository")
4248 });
4249
4250 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4251 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4252 });
4253
4254 let workspace_weak =
4255 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4256
4257 let git_graph = cx.new_window_entity(|window, cx| {
4258 GitGraph::new(
4259 repository.read(cx).id,
4260 project.read(cx).git_store().clone(),
4261 workspace_weak,
4262 window,
4263 cx,
4264 )
4265 });
4266 cx.run_until_parked();
4267
4268 cx.draw(
4269 point(px(0.), px(0.)),
4270 gpui::size(px(1200.), px(800.)),
4271 |_, _| git_graph.clone().into_any_element(),
4272 );
4273 cx.run_until_parked();
4274
4275 git_graph.update_in(cx, |graph, window, cx| {
4276 let commit_count = graph.graph_data.commits.len();
4277 assert!(
4278 commit_count > 0,
4279 "need at least one commit to measure item height"
4280 );
4281
4282 let table_state = graph.table_interaction_state.read(cx);
4283 let item_size = table_state.scroll_handle.0.borrow().last_item_size.expect(
4284 "uniform_list should have populated last_item_size after draw(); \
4285 the table has not been laid out",
4286 );
4287
4288 let measured_item_height = item_size.contents.height / commit_count as f32;
4289 let computed_row_height = GitGraph::row_height(window, cx);
4290
4291 assert_eq!(
4292 computed_row_height, measured_item_height,
4293 "GitGraph::row_height ({}) must exactly match the height that \
4294 uniform_list measured for each table row ({}). \
4295 A mismatch means the canvas and table rows will drift when scrolling.",
4296 computed_row_height, measured_item_height,
4297 );
4298 });
4299 }
4300}