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