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.emit(ItemEvent::Edit);
1001 cx.notify();
1002 }
1003
1004 fn row_height(cx: &App) -> Pixels {
1005 let settings = ThemeSettings::get_global(cx);
1006 let font_size = settings.buffer_font_size(cx);
1007 font_size + px(12.0)
1008 }
1009
1010 fn graph_canvas_content_width(&self) -> Pixels {
1011 (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0
1012 }
1013
1014 fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] {
1015 // todo(git_graph): We should make a column/table api that allows removing table columns
1016 let fractions = self
1017 .column_widths
1018 .read(cx)
1019 .preview_fractions(window.rem_size());
1020
1021 let is_file_history = matches!(self.log_source, LogSource::File(_));
1022 let graph_fraction = if is_file_history { 0.0 } else { fractions[0] };
1023 let offset = if is_file_history { 0 } else { 1 };
1024
1025 [
1026 graph_fraction,
1027 fractions[offset],
1028 fractions[offset + 1],
1029 fractions[offset + 2],
1030 fractions[offset + 3],
1031 ]
1032 }
1033
1034 fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig {
1035 let [_, description, date, author, commit] = self.preview_column_fractions(window, cx);
1036 let table_total = description + date + author + commit;
1037
1038 let widths = if table_total > 0.0 {
1039 vec![
1040 DefiniteLength::Fraction(description / table_total),
1041 DefiniteLength::Fraction(date / table_total),
1042 DefiniteLength::Fraction(author / table_total),
1043 DefiniteLength::Fraction(commit / table_total),
1044 ]
1045 } else {
1046 vec![
1047 DefiniteLength::Fraction(0.25),
1048 DefiniteLength::Fraction(0.25),
1049 DefiniteLength::Fraction(0.25),
1050 DefiniteLength::Fraction(0.25),
1051 ]
1052 };
1053
1054 ColumnWidthConfig::explicit(widths)
1055 }
1056
1057 fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels {
1058 self.column_widths
1059 .read(cx)
1060 .preview_column_width(0, window)
1061 .unwrap_or_else(|| self.graph_canvas_content_width())
1062 }
1063
1064 pub fn new(
1065 repo_id: RepositoryId,
1066 git_store: Entity<GitStore>,
1067 workspace: WeakEntity<Workspace>,
1068 log_source: Option<LogSource>,
1069 window: &mut Window,
1070 cx: &mut Context<Self>,
1071 ) -> Self {
1072 let focus_handle = cx.focus_handle();
1073 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
1074 .detach();
1075
1076 let accent_colors = cx.theme().accents();
1077 let graph = GraphData::new(accent_colors_count(accent_colors));
1078 let log_source = log_source.unwrap_or_default();
1079 let log_order = LogOrder::default();
1080
1081 cx.subscribe(&git_store, |this, _, event, cx| match event {
1082 GitStoreEvent::RepositoryUpdated(updated_repo_id, repo_event, _) => {
1083 if this.repo_id == *updated_repo_id {
1084 if let Some(repository) = this.get_repository(cx) {
1085 this.on_repository_event(repository, repo_event, cx);
1086 }
1087 }
1088 }
1089 _ => {}
1090 })
1091 .detach();
1092
1093 let search_editor = cx.new(|cx| {
1094 let mut editor = Editor::single_line(window, cx);
1095 editor.set_placeholder_text("Search commits…", window, cx);
1096 editor
1097 });
1098
1099 let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
1100
1101 let column_widths = if matches!(log_source, LogSource::File(_)) {
1102 cx.new(|_cx| {
1103 RedistributableColumnsState::new(
1104 4,
1105 vec![
1106 DefiniteLength::Fraction(0.6192),
1107 DefiniteLength::Fraction(0.1032),
1108 DefiniteLength::Fraction(0.086),
1109 DefiniteLength::Fraction(0.0516),
1110 ],
1111 vec![
1112 TableResizeBehavior::Resizable,
1113 TableResizeBehavior::Resizable,
1114 TableResizeBehavior::Resizable,
1115 TableResizeBehavior::Resizable,
1116 ],
1117 )
1118 })
1119 } else {
1120 cx.new(|_cx| {
1121 RedistributableColumnsState::new(
1122 5,
1123 vec![
1124 DefiniteLength::Fraction(0.14),
1125 DefiniteLength::Fraction(0.6192),
1126 DefiniteLength::Fraction(0.1032),
1127 DefiniteLength::Fraction(0.086),
1128 DefiniteLength::Fraction(0.0516),
1129 ],
1130 vec![
1131 TableResizeBehavior::Resizable,
1132 TableResizeBehavior::Resizable,
1133 TableResizeBehavior::Resizable,
1134 TableResizeBehavior::Resizable,
1135 TableResizeBehavior::Resizable,
1136 ],
1137 )
1138 })
1139 };
1140 let mut row_height = Self::row_height(cx);
1141
1142 cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
1143 let new_row_height = Self::row_height(cx);
1144 if new_row_height != row_height {
1145 this.row_height = new_row_height;
1146 this.table_interaction_state.update(cx, |state, _cx| {
1147 state.scroll_handle.0.borrow_mut().last_item_size = None;
1148 });
1149 row_height = new_row_height;
1150 cx.notify();
1151 }
1152 })
1153 .detach();
1154
1155 let mut this = GitGraph {
1156 focus_handle,
1157 git_store,
1158 search_state: SearchState {
1159 case_sensitive: false,
1160 editor: search_editor,
1161 matches: IndexSet::default(),
1162 selected_index: None,
1163 state: QueryState::Empty,
1164 },
1165 workspace,
1166 graph_data: graph,
1167 _commit_diff_task: None,
1168 context_menu: None,
1169 row_height,
1170 table_interaction_state,
1171 column_widths,
1172 selected_entry_idx: None,
1173 hovered_entry_idx: None,
1174 graph_canvas_bounds: Rc::new(Cell::new(None)),
1175 selected_commit_diff: None,
1176 selected_commit_diff_stats: None,
1177 log_source,
1178 log_order,
1179 commit_details_split_state: cx.new(|_cx| SplitState::new()),
1180 repo_id,
1181 changed_files_scroll_handle: UniformListScrollHandle::new(),
1182 pending_select_sha: None,
1183 };
1184
1185 this.fetch_initial_graph_data(cx);
1186 this
1187 }
1188
1189 fn on_repository_event(
1190 &mut self,
1191 repository: Entity<Repository>,
1192 event: &RepositoryEvent,
1193 cx: &mut Context<Self>,
1194 ) {
1195 match event {
1196 RepositoryEvent::GraphEvent((source, order), event)
1197 if source == &self.log_source && order == &self.log_order =>
1198 {
1199 match event {
1200 GitGraphEvent::FullyLoaded => {
1201 if let Some(pending_sha_index) =
1202 self.pending_select_sha.take().and_then(|oid| {
1203 repository
1204 .read(cx)
1205 .get_graph_data(source.clone(), *order)
1206 .and_then(|data| data.commit_oid_to_index.get(&oid).copied())
1207 })
1208 {
1209 self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx);
1210 }
1211 }
1212 GitGraphEvent::LoadingError => {
1213 // todo(git_graph): Wire this up with the UI
1214 }
1215 GitGraphEvent::CountUpdated(commit_count) => {
1216 let old_count = self.graph_data.commits.len();
1217
1218 if let Some(pending_selection_index) =
1219 repository.update(cx, |repository, cx| {
1220 let GraphDataResponse {
1221 commits,
1222 is_loading,
1223 error: _,
1224 } = repository.graph_data(
1225 source.clone(),
1226 *order,
1227 old_count..*commit_count,
1228 cx,
1229 );
1230 self.graph_data.add_commits(commits);
1231
1232 let pending_sha_index = self.pending_select_sha.and_then(|oid| {
1233 repository.get_graph_data(source.clone(), *order).and_then(
1234 |data| data.commit_oid_to_index.get(&oid).copied(),
1235 )
1236 });
1237
1238 if !is_loading && pending_sha_index.is_none() {
1239 self.pending_select_sha.take();
1240 }
1241
1242 pending_sha_index
1243 })
1244 {
1245 self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx);
1246 self.pending_select_sha.take();
1247 }
1248
1249 cx.notify();
1250 }
1251 }
1252 }
1253 RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => {
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.pending_select_sha = None;
1259 self.invalidate_state(cx);
1260 }
1261 }
1262 RepositoryEvent::StashEntriesChanged if self.log_source == LogSource::All => {
1263 // Stash entries initial's scan id is 2, so we don't want to invalidate the graph before that
1264 if repository.read(cx).scan_id > 2 {
1265 self.pending_select_sha = None;
1266 self.invalidate_state(cx);
1267 }
1268 }
1269 RepositoryEvent::GraphEvent(_, _) => {}
1270 _ => {}
1271 }
1272 }
1273
1274 fn fetch_initial_graph_data(&mut self, cx: &mut App) {
1275 if let Some(repository) = self.get_repository(cx) {
1276 repository.update(cx, |repository, cx| {
1277 let commits = repository
1278 .graph_data(self.log_source.clone(), self.log_order, 0..usize::MAX, cx)
1279 .commits;
1280 self.graph_data.add_commits(commits);
1281 });
1282 }
1283 }
1284
1285 fn get_repository(&self, cx: &App) -> Option<Entity<Repository>> {
1286 let git_store = self.git_store.read(cx);
1287 git_store.repositories().get(&self.repo_id).cloned()
1288 }
1289
1290 fn render_chip(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
1291 Chip::new(name.clone())
1292 .label_size(LabelSize::Small)
1293 .bg_color(accent_color.opacity(0.1))
1294 .border_color(accent_color.opacity(0.5))
1295 }
1296
1297 fn render_table_rows(
1298 &mut self,
1299 range: Range<usize>,
1300 _window: &mut Window,
1301 cx: &mut Context<Self>,
1302 ) -> Vec<Vec<AnyElement>> {
1303 let repository = self.get_repository(cx);
1304
1305 let row_height = self.row_height;
1306
1307 // We fetch data outside the visible viewport to avoid loading entries when
1308 // users scroll through the git graph
1309 if let Some(repository) = repository.as_ref() {
1310 const FETCH_RANGE: usize = 100;
1311 repository.update(cx, |repository, cx| {
1312 self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
1313 ..(range.end + FETCH_RANGE)
1314 .min(self.graph_data.commits.len().saturating_sub(1))]
1315 .iter()
1316 .for_each(|commit| {
1317 repository.fetch_commit_data(commit.data.sha, cx);
1318 });
1319 });
1320 }
1321
1322 range
1323 .map(|idx| {
1324 let Some((commit, repository)) =
1325 self.graph_data.commits.get(idx).zip(repository.as_ref())
1326 else {
1327 return vec![
1328 div().h(row_height).into_any_element(),
1329 div().h(row_height).into_any_element(),
1330 div().h(row_height).into_any_element(),
1331 div().h(row_height).into_any_element(),
1332 ];
1333 };
1334
1335 let data = repository.update(cx, |repository, cx| {
1336 repository.fetch_commit_data(commit.data.sha, cx).clone()
1337 });
1338
1339 let short_sha = commit.data.sha.display_short();
1340 let mut formatted_time = String::new();
1341 let subject: SharedString;
1342 let author_name: SharedString;
1343
1344 if let CommitDataState::Loaded(data) = data {
1345 subject = data.subject.clone();
1346 author_name = data.author_name.clone();
1347 formatted_time = format_timestamp(data.commit_timestamp);
1348 } else {
1349 subject = "Loading…".into();
1350 author_name = "".into();
1351 }
1352
1353 let accent_colors = cx.theme().accents();
1354 let accent_color = accent_colors
1355 .0
1356 .get(commit.color_idx)
1357 .copied()
1358 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1359
1360 let is_selected = self.selected_entry_idx == Some(idx);
1361 let is_matched = self.search_state.matches.contains(&commit.data.sha);
1362 let column_label = |label: SharedString| {
1363 Label::new(label)
1364 .when(!is_selected, |c| c.color(Color::Muted))
1365 .truncate()
1366 .into_any_element()
1367 };
1368
1369 let subject_label = if is_matched {
1370 let query = match &self.search_state.state {
1371 QueryState::Confirmed((query, _)) => Some(query.clone()),
1372 _ => None,
1373 };
1374 let highlight_ranges = query
1375 .and_then(|q| {
1376 let ranges = if self.search_state.case_sensitive {
1377 subject
1378 .match_indices(q.as_str())
1379 .map(|(start, matched)| start..start + matched.len())
1380 .collect::<Vec<_>>()
1381 } else {
1382 let q = q.to_lowercase();
1383 let subject_lower = subject.to_lowercase();
1384
1385 subject_lower
1386 .match_indices(&q)
1387 .filter_map(|(start, matched)| {
1388 let end = start + matched.len();
1389 subject.is_char_boundary(start).then_some(()).and_then(
1390 |_| subject.is_char_boundary(end).then_some(start..end),
1391 )
1392 })
1393 .collect::<Vec<_>>()
1394 };
1395
1396 (!ranges.is_empty()).then_some(ranges)
1397 })
1398 .unwrap_or_default();
1399 HighlightedLabel::from_ranges(subject.clone(), highlight_ranges)
1400 .when(!is_selected, |c| c.color(Color::Muted))
1401 .truncate()
1402 .into_any_element()
1403 } else {
1404 column_label(subject.clone())
1405 };
1406
1407 vec![
1408 div()
1409 .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1410 .overflow_hidden()
1411 .tooltip(Tooltip::text(subject))
1412 .child(
1413 h_flex()
1414 .gap_2()
1415 .overflow_hidden()
1416 .children((!commit.data.ref_names.is_empty()).then(|| {
1417 h_flex().gap_1().children(
1418 commit
1419 .data
1420 .ref_names
1421 .iter()
1422 .map(|name| self.render_chip(name, accent_color)),
1423 )
1424 }))
1425 .child(subject_label),
1426 )
1427 .into_any_element(),
1428 column_label(formatted_time.into()),
1429 column_label(author_name),
1430 column_label(short_sha.into()),
1431 ]
1432 })
1433 .collect()
1434 }
1435
1436 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1437 self.selected_entry_idx = None;
1438 self.selected_commit_diff = None;
1439 self.selected_commit_diff_stats = None;
1440 cx.emit(ItemEvent::Edit);
1441 cx.notify();
1442 }
1443
1444 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1445 self.select_entry(0, ScrollStrategy::Nearest, cx);
1446 }
1447
1448 fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1449 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1450 self.select_entry(
1451 selected_entry_idx.saturating_sub(1),
1452 ScrollStrategy::Nearest,
1453 cx,
1454 );
1455 } else {
1456 self.select_first(&SelectFirst, window, cx);
1457 }
1458 }
1459
1460 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1461 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1462 self.select_entry(
1463 selected_entry_idx
1464 .saturating_add(1)
1465 .min(self.graph_data.commits.len().saturating_sub(1)),
1466 ScrollStrategy::Nearest,
1467 cx,
1468 );
1469 } else {
1470 self.select_prev(&SelectPrevious, window, cx);
1471 }
1472 }
1473
1474 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1475 self.select_entry(
1476 self.graph_data.commits.len().saturating_sub(1),
1477 ScrollStrategy::Nearest,
1478 cx,
1479 );
1480 }
1481
1482 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1483 self.open_selected_commit_view(window, cx);
1484 }
1485
1486 fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
1487 let Some(repo) = self.get_repository(cx) else {
1488 return;
1489 };
1490
1491 self.search_state.matches.clear();
1492 self.search_state.selected_index = None;
1493 self.search_state.editor.update(cx, |editor, _cx| {
1494 editor.set_text_style_refinement(Default::default());
1495 });
1496
1497 if query.as_str().is_empty() {
1498 self.search_state.state = QueryState::Empty;
1499 cx.notify();
1500 return;
1501 }
1502
1503 let (request_tx, request_rx) = smol::channel::unbounded::<Oid>();
1504
1505 repo.update(cx, |repo, cx| {
1506 repo.search_commits(
1507 self.log_source.clone(),
1508 SearchCommitArgs {
1509 query: query.clone(),
1510 case_sensitive: self.search_state.case_sensitive,
1511 },
1512 request_tx,
1513 cx,
1514 );
1515 });
1516
1517 let search_task = cx.spawn(async move |this, cx| {
1518 while let Ok(first_oid) = request_rx.recv().await {
1519 let mut pending_oids = vec![first_oid];
1520 while let Ok(oid) = request_rx.try_recv() {
1521 pending_oids.push(oid);
1522 }
1523
1524 this.update(cx, |this, cx| {
1525 if this.search_state.selected_index.is_none() {
1526 this.search_state.selected_index = Some(0);
1527 this.select_commit_by_sha(first_oid, cx);
1528 }
1529
1530 this.search_state.matches.extend(pending_oids);
1531 cx.notify();
1532 })
1533 .ok();
1534 }
1535
1536 this.update(cx, |this, cx| {
1537 if this.search_state.matches.is_empty() {
1538 this.search_state.editor.update(cx, |editor, cx| {
1539 editor.set_text_style_refinement(TextStyleRefinement {
1540 color: Some(Color::Error.color(cx)),
1541 ..Default::default()
1542 });
1543 });
1544 }
1545 })
1546 .ok();
1547 });
1548
1549 self.search_state.state = QueryState::Confirmed((query, search_task));
1550 cx.emit(ItemEvent::Edit);
1551 }
1552
1553 fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
1554 let query = self.search_state.editor.read(cx).text(cx).into();
1555 self.search(query, cx);
1556 }
1557
1558 fn select_entry(
1559 &mut self,
1560 idx: usize,
1561 scroll_strategy: ScrollStrategy,
1562 cx: &mut Context<Self>,
1563 ) {
1564 if self.selected_entry_idx == Some(idx) {
1565 return;
1566 }
1567
1568 self.selected_entry_idx = Some(idx);
1569 self.selected_commit_diff = None;
1570 self.selected_commit_diff_stats = None;
1571 self.changed_files_scroll_handle
1572 .scroll_to_item(0, ScrollStrategy::Top);
1573 self.table_interaction_state.update(cx, |state, cx| {
1574 state.scroll_handle.scroll_to_item(idx, scroll_strategy);
1575 cx.notify();
1576 });
1577
1578 let Some(commit) = self.graph_data.commits.get(idx) else {
1579 return;
1580 };
1581
1582 let sha = commit.data.sha.to_string();
1583
1584 let Some(repository) = self.get_repository(cx) else {
1585 return;
1586 };
1587
1588 let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1589
1590 self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1591 if let Ok(Ok(diff)) = diff_receiver.await {
1592 this.update(cx, |this, cx| {
1593 let stats = compute_diff_stats(&diff);
1594 this.selected_commit_diff = Some(diff);
1595 this.selected_commit_diff_stats = Some(stats);
1596 cx.notify();
1597 })
1598 .ok();
1599 }
1600 }));
1601
1602 cx.emit(ItemEvent::Edit);
1603 cx.notify();
1604 }
1605
1606 fn select_previous_match(&mut self, cx: &mut Context<Self>) {
1607 if self.search_state.matches.is_empty() {
1608 return;
1609 }
1610
1611 let mut prev_selection = self.search_state.selected_index.unwrap_or_default();
1612
1613 if prev_selection == 0 {
1614 prev_selection = self.search_state.matches.len() - 1;
1615 } else {
1616 prev_selection -= 1;
1617 }
1618
1619 let Some(&oid) = self.search_state.matches.get_index(prev_selection) else {
1620 return;
1621 };
1622
1623 self.search_state.selected_index = Some(prev_selection);
1624 self.select_commit_by_sha(oid, cx);
1625 }
1626
1627 fn select_next_match(&mut self, cx: &mut Context<Self>) {
1628 if self.search_state.matches.is_empty() {
1629 return;
1630 }
1631
1632 let mut next_selection = self
1633 .search_state
1634 .selected_index
1635 .map(|index| index + 1)
1636 .unwrap_or_default();
1637
1638 if next_selection >= self.search_state.matches.len() {
1639 next_selection = 0;
1640 }
1641
1642 let Some(&oid) = self.search_state.matches.get_index(next_selection) else {
1643 return;
1644 };
1645
1646 self.search_state.selected_index = Some(next_selection);
1647 self.select_commit_by_sha(oid, cx);
1648 }
1649
1650 pub fn set_repo_id(&mut self, repo_id: RepositoryId, cx: &mut Context<Self>) {
1651 if repo_id != self.repo_id
1652 && self
1653 .git_store
1654 .read(cx)
1655 .repositories()
1656 .contains_key(&repo_id)
1657 {
1658 self.repo_id = repo_id;
1659 self.invalidate_state(cx);
1660 }
1661 }
1662
1663 pub fn select_commit_by_sha(&mut self, sha: impl TryInto<Oid>, cx: &mut Context<Self>) {
1664 fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
1665 let Some(selected_repository) = this.get_repository(cx) else {
1666 return;
1667 };
1668
1669 let Some(index) = selected_repository
1670 .read(cx)
1671 .get_graph_data(this.log_source.clone(), this.log_order)
1672 .and_then(|data| data.commit_oid_to_index.get(&oid))
1673 .copied()
1674 else {
1675 this.pending_select_sha = Some(oid);
1676 return;
1677 };
1678
1679 this.pending_select_sha = None;
1680 this.select_entry(index, ScrollStrategy::Center, cx);
1681 }
1682
1683 if let Ok(oid) = sha.try_into() {
1684 inner(self, oid, cx);
1685 }
1686 }
1687
1688 fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1689 let Some(selected_entry_index) = self.selected_entry_idx else {
1690 return;
1691 };
1692
1693 self.open_commit_view(selected_entry_index, window, cx);
1694 }
1695
1696 fn open_commit_view(
1697 &mut self,
1698 entry_index: usize,
1699 window: &mut Window,
1700 cx: &mut Context<Self>,
1701 ) {
1702 let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1703 return;
1704 };
1705
1706 let Some(repository) = self.get_repository(cx) else {
1707 return;
1708 };
1709
1710 CommitView::open(
1711 commit_entry.data.sha.to_string(),
1712 repository.downgrade(),
1713 self.workspace.clone(),
1714 None,
1715 None,
1716 window,
1717 cx,
1718 );
1719 }
1720
1721 fn get_remote(
1722 &self,
1723 repository: &Repository,
1724 _window: &mut Window,
1725 cx: &mut App,
1726 ) -> Option<GitRemote> {
1727 let remote_url = repository.default_remote_url()?;
1728 let provider_registry = GitHostingProviderRegistry::default_global(cx);
1729 let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1730 Some(GitRemote {
1731 host: provider,
1732 owner: parsed.owner.into(),
1733 repo: parsed.repo.into(),
1734 })
1735 }
1736
1737 fn render_search_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1738 let color = cx.theme().colors();
1739 let query_focus_handle = self.search_state.editor.focus_handle(cx);
1740 let search_options = {
1741 let mut options = SearchOptions::NONE;
1742 options.set(
1743 SearchOptions::CASE_SENSITIVE,
1744 self.search_state.case_sensitive,
1745 );
1746 options
1747 };
1748
1749 h_flex()
1750 .w_full()
1751 .p_1p5()
1752 .gap_1p5()
1753 .border_b_1()
1754 .border_color(color.border_variant)
1755 .child(
1756 h_flex()
1757 .h_8()
1758 .flex_1()
1759 .min_w_0()
1760 .px_1p5()
1761 .gap_1()
1762 .border_1()
1763 .border_color(color.border)
1764 .rounded_md()
1765 .bg(color.toolbar_background)
1766 .on_action(cx.listener(Self::confirm_search))
1767 .child(self.search_state.editor.clone())
1768 .child(SearchOption::CaseSensitive.as_button(
1769 search_options,
1770 SearchSource::Buffer,
1771 query_focus_handle,
1772 )),
1773 )
1774 .child(
1775 h_flex()
1776 .min_w_64()
1777 .gap_1()
1778 .child({
1779 let focus_handle = self.focus_handle.clone();
1780 IconButton::new("git-graph-search-prev", IconName::ChevronLeft)
1781 .shape(ui::IconButtonShape::Square)
1782 .icon_size(IconSize::Small)
1783 .tooltip(move |_, cx| {
1784 Tooltip::for_action_in(
1785 "Select Previous Match",
1786 &SelectPreviousMatch,
1787 &focus_handle,
1788 cx,
1789 )
1790 })
1791 .map(|this| {
1792 if self.search_state.matches.is_empty() {
1793 this.disabled(true)
1794 } else {
1795 this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
1796 this.select_previous_match(cx);
1797 }))
1798 }
1799 })
1800 })
1801 .child({
1802 let focus_handle = self.focus_handle.clone();
1803 IconButton::new("git-graph-search-next", IconName::ChevronRight)
1804 .shape(ui::IconButtonShape::Square)
1805 .icon_size(IconSize::Small)
1806 .tooltip(move |_, cx| {
1807 Tooltip::for_action_in(
1808 "Select Next Match",
1809 &SelectNextMatch,
1810 &focus_handle,
1811 cx,
1812 )
1813 })
1814 .map(|this| {
1815 if self.search_state.matches.is_empty() {
1816 this.disabled(true)
1817 } else {
1818 this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
1819 this.select_next_match(cx);
1820 }))
1821 }
1822 })
1823 })
1824 .child(
1825 h_flex()
1826 .gap_1p5()
1827 .child(
1828 Label::new(format!(
1829 "{}/{}",
1830 self.search_state
1831 .selected_index
1832 .map(|index| index + 1)
1833 .unwrap_or(0),
1834 self.search_state.matches.len()
1835 ))
1836 .size(LabelSize::Small)
1837 .when(self.search_state.matches.is_empty(), |this| {
1838 this.color(Color::Disabled)
1839 }),
1840 )
1841 .when(
1842 matches!(
1843 &self.search_state.state,
1844 QueryState::Confirmed((_, task)) if !task.is_ready()
1845 ),
1846 |this| {
1847 this.child(
1848 Icon::new(IconName::ArrowCircle)
1849 .color(Color::Accent)
1850 .size(IconSize::Small)
1851 .with_rotate_animation(2)
1852 .into_any_element(),
1853 )
1854 },
1855 ),
1856 ),
1857 )
1858 }
1859
1860 fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1861 let rems = TextSize::Large.rems(cx);
1862 Icon::new(IconName::LoadCircle)
1863 .size(IconSize::Custom(rems))
1864 .color(Color::Accent)
1865 .with_rotate_animation(3)
1866 .into_any_element()
1867 }
1868
1869 fn render_commit_detail_panel(
1870 &self,
1871 window: &mut Window,
1872 cx: &mut Context<Self>,
1873 ) -> impl IntoElement {
1874 let Some(selected_idx) = self.selected_entry_idx else {
1875 return Empty.into_any_element();
1876 };
1877
1878 let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1879 return Empty.into_any_element();
1880 };
1881
1882 let Some(repository) = self.get_repository(cx) else {
1883 return Empty.into_any_element();
1884 };
1885
1886 let data = repository.update(cx, |repository, cx| {
1887 repository
1888 .fetch_commit_data(commit_entry.data.sha, cx)
1889 .clone()
1890 });
1891
1892 let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1893 let ref_names = commit_entry.data.ref_names.clone();
1894
1895 let accent_colors = cx.theme().accents();
1896 let accent_color = accent_colors
1897 .0
1898 .get(commit_entry.color_idx)
1899 .copied()
1900 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1901
1902 // todo(git graph): We should use the full commit message here
1903 let (author_name, author_email, commit_timestamp, commit_message) = match &data {
1904 CommitDataState::Loaded(data) => (
1905 data.author_name.clone(),
1906 data.author_email.clone(),
1907 Some(data.commit_timestamp),
1908 data.subject.clone(),
1909 ),
1910 CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
1911 };
1912
1913 let date_string = commit_timestamp
1914 .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1915 .map(|datetime| {
1916 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1917 let local_datetime = datetime.to_offset(local_offset);
1918 let format =
1919 time::format_description::parse("[month repr:short] [day], [year]").ok();
1920 format
1921 .and_then(|f| local_datetime.format(&f).ok())
1922 .unwrap_or_default()
1923 })
1924 .unwrap_or_default();
1925
1926 let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1927
1928 let avatar = {
1929 let author_email_for_avatar = if author_email.is_empty() {
1930 None
1931 } else {
1932 Some(author_email.clone())
1933 };
1934
1935 CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
1936 .size(px(40.))
1937 .render(window, cx)
1938 };
1939
1940 let changed_files_count = self
1941 .selected_commit_diff
1942 .as_ref()
1943 .map(|diff| diff.files.len())
1944 .unwrap_or(0);
1945
1946 let (total_lines_added, total_lines_removed) =
1947 self.selected_commit_diff_stats.unwrap_or((0, 0));
1948
1949 let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1950 self.selected_commit_diff
1951 .as_ref()
1952 .map(|diff| {
1953 let mut files: Vec<_> = diff.files.iter().collect();
1954 files.sort_by_key(|file| file.status());
1955 files
1956 .into_iter()
1957 .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1958 .collect()
1959 })
1960 .unwrap_or_default(),
1961 );
1962
1963 v_flex()
1964 .min_w(px(300.))
1965 .h_full()
1966 .bg(cx.theme().colors().surface_background)
1967 .flex_basis(DefiniteLength::Fraction(
1968 self.commit_details_split_state.read(cx).right_ratio(),
1969 ))
1970 .child(
1971 v_flex()
1972 .relative()
1973 .w_full()
1974 .p_2()
1975 .gap_2()
1976 .child(
1977 div().absolute().top_2().right_2().child(
1978 IconButton::new("close-detail", IconName::Close)
1979 .icon_size(IconSize::Small)
1980 .on_click(cx.listener(move |this, _, _, cx| {
1981 this.selected_entry_idx = None;
1982 this.selected_commit_diff = None;
1983 this.selected_commit_diff_stats = None;
1984 this._commit_diff_task = None;
1985 cx.notify();
1986 })),
1987 ),
1988 )
1989 .child(
1990 v_flex()
1991 .py_1()
1992 .w_full()
1993 .items_center()
1994 .gap_1()
1995 .child(avatar)
1996 .child(
1997 v_flex()
1998 .items_center()
1999 .child(Label::new(author_name))
2000 .child(
2001 Label::new(date_string)
2002 .color(Color::Muted)
2003 .size(LabelSize::Small),
2004 ),
2005 ),
2006 )
2007 .children((!ref_names.is_empty()).then(|| {
2008 h_flex().gap_1().flex_wrap().justify_center().children(
2009 ref_names
2010 .iter()
2011 .map(|name| self.render_chip(name, accent_color)),
2012 )
2013 }))
2014 .child(
2015 v_flex()
2016 .ml_neg_1()
2017 .gap_1p5()
2018 .when(!author_email.is_empty(), |this| {
2019 let copied_state: Entity<CopiedState> = window.use_keyed_state(
2020 "author-email-copy",
2021 cx,
2022 CopiedState::new,
2023 );
2024 let is_copied = copied_state.read(cx).is_copied();
2025
2026 let (icon, icon_color, tooltip_label) = if is_copied {
2027 (IconName::Check, Color::Success, "Email Copied!")
2028 } else {
2029 (IconName::Envelope, Color::Muted, "Copy Email")
2030 };
2031
2032 let copy_email = author_email.clone();
2033 let author_email_for_tooltip = author_email.clone();
2034
2035 this.child(
2036 Button::new("author-email-copy", author_email.clone())
2037 .start_icon(
2038 Icon::new(icon).size(IconSize::Small).color(icon_color),
2039 )
2040 .label_size(LabelSize::Small)
2041 .truncate(true)
2042 .color(Color::Muted)
2043 .tooltip(move |_, cx| {
2044 Tooltip::with_meta(
2045 tooltip_label,
2046 None,
2047 author_email_for_tooltip.clone(),
2048 cx,
2049 )
2050 })
2051 .on_click(move |_, _, cx| {
2052 copied_state.update(cx, |state, _cx| {
2053 state.mark_copied();
2054 });
2055 cx.write_to_clipboard(ClipboardItem::new_string(
2056 copy_email.to_string(),
2057 ));
2058 let state_id = copied_state.entity_id();
2059 cx.spawn(async move |cx| {
2060 cx.background_executor()
2061 .timer(COPIED_STATE_DURATION)
2062 .await;
2063 cx.update(|cx| {
2064 cx.notify(state_id);
2065 })
2066 })
2067 .detach();
2068 }),
2069 )
2070 })
2071 .child({
2072 let copy_sha = full_sha.clone();
2073 let copied_state: Entity<CopiedState> =
2074 window.use_keyed_state("sha-copy", cx, CopiedState::new);
2075 let is_copied = copied_state.read(cx).is_copied();
2076
2077 let (icon, icon_color, tooltip_label) = if is_copied {
2078 (IconName::Check, Color::Success, "Commit SHA Copied!")
2079 } else {
2080 (IconName::Hash, Color::Muted, "Copy Commit SHA")
2081 };
2082
2083 Button::new("sha-button", &full_sha)
2084 .start_icon(
2085 Icon::new(icon).size(IconSize::Small).color(icon_color),
2086 )
2087 .label_size(LabelSize::Small)
2088 .truncate(true)
2089 .color(Color::Muted)
2090 .tooltip({
2091 let full_sha = full_sha.clone();
2092 move |_, cx| {
2093 Tooltip::with_meta(
2094 tooltip_label,
2095 None,
2096 full_sha.clone(),
2097 cx,
2098 )
2099 }
2100 })
2101 .on_click(move |_, _, cx| {
2102 copied_state.update(cx, |state, _cx| {
2103 state.mark_copied();
2104 });
2105 cx.write_to_clipboard(ClipboardItem::new_string(
2106 copy_sha.to_string(),
2107 ));
2108 let state_id = copied_state.entity_id();
2109 cx.spawn(async move |cx| {
2110 cx.background_executor()
2111 .timer(COPIED_STATE_DURATION)
2112 .await;
2113 cx.update(|cx| {
2114 cx.notify(state_id);
2115 })
2116 })
2117 .detach();
2118 })
2119 })
2120 .when_some(remote.clone(), |this, remote| {
2121 let provider_name = remote.host.name();
2122 let icon = match provider_name.as_str() {
2123 "GitHub" => IconName::Github,
2124 _ => IconName::Link,
2125 };
2126 let parsed_remote = ParsedGitRemote {
2127 owner: remote.owner.as_ref().into(),
2128 repo: remote.repo.as_ref().into(),
2129 };
2130 let params = BuildCommitPermalinkParams {
2131 sha: full_sha.as_ref(),
2132 };
2133 let url = remote
2134 .host
2135 .build_commit_permalink(&parsed_remote, params)
2136 .to_string();
2137
2138 this.child(
2139 Button::new(
2140 "view-on-provider",
2141 format!("View on {}", provider_name),
2142 )
2143 .start_icon(
2144 Icon::new(icon).size(IconSize::Small).color(Color::Muted),
2145 )
2146 .label_size(LabelSize::Small)
2147 .truncate(true)
2148 .color(Color::Muted)
2149 .on_click(
2150 move |_, _, cx| {
2151 cx.open_url(&url);
2152 },
2153 ),
2154 )
2155 }),
2156 ),
2157 )
2158 .child(Divider::horizontal())
2159 .child(div().p_2().child(Label::new(commit_message)))
2160 .child(Divider::horizontal())
2161 .child(
2162 v_flex()
2163 .min_w_0()
2164 .p_2()
2165 .flex_1()
2166 .gap_1()
2167 .child(
2168 h_flex()
2169 .gap_1()
2170 .child(
2171 Label::new(format!("{} Changed Files", changed_files_count))
2172 .size(LabelSize::Small)
2173 .color(Color::Muted),
2174 )
2175 .child(DiffStat::new(
2176 "commit-diff-stat",
2177 total_lines_added,
2178 total_lines_removed,
2179 )),
2180 )
2181 .child(
2182 div()
2183 .id("changed-files-container")
2184 .flex_1()
2185 .min_h_0()
2186 .child({
2187 let entries = sorted_file_entries;
2188 let entry_count = entries.len();
2189 let commit_sha = full_sha.clone();
2190 let repository = repository.downgrade();
2191 let workspace = self.workspace.clone();
2192 uniform_list(
2193 "changed-files-list",
2194 entry_count,
2195 move |range, _window, cx| {
2196 range
2197 .map(|ix| {
2198 entries[ix].render(
2199 ix,
2200 commit_sha.clone(),
2201 repository.clone(),
2202 workspace.clone(),
2203 cx,
2204 )
2205 })
2206 .collect()
2207 },
2208 )
2209 .size_full()
2210 .ml_neg_1()
2211 .track_scroll(&self.changed_files_scroll_handle)
2212 })
2213 .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
2214 ),
2215 )
2216 .child(Divider::horizontal())
2217 .child(
2218 h_flex().p_1p5().w_full().child(
2219 Button::new("view-commit", "View Commit")
2220 .full_width()
2221 .style(ButtonStyle::Outlined)
2222 .on_click(cx.listener(|this, _, window, cx| {
2223 this.open_selected_commit_view(window, cx);
2224 })),
2225 ),
2226 )
2227 .into_any_element()
2228 }
2229
2230 fn render_graph_canvas(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
2231 let row_height = self.row_height;
2232 let table_state = self.table_interaction_state.read(cx);
2233 let viewport_height = table_state
2234 .scroll_handle
2235 .0
2236 .borrow()
2237 .last_item_size
2238 .map(|size| size.item.height)
2239 .unwrap_or(px(600.0));
2240 let loaded_commit_count = self.graph_data.commits.len();
2241
2242 let content_height = row_height * loaded_commit_count;
2243 let max_scroll = (content_height - viewport_height).max(px(0.));
2244 let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
2245
2246 let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
2247 let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
2248
2249 let graph_viewport_width = self.graph_viewport_width(window, cx);
2250 let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
2251 self.graph_canvas_content_width()
2252 } else {
2253 graph_viewport_width
2254 };
2255 let last_visible_row =
2256 first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
2257
2258 let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
2259 ..(last_visible_row).min(loaded_commit_count);
2260 let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
2261 let commit_lines: Vec<_> = self
2262 .graph_data
2263 .lines
2264 .iter()
2265 .filter(|line| {
2266 line.full_interval.start <= viewport_range.end
2267 && line.full_interval.end >= viewport_range.start
2268 })
2269 .cloned()
2270 .collect();
2271
2272 let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
2273
2274 let hovered_entry_idx = self.hovered_entry_idx;
2275 let selected_entry_idx = self.selected_entry_idx;
2276 let is_focused = self.focus_handle.is_focused(window);
2277 let graph_canvas_bounds = self.graph_canvas_bounds.clone();
2278
2279 gpui::canvas(
2280 move |_bounds, _window, _cx| {},
2281 move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
2282 graph_canvas_bounds.set(Some(bounds));
2283
2284 window.paint_layer(bounds, |window| {
2285 let accent_colors = cx.theme().accents();
2286
2287 let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
2288 let selected_bg = if is_focused {
2289 cx.theme().colors().element_selected
2290 } else {
2291 cx.theme().colors().element_hover
2292 };
2293
2294 for visible_row_idx in 0..rows.len() {
2295 let absolute_row_idx = first_visible_row + visible_row_idx;
2296 let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
2297 let is_selected = selected_entry_idx == Some(absolute_row_idx);
2298
2299 if is_hovered || is_selected {
2300 let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
2301 - vertical_scroll_offset;
2302
2303 let row_bounds = Bounds::new(
2304 point(bounds.origin.x, row_y),
2305 gpui::Size {
2306 width: bounds.size.width,
2307 height: row_height,
2308 },
2309 );
2310
2311 let bg_color = if is_selected { selected_bg } else { hover_bg };
2312 window.paint_quad(gpui::fill(row_bounds, bg_color));
2313 }
2314 }
2315
2316 for (row_idx, row) in rows.into_iter().enumerate() {
2317 let row_color = accent_colors.color_for_index(row.color_idx as u32);
2318 let row_y_center =
2319 bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
2320 - vertical_scroll_offset;
2321
2322 let commit_x = lane_center_x(bounds, row.lane as f32);
2323
2324 draw_commit_circle(commit_x, row_y_center, row_color, window);
2325 }
2326
2327 for line in commit_lines {
2328 let Some((start_segment_idx, start_column)) =
2329 line.get_first_visible_segment_idx(first_visible_row)
2330 else {
2331 continue;
2332 };
2333
2334 let line_x = lane_center_x(bounds, start_column as f32);
2335
2336 let start_row = line.full_interval.start as i32 - first_visible_row as i32;
2337
2338 let from_y =
2339 bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
2340 - vertical_scroll_offset
2341 + COMMIT_CIRCLE_RADIUS;
2342
2343 let mut current_row = from_y;
2344 let mut current_column = line_x;
2345
2346 let mut builder = PathBuilder::stroke(LINE_WIDTH);
2347 builder.move_to(point(line_x, from_y));
2348
2349 let segments = &line.segments[start_segment_idx..];
2350 let desired_curve_height = row_height / 3.0;
2351 let desired_curve_width = LANE_WIDTH / 3.0;
2352
2353 for (segment_idx, segment) in segments.iter().enumerate() {
2354 let is_last = segment_idx + 1 == segments.len();
2355
2356 match segment {
2357 CommitLineSegment::Straight { to_row } => {
2358 let mut dest_row = to_row_center(
2359 to_row - first_visible_row,
2360 row_height,
2361 vertical_scroll_offset,
2362 bounds,
2363 );
2364 if is_last {
2365 dest_row -= COMMIT_CIRCLE_RADIUS;
2366 }
2367
2368 let dest_point = point(current_column, dest_row);
2369
2370 current_row = dest_point.y;
2371 builder.line_to(dest_point);
2372 builder.move_to(dest_point);
2373 }
2374 CommitLineSegment::Curve {
2375 to_column,
2376 on_row,
2377 curve_kind,
2378 } => {
2379 let mut to_column = lane_center_x(bounds, *to_column as f32);
2380
2381 let mut to_row = to_row_center(
2382 *on_row - first_visible_row,
2383 row_height,
2384 vertical_scroll_offset,
2385 bounds,
2386 );
2387
2388 // This means that this branch was a checkout
2389 let going_right = to_column > current_column;
2390 let column_shift = if going_right {
2391 COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
2392 } else {
2393 -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
2394 };
2395
2396 match curve_kind {
2397 CurveKind::Checkout => {
2398 if is_last {
2399 to_column -= column_shift;
2400 }
2401
2402 let available_curve_width =
2403 (to_column - current_column).abs();
2404 let available_curve_height =
2405 (to_row - current_row).abs();
2406 let curve_width =
2407 desired_curve_width.min(available_curve_width);
2408 let curve_height =
2409 desired_curve_height.min(available_curve_height);
2410 let signed_curve_width = if going_right {
2411 curve_width
2412 } else {
2413 -curve_width
2414 };
2415 let curve_start =
2416 point(current_column, to_row - curve_height);
2417 let curve_end =
2418 point(current_column + signed_curve_width, to_row);
2419 let curve_control = point(current_column, to_row);
2420
2421 builder.move_to(point(current_column, current_row));
2422 builder.line_to(curve_start);
2423 builder.move_to(curve_start);
2424 builder.curve_to(curve_end, curve_control);
2425 builder.move_to(curve_end);
2426 builder.line_to(point(to_column, to_row));
2427 }
2428 CurveKind::Merge => {
2429 if is_last {
2430 to_row -= COMMIT_CIRCLE_RADIUS;
2431 }
2432
2433 let merge_start = point(
2434 current_column + column_shift,
2435 current_row - COMMIT_CIRCLE_RADIUS,
2436 );
2437 let available_curve_width =
2438 (to_column - merge_start.x).abs();
2439 let available_curve_height =
2440 (to_row - merge_start.y).abs();
2441 let curve_width =
2442 desired_curve_width.min(available_curve_width);
2443 let curve_height =
2444 desired_curve_height.min(available_curve_height);
2445 let signed_curve_width = if going_right {
2446 curve_width
2447 } else {
2448 -curve_width
2449 };
2450 let curve_start = point(
2451 to_column - signed_curve_width,
2452 merge_start.y,
2453 );
2454 let curve_end =
2455 point(to_column, merge_start.y + curve_height);
2456 let curve_control = point(to_column, merge_start.y);
2457
2458 builder.move_to(merge_start);
2459 builder.line_to(curve_start);
2460 builder.move_to(curve_start);
2461 builder.curve_to(curve_end, curve_control);
2462 builder.move_to(curve_end);
2463 builder.line_to(point(to_column, to_row));
2464 }
2465 }
2466 current_row = to_row;
2467 current_column = to_column;
2468 builder.move_to(point(current_column, current_row));
2469 }
2470 }
2471 }
2472
2473 builder.close();
2474 lines.entry(line.color_idx).or_default().push(builder);
2475 }
2476
2477 for (color_idx, builders) in lines {
2478 let line_color = accent_colors.color_for_index(color_idx as u32);
2479
2480 for builder in builders {
2481 if let Ok(path) = builder.build() {
2482 // we paint each color on it's own layer to stop overlapping lines
2483 // of different colors changing the color of a line
2484 window.paint_layer(bounds, |window| {
2485 window.paint_path(path, line_color);
2486 });
2487 }
2488 }
2489 }
2490 })
2491 },
2492 )
2493 .w(graph_width)
2494 .h_full()
2495 }
2496
2497 fn row_at_position(&self, position_y: Pixels, cx: &Context<Self>) -> Option<usize> {
2498 let canvas_bounds = self.graph_canvas_bounds.get()?;
2499 let table_state = self.table_interaction_state.read(cx);
2500 let scroll_offset_y = -table_state.scroll_offset().y;
2501
2502 let local_y = position_y - canvas_bounds.origin.y;
2503
2504 if local_y >= px(0.) && local_y < canvas_bounds.size.height {
2505 let absolute_y = local_y + scroll_offset_y;
2506 let absolute_row = (absolute_y / self.row_height).floor() as usize;
2507
2508 if absolute_row < self.graph_data.commits.len() {
2509 return Some(absolute_row);
2510 }
2511 }
2512
2513 None
2514 }
2515
2516 fn handle_graph_mouse_move(
2517 &mut self,
2518 event: &gpui::MouseMoveEvent,
2519 _window: &mut Window,
2520 cx: &mut Context<Self>,
2521 ) {
2522 if let Some(row) = self.row_at_position(event.position.y, cx) {
2523 if self.hovered_entry_idx != Some(row) {
2524 self.hovered_entry_idx = Some(row);
2525 cx.notify();
2526 }
2527 } else if self.hovered_entry_idx.is_some() {
2528 self.hovered_entry_idx = None;
2529 cx.notify();
2530 }
2531 }
2532
2533 fn handle_graph_click(
2534 &mut self,
2535 event: &ClickEvent,
2536 window: &mut Window,
2537 cx: &mut Context<Self>,
2538 ) {
2539 if let Some(row) = self.row_at_position(event.position().y, cx) {
2540 self.select_entry(row, ScrollStrategy::Nearest, cx);
2541 if event.click_count() >= 2 {
2542 self.open_commit_view(row, window, cx);
2543 }
2544 }
2545 }
2546
2547 fn handle_graph_scroll(
2548 &mut self,
2549 event: &ScrollWheelEvent,
2550 window: &mut Window,
2551 cx: &mut Context<Self>,
2552 ) {
2553 let line_height = window.line_height();
2554 let delta = event.delta.pixel_delta(line_height);
2555
2556 let table_state = self.table_interaction_state.read(cx);
2557 let current_offset = table_state.scroll_offset();
2558
2559 let viewport_height = table_state.scroll_handle.viewport().size.height;
2560
2561 let commit_count = match self.graph_data.max_commit_count {
2562 AllCommitCount::Loaded(count) => count,
2563 AllCommitCount::NotLoaded => self.graph_data.commits.len(),
2564 };
2565 let content_height = self.row_height * commit_count;
2566 let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
2567
2568 let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
2569 let new_offset = Point::new(current_offset.x, new_y);
2570
2571 if new_offset != current_offset {
2572 table_state.set_scroll_offset(new_offset);
2573 cx.notify();
2574 }
2575 }
2576
2577 fn render_commit_view_resize_handle(
2578 &self,
2579 _window: &mut Window,
2580 cx: &mut Context<Self>,
2581 ) -> AnyElement {
2582 div()
2583 .id("commit-view-split-resize-container")
2584 .relative()
2585 .h_full()
2586 .flex_shrink_0()
2587 .w(px(1.))
2588 .bg(cx.theme().colors().border_variant)
2589 .child(
2590 div()
2591 .id("commit-view-split-resize-handle")
2592 .absolute()
2593 .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
2594 .w(px(RESIZE_HANDLE_WIDTH))
2595 .h_full()
2596 .cursor_col_resize()
2597 .block_mouse_except_scroll()
2598 .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
2599 if event.click_count() >= 2 {
2600 this.commit_details_split_state.update(cx, |state, _| {
2601 state.on_double_click();
2602 });
2603 }
2604 cx.stop_propagation();
2605 }))
2606 .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
2607 )
2608 .into_any_element()
2609 }
2610}
2611
2612impl Render for GitGraph {
2613 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2614 // This happens when we changed branches, we should refresh our search as well
2615 if let QueryState::Pending(query) = &mut self.search_state.state {
2616 let query = std::mem::take(query);
2617 self.search_state.state = QueryState::Empty;
2618 self.search(query, cx);
2619 }
2620 let (commit_count, is_loading) = match self.graph_data.max_commit_count {
2621 AllCommitCount::Loaded(count) => (count, true),
2622 AllCommitCount::NotLoaded => {
2623 let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) {
2624 repository.update(cx, |repository, cx| {
2625 // Start loading the graph data if we haven't started already
2626 let GraphDataResponse {
2627 commits,
2628 is_loading,
2629 error: _,
2630 } = repository.graph_data(
2631 self.log_source.clone(),
2632 self.log_order,
2633 0..usize::MAX,
2634 cx,
2635 );
2636 self.graph_data.add_commits(&commits);
2637 (commits.len(), is_loading)
2638 })
2639 } else {
2640 (0, false)
2641 };
2642
2643 (commit_count, is_loading)
2644 }
2645 };
2646
2647 let error = self.get_repository(cx).and_then(|repo| {
2648 repo.read(cx)
2649 .get_graph_data(self.log_source.clone(), self.log_order)
2650 .and_then(|data| data.error.clone())
2651 });
2652
2653 let content = if commit_count == 0 {
2654 let message = if let Some(error) = &error {
2655 format!("Error loading: {}", error)
2656 } else if is_loading {
2657 "Loading".to_string()
2658 } else {
2659 "No commits found".to_string()
2660 };
2661 let label = Label::new(message)
2662 .color(Color::Muted)
2663 .size(LabelSize::Large);
2664 div()
2665 .size_full()
2666 .h_flex()
2667 .gap_1()
2668 .items_center()
2669 .justify_center()
2670 .child(label)
2671 .when(is_loading && error.is_none(), |this| {
2672 this.child(self.render_loading_spinner(cx))
2673 })
2674 } else {
2675 let is_file_history = matches!(self.log_source, LogSource::File(_));
2676 let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx);
2677 let header_context = TableRenderContext::for_column_widths(
2678 Some(self.column_widths.read(cx).widths_to_render()),
2679 true,
2680 );
2681 let [
2682 graph_fraction,
2683 description_fraction,
2684 date_fraction,
2685 author_fraction,
2686 commit_fraction,
2687 ] = self.preview_column_fractions(window, cx);
2688 let table_fraction =
2689 description_fraction + date_fraction + author_fraction + commit_fraction;
2690 let table_width_config = self.table_column_width_config(window, cx);
2691
2692 h_flex()
2693 .size_full()
2694 .child(
2695 div()
2696 .flex_1()
2697 .min_w_0()
2698 .size_full()
2699 .flex()
2700 .flex_col()
2701 .child(render_table_header(
2702
2703 if !is_file_history {
2704
2705 TableRow::from_vec(
2706 vec![
2707 Label::new("Graph")
2708 .color(Color::Muted)
2709 .truncate()
2710 .into_any_element(),
2711 Label::new("Description")
2712 .color(Color::Muted)
2713 .into_any_element(),
2714 Label::new("Date").color(Color::Muted).into_any_element(),
2715 Label::new("Author").color(Color::Muted).into_any_element(),
2716 Label::new("Commit").color(Color::Muted).into_any_element(),
2717 ],
2718 5,
2719 )
2720 } else {
2721 TableRow::from_vec(
2722 vec![
2723 Label::new("Description")
2724 .color(Color::Muted)
2725 .into_any_element(),
2726 Label::new("Date").color(Color::Muted).into_any_element(),
2727 Label::new("Author").color(Color::Muted).into_any_element(),
2728 Label::new("Commit").color(Color::Muted).into_any_element(),
2729 ],
2730 4,
2731 )
2732
2733 },
2734
2735 header_context,
2736 Some(header_resize_info),
2737 Some(self.column_widths.entity_id()),
2738 cx,
2739 ))
2740 .child({
2741 let row_height = self.row_height;
2742 let selected_entry_idx = self.selected_entry_idx;
2743 let hovered_entry_idx = self.hovered_entry_idx;
2744 let weak_self = cx.weak_entity();
2745 let focus_handle = self.focus_handle.clone();
2746
2747 bind_redistributable_columns(
2748 div()
2749 .relative()
2750 .flex_1()
2751 .w_full()
2752 .overflow_hidden()
2753 .child(
2754 h_flex()
2755 .size_full()
2756 .when(!is_file_history, |this| {
2757 this.child(
2758 div()
2759 .w(DefiniteLength::Fraction(graph_fraction))
2760 .h_full()
2761 .min_w_0()
2762 .overflow_hidden()
2763 .child(
2764 div()
2765 .id("graph-canvas")
2766 .size_full()
2767 .overflow_hidden()
2768 .child(
2769 div()
2770 .size_full()
2771 .child(self.render_graph_canvas(window, cx)),
2772 )
2773 .on_scroll_wheel(
2774 cx.listener(Self::handle_graph_scroll),
2775 )
2776 .on_mouse_move(
2777 cx.listener(Self::handle_graph_mouse_move),
2778 )
2779 .on_click(cx.listener(Self::handle_graph_click))
2780 .on_hover(cx.listener(
2781 |this, &is_hovered: &bool, _, cx| {
2782 if !is_hovered
2783 && this.hovered_entry_idx.is_some()
2784 {
2785 this.hovered_entry_idx = None;
2786 cx.notify();
2787 }
2788 },
2789 )),
2790 ),
2791 )
2792 })
2793 .child(
2794 div()
2795 .w(DefiniteLength::Fraction(table_fraction))
2796 .h_full()
2797 .min_w_0()
2798 .child(
2799 Table::new(4)
2800 .interactable(&self.table_interaction_state)
2801 .hide_row_borders()
2802 .hide_row_hover()
2803 .width_config(table_width_config)
2804 .map_row(move |(index, row), window, cx| {
2805 let is_selected =
2806 selected_entry_idx == Some(index);
2807 let is_hovered =
2808 hovered_entry_idx == Some(index);
2809 let is_focused =
2810 focus_handle.is_focused(window);
2811 let weak = weak_self.clone();
2812 let weak_for_hover = weak.clone();
2813
2814 let hover_bg = cx
2815 .theme()
2816 .colors()
2817 .element_hover
2818 .opacity(0.6);
2819 let selected_bg = if is_focused {
2820 cx.theme().colors().element_selected
2821 } else {
2822 cx.theme().colors().element_hover
2823 };
2824
2825 row.h(row_height)
2826 .when(is_selected, |row| row.bg(selected_bg))
2827 .when(
2828 is_hovered && !is_selected,
2829 |row| row.bg(hover_bg),
2830 )
2831 .on_hover(move |&is_hovered, _, cx| {
2832 weak_for_hover
2833 .update(cx, |this, cx| {
2834 if is_hovered {
2835 if this.hovered_entry_idx
2836 != Some(index)
2837 {
2838 this.hovered_entry_idx =
2839 Some(index);
2840 cx.notify();
2841 }
2842 } else if this
2843 .hovered_entry_idx
2844 == Some(index)
2845 {
2846 this.hovered_entry_idx =
2847 None;
2848 cx.notify();
2849 }
2850 })
2851 .ok();
2852 })
2853 .on_click(move |event, window, cx| {
2854 let click_count = event.click_count();
2855 weak.update(cx, |this, cx| {
2856 this.select_entry(
2857 index,
2858 ScrollStrategy::Center,
2859 cx,
2860 );
2861 if click_count >= 2 {
2862 this.open_commit_view(
2863 index,
2864 window,
2865 cx,
2866 );
2867 }
2868 })
2869 .ok();
2870 })
2871 .into_any_element()
2872 })
2873 .uniform_list(
2874 "git-graph-commits",
2875 commit_count,
2876 cx.processor(Self::render_table_rows),
2877 ),
2878 ),
2879 ),
2880 )
2881 .child(render_redistributable_columns_resize_handles(
2882 &self.column_widths,
2883 window,
2884 cx,
2885 )),
2886 self.column_widths.clone(),
2887 )
2888 }),
2889 )
2890 .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
2891 this.commit_details_split_state.update(cx, |state, cx| {
2892 state.on_drag_move(event, window, cx);
2893 });
2894 }))
2895 .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
2896 this.commit_details_split_state.update(cx, |state, _cx| {
2897 state.commit_ratio();
2898 });
2899 }))
2900 .when(self.selected_entry_idx.is_some(), |this| {
2901 this.child(self.render_commit_view_resize_handle(window, cx))
2902 .child(self.render_commit_detail_panel(window, cx))
2903 })
2904 };
2905
2906 div()
2907 .key_context("GitGraph")
2908 .track_focus(&self.focus_handle)
2909 .size_full()
2910 .bg(cx.theme().colors().editor_background)
2911 .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
2912 this.open_selected_commit_view(window, cx);
2913 }))
2914 .on_action(cx.listener(Self::cancel))
2915 .on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
2916 this.search_state
2917 .editor
2918 .update(cx, |editor, cx| editor.focus_handle(cx).focus(window, cx));
2919 }))
2920 .on_action(cx.listener(Self::select_first))
2921 .on_action(cx.listener(Self::select_prev))
2922 .on_action(cx.listener(Self::select_next))
2923 .on_action(cx.listener(Self::select_last))
2924 .on_action(cx.listener(Self::confirm))
2925 .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| {
2926 this.select_next_match(cx);
2927 }))
2928 .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| {
2929 this.select_previous_match(cx);
2930 }))
2931 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| {
2932 this.search_state.case_sensitive = !this.search_state.case_sensitive;
2933 this.search_state.state.next_state();
2934 cx.emit(ItemEvent::Edit);
2935 cx.notify();
2936 }))
2937 .child(
2938 v_flex()
2939 .size_full()
2940 .child(self.render_search_bar(cx))
2941 .child(div().flex_1().child(content)),
2942 )
2943 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2944 deferred(
2945 anchored()
2946 .position(*position)
2947 .anchor(Corner::TopLeft)
2948 .child(menu.clone()),
2949 )
2950 .with_priority(1)
2951 }))
2952 .on_action(cx.listener(|_, _: &buffer_search::Deploy, window, cx| {
2953 window.dispatch_action(Box::new(FocusSearch), cx);
2954 cx.stop_propagation();
2955 }))
2956 }
2957}
2958
2959impl EventEmitter<ItemEvent> for GitGraph {}
2960
2961impl Focusable for GitGraph {
2962 fn focus_handle(&self, _cx: &App) -> FocusHandle {
2963 self.focus_handle.clone()
2964 }
2965}
2966
2967impl Item for GitGraph {
2968 type Event = ItemEvent;
2969
2970 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
2971 Some(Icon::new(IconName::GitGraph))
2972 }
2973
2974 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
2975 let repo_name = self.get_repository(cx).and_then(|repo| {
2976 repo.read(cx)
2977 .work_directory_abs_path
2978 .file_name()
2979 .map(|name| name.to_string_lossy().to_string())
2980 });
2981 let file_history_path = match &self.log_source {
2982 LogSource::File(path) => Some(path.as_unix_str().to_string()),
2983 _ => None,
2984 };
2985
2986 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
2987 move |_, _| {
2988 v_flex()
2989 .child(Label::new(if file_history_path.is_some() {
2990 "File History"
2991 } else {
2992 "Git Graph"
2993 }))
2994 .when_some(file_history_path.clone(), |this, path| {
2995 this.child(Label::new(path).color(Color::Muted).size(LabelSize::Small))
2996 })
2997 .when_some(repo_name.clone(), |this, name| {
2998 this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
2999 })
3000 .into_any_element()
3001 }
3002 }))))
3003 }
3004
3005 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
3006 if let LogSource::File(path) = &self.log_source {
3007 return path
3008 .as_ref()
3009 .file_name()
3010 .map(|name| SharedString::from(name.to_string()))
3011 .unwrap_or_else(|| SharedString::from(path.as_unix_str().to_string()));
3012 }
3013
3014 self.get_repository(cx)
3015 .and_then(|repo| {
3016 repo.read(cx)
3017 .work_directory_abs_path
3018 .file_name()
3019 .map(|name| name.to_string_lossy().to_string())
3020 })
3021 .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
3022 }
3023
3024 fn show_toolbar(&self) -> bool {
3025 false
3026 }
3027
3028 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
3029 f(*event)
3030 }
3031}
3032
3033impl workspace::SerializableItem for GitGraph {
3034 fn serialized_item_kind() -> &'static str {
3035 "GitGraph"
3036 }
3037
3038 fn cleanup(
3039 workspace_id: workspace::WorkspaceId,
3040 alive_items: Vec<workspace::ItemId>,
3041 _window: &mut Window,
3042 cx: &mut App,
3043 ) -> Task<gpui::Result<()>> {
3044 workspace::delete_unloaded_items(
3045 alive_items,
3046 workspace_id,
3047 "git_graphs",
3048 &persistence::GitGraphsDb::global(cx),
3049 cx,
3050 )
3051 }
3052
3053 fn deserialize(
3054 project: Entity<project::Project>,
3055 workspace: WeakEntity<Workspace>,
3056 workspace_id: workspace::WorkspaceId,
3057 item_id: workspace::ItemId,
3058 window: &mut Window,
3059 cx: &mut App,
3060 ) -> Task<gpui::Result<Entity<Self>>> {
3061 let db = persistence::GitGraphsDb::global(cx);
3062 let Some((
3063 repo_work_path,
3064 log_source_type,
3065 log_source_value,
3066 log_order,
3067 selected_sha,
3068 search_query,
3069 search_case_sensitive,
3070 )) = db.get_git_graph(item_id, workspace_id).ok().flatten()
3071 else {
3072 return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")));
3073 };
3074
3075 let state = persistence::SerializedGitGraphState {
3076 log_source_type,
3077 log_source_value,
3078 log_order,
3079 selected_sha,
3080 search_query,
3081 search_case_sensitive,
3082 };
3083
3084 let window_handle = window.window_handle();
3085 let project = project.read(cx);
3086 let git_store = project.git_store().clone();
3087 let wait = project.wait_for_initial_scan(cx);
3088
3089 cx.spawn(async move |cx| {
3090 wait.await;
3091
3092 cx.update_window(window_handle, |_, window, cx| {
3093 let path = repo_work_path.as_path();
3094
3095 let repositories = git_store.read(cx).repositories();
3096 let repo_id = repositories.iter().find_map(|(&repo_id, repo)| {
3097 if repo.read(cx).snapshot().work_directory_abs_path.as_ref() == path {
3098 Some(repo_id)
3099 } else {
3100 None
3101 }
3102 });
3103
3104 let Some(repo_id) = repo_id else {
3105 return Err(anyhow::anyhow!("Repository not found for path: {:?}", path));
3106 };
3107
3108 let log_source = persistence::deserialize_log_source(&state);
3109 let log_order = persistence::deserialize_log_order(&state);
3110
3111 let git_graph = cx.new(|cx| {
3112 let mut graph =
3113 GitGraph::new(repo_id, git_store, workspace, Some(log_source), window, cx);
3114 graph.log_order = log_order;
3115
3116 if let Some(sha) = &state.selected_sha {
3117 graph.select_commit_by_sha(sha.as_str(), cx);
3118 }
3119
3120 graph
3121 });
3122
3123 git_graph.update(cx, |graph, cx| {
3124 graph.search_state.case_sensitive =
3125 state.search_case_sensitive.unwrap_or(false);
3126
3127 if let Some(query) = &state.search_query
3128 && !query.is_empty()
3129 {
3130 graph
3131 .search_state
3132 .editor
3133 .update(cx, |editor, cx| editor.set_text(query.as_str(), window, cx));
3134 graph.search(query.clone().into(), cx);
3135 }
3136 });
3137
3138 Ok(git_graph)
3139 })?
3140 })
3141 }
3142
3143 fn serialize(
3144 &mut self,
3145 workspace: &mut Workspace,
3146 item_id: workspace::ItemId,
3147 _closing: bool,
3148 _window: &mut Window,
3149 cx: &mut Context<Self>,
3150 ) -> Option<Task<gpui::Result<()>>> {
3151 let workspace_id = workspace.database_id()?;
3152 let repo = self.get_repository(cx)?;
3153 let repo_working_path = repo
3154 .read(cx)
3155 .snapshot()
3156 .work_directory_abs_path
3157 .to_string_lossy()
3158 .to_string();
3159
3160 let selected_sha = self
3161 .selected_entry_idx
3162 .and_then(|idx| self.graph_data.commits.get(idx))
3163 .map(|commit| commit.data.sha.to_string());
3164
3165 let search_query = self.search_state.editor.read(cx).text(cx);
3166 let search_query = if search_query.is_empty() {
3167 None
3168 } else {
3169 Some(search_query)
3170 };
3171
3172 let log_source_type = Some(persistence::serialize_log_source_type(&self.log_source));
3173 let log_source_value = persistence::serialize_log_source_value(&self.log_source);
3174 let log_order = Some(persistence::serialize_log_order(&self.log_order));
3175 let search_case_sensitive = Some(self.search_state.case_sensitive);
3176
3177 let db = persistence::GitGraphsDb::global(cx);
3178 Some(cx.background_spawn(async move {
3179 db.save_git_graph(
3180 item_id,
3181 workspace_id,
3182 repo_working_path,
3183 log_source_type,
3184 log_source_value,
3185 log_order,
3186 selected_sha,
3187 search_query,
3188 search_case_sensitive,
3189 )
3190 .await
3191 }))
3192 }
3193
3194 fn should_serialize(&self, event: &Self::Event) -> bool {
3195 match event {
3196 ItemEvent::UpdateTab | ItemEvent::Edit => true,
3197 _ => false,
3198 }
3199 }
3200}
3201
3202mod persistence {
3203 use std::{path::PathBuf, str::FromStr};
3204
3205 use db::{
3206 query,
3207 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
3208 sqlez_macros::sql,
3209 };
3210 use git::{
3211 Oid,
3212 repository::{LogOrder, LogSource, RepoPath},
3213 };
3214 use workspace::WorkspaceDb;
3215
3216 pub struct GitGraphsDb(ThreadSafeConnection);
3217
3218 impl Domain for GitGraphsDb {
3219 const NAME: &str = stringify!(GitGraphsDb);
3220
3221 const MIGRATIONS: &[&str] = &[
3222 sql!(
3223 CREATE TABLE git_graphs (
3224 workspace_id INTEGER,
3225 item_id INTEGER UNIQUE,
3226 is_open INTEGER DEFAULT FALSE,
3227
3228 PRIMARY KEY(workspace_id, item_id),
3229 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
3230 ON DELETE CASCADE
3231 ) STRICT;
3232 ),
3233 sql!(
3234 ALTER TABLE git_graphs ADD COLUMN repo_working_path TEXT;
3235 ),
3236 sql!(
3237 ALTER TABLE git_graphs ADD COLUMN log_source_type TEXT;
3238 ALTER TABLE git_graphs ADD COLUMN log_source_value TEXT;
3239 ALTER TABLE git_graphs ADD COLUMN log_order TEXT;
3240 ALTER TABLE git_graphs ADD COLUMN selected_sha TEXT;
3241 ALTER TABLE git_graphs ADD COLUMN search_query TEXT;
3242 ALTER TABLE git_graphs ADD COLUMN search_case_sensitive INTEGER;
3243 ),
3244 ];
3245 }
3246
3247 db::static_connection!(GitGraphsDb, [WorkspaceDb]);
3248
3249 pub const LOG_SOURCE_ALL: i32 = 0;
3250 pub const LOG_SOURCE_BRANCH: i32 = 1;
3251 pub const LOG_SOURCE_SHA: i32 = 2;
3252 pub const LOG_SOURCE_FILE: i32 = 3;
3253
3254 pub const LOG_ORDER_DATE: i32 = 0;
3255 pub const LOG_ORDER_TOPO: i32 = 1;
3256 pub const LOG_ORDER_AUTHOR_DATE: i32 = 2;
3257 pub const LOG_ORDER_REVERSE: i32 = 3;
3258
3259 pub fn serialize_log_source_type(log_source: &LogSource) -> i32 {
3260 match log_source {
3261 LogSource::All => LOG_SOURCE_ALL,
3262 LogSource::Branch(_) => LOG_SOURCE_BRANCH,
3263 LogSource::Sha(_) => LOG_SOURCE_SHA,
3264 LogSource::File(_) => LOG_SOURCE_FILE,
3265 }
3266 }
3267
3268 pub fn serialize_log_source_value(log_source: &LogSource) -> Option<String> {
3269 match log_source {
3270 LogSource::All => None,
3271 LogSource::Branch(branch) => Some(branch.to_string()),
3272 LogSource::Sha(oid) => Some(oid.to_string()),
3273 LogSource::File(path) => Some(path.as_unix_str().to_string()),
3274 }
3275 }
3276
3277 pub fn serialize_log_order(log_order: &LogOrder) -> i32 {
3278 match log_order {
3279 LogOrder::DateOrder => LOG_ORDER_DATE,
3280 LogOrder::TopoOrder => LOG_ORDER_TOPO,
3281 LogOrder::AuthorDateOrder => LOG_ORDER_AUTHOR_DATE,
3282 LogOrder::ReverseChronological => LOG_ORDER_REVERSE,
3283 }
3284 }
3285
3286 pub fn deserialize_log_source(state: &SerializedGitGraphState) -> LogSource {
3287 match state.log_source_type {
3288 Some(LOG_SOURCE_ALL) => LogSource::All,
3289 Some(LOG_SOURCE_BRANCH) => state
3290 .log_source_value
3291 .as_ref()
3292 .map(|v| LogSource::Branch(v.clone().into()))
3293 .unwrap_or_default(),
3294 Some(LOG_SOURCE_SHA) => state
3295 .log_source_value
3296 .as_ref()
3297 .and_then(|v| Oid::from_str(v).ok())
3298 .map(LogSource::Sha)
3299 .unwrap_or_default(),
3300 Some(LOG_SOURCE_FILE) => state
3301 .log_source_value
3302 .as_ref()
3303 .and_then(|v| RepoPath::new(v).ok())
3304 .map(LogSource::File)
3305 .unwrap_or_default(),
3306 None | Some(_) => LogSource::default(),
3307 }
3308 }
3309
3310 pub fn deserialize_log_order(state: &SerializedGitGraphState) -> LogOrder {
3311 match state.log_order {
3312 Some(LOG_ORDER_DATE) => LogOrder::DateOrder,
3313 Some(LOG_ORDER_TOPO) => LogOrder::TopoOrder,
3314 Some(LOG_ORDER_AUTHOR_DATE) => LogOrder::AuthorDateOrder,
3315 Some(LOG_ORDER_REVERSE) => LogOrder::ReverseChronological,
3316 _ => LogOrder::default(),
3317 }
3318 }
3319
3320 #[derive(Debug, Default, Clone)]
3321 pub struct SerializedGitGraphState {
3322 pub log_source_type: Option<i32>,
3323 pub log_source_value: Option<String>,
3324 pub log_order: Option<i32>,
3325 pub selected_sha: Option<String>,
3326 pub search_query: Option<String>,
3327 pub search_case_sensitive: Option<bool>,
3328 }
3329
3330 impl GitGraphsDb {
3331 query! {
3332 pub async fn save_git_graph(
3333 item_id: workspace::ItemId,
3334 workspace_id: workspace::WorkspaceId,
3335 repo_working_path: String,
3336 log_source_type: Option<i32>,
3337 log_source_value: Option<String>,
3338 log_order: Option<i32>,
3339 selected_sha: Option<String>,
3340 search_query: Option<String>,
3341 search_case_sensitive: Option<bool>
3342 ) -> Result<()> {
3343 INSERT OR REPLACE INTO git_graphs(
3344 item_id, workspace_id, repo_working_path,
3345 log_source_type, log_source_value, log_order,
3346 selected_sha, search_query, search_case_sensitive
3347 )
3348 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3349 }
3350 }
3351
3352 query! {
3353 pub fn get_git_graph(
3354 item_id: workspace::ItemId,
3355 workspace_id: workspace::WorkspaceId
3356 ) -> Result<Option<(
3357 PathBuf,
3358 Option<i32>,
3359 Option<String>,
3360 Option<i32>,
3361 Option<String>,
3362 Option<String>,
3363 Option<bool>
3364 )>> {
3365 SELECT
3366 repo_working_path,
3367 log_source_type,
3368 log_source_value,
3369 log_order,
3370 selected_sha,
3371 search_query,
3372 search_case_sensitive
3373 FROM git_graphs
3374 WHERE item_id = ? AND workspace_id = ?
3375 }
3376 }
3377 }
3378}
3379
3380#[cfg(test)]
3381mod tests {
3382 use super::*;
3383 use anyhow::{Context, Result, bail};
3384 use collections::{HashMap, HashSet};
3385 use fs::FakeFs;
3386 use git::Oid;
3387 use git::repository::InitialGraphCommitData;
3388 use gpui::TestAppContext;
3389 use project::Project;
3390 use project::git_store::{GitStoreEvent, RepositoryEvent};
3391 use rand::prelude::*;
3392 use serde_json::json;
3393 use settings::SettingsStore;
3394 use smallvec::{SmallVec, smallvec};
3395 use std::path::Path;
3396 use std::sync::{Arc, Mutex};
3397
3398 fn init_test(cx: &mut TestAppContext) {
3399 cx.update(|cx| {
3400 let settings_store = SettingsStore::test(cx);
3401 cx.set_global(settings_store);
3402 theme_settings::init(theme::LoadThemes::JustBase, cx);
3403 language_model::init(cx);
3404 git_ui::init(cx);
3405 project_panel::init(cx);
3406 init(cx);
3407 });
3408 }
3409
3410 /// Generates a random commit DAG suitable for testing git graph rendering.
3411 ///
3412 /// The commits are ordered newest-first (like git log output), so:
3413 /// - Index 0 = most recent commit (HEAD)
3414 /// - Last index = oldest commit (root, has no parents)
3415 /// - Parents of commit at index I must have index > I
3416 ///
3417 /// When `adversarial` is true, generates complex topologies with many branches
3418 /// and octopus merges. Otherwise generates more realistic linear histories
3419 /// with occasional branches.
3420 fn generate_random_commit_dag(
3421 rng: &mut StdRng,
3422 num_commits: usize,
3423 adversarial: bool,
3424 ) -> Vec<Arc<InitialGraphCommitData>> {
3425 if num_commits == 0 {
3426 return Vec::new();
3427 }
3428
3429 let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
3430 let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
3431
3432 for i in 0..num_commits {
3433 let sha = oids[i];
3434
3435 let parents = if i == num_commits - 1 {
3436 smallvec![]
3437 } else {
3438 generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
3439 };
3440
3441 let ref_names = if i == 0 {
3442 vec!["HEAD".into(), "main".into()]
3443 } else if adversarial && rng.random_bool(0.1) {
3444 vec![format!("branch-{}", i).into()]
3445 } else {
3446 Vec::new()
3447 };
3448
3449 commits.push(Arc::new(InitialGraphCommitData {
3450 sha,
3451 parents,
3452 ref_names,
3453 }));
3454 }
3455
3456 commits
3457 }
3458
3459 fn generate_parents_from_oids(
3460 rng: &mut StdRng,
3461 oids: &[Oid],
3462 current_idx: usize,
3463 num_commits: usize,
3464 adversarial: bool,
3465 ) -> SmallVec<[Oid; 1]> {
3466 let remaining = num_commits - current_idx - 1;
3467 if remaining == 0 {
3468 return smallvec![];
3469 }
3470
3471 if adversarial {
3472 let merge_chance = 0.4;
3473 let octopus_chance = 0.15;
3474
3475 if remaining >= 3 && rng.random_bool(octopus_chance) {
3476 let num_parents = rng.random_range(3..=remaining.min(5));
3477 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
3478 parent_indices.shuffle(rng);
3479 parent_indices
3480 .into_iter()
3481 .take(num_parents)
3482 .map(|idx| oids[idx])
3483 .collect()
3484 } else if remaining >= 2 && rng.random_bool(merge_chance) {
3485 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
3486 parent_indices.shuffle(rng);
3487 parent_indices
3488 .into_iter()
3489 .take(2)
3490 .map(|idx| oids[idx])
3491 .collect()
3492 } else {
3493 let parent_idx = rng.random_range(current_idx + 1..num_commits);
3494 smallvec![oids[parent_idx]]
3495 }
3496 } else {
3497 let merge_chance = 0.15;
3498 let skip_chance = 0.1;
3499
3500 if remaining >= 2 && rng.random_bool(merge_chance) {
3501 let first_parent = current_idx + 1;
3502 let second_parent = rng.random_range(current_idx + 2..num_commits);
3503 smallvec![oids[first_parent], oids[second_parent]]
3504 } else if rng.random_bool(skip_chance) && remaining >= 2 {
3505 let skip = rng.random_range(1..remaining.min(3));
3506 smallvec![oids[current_idx + 1 + skip]]
3507 } else {
3508 smallvec![oids[current_idx + 1]]
3509 }
3510 }
3511 }
3512
3513 fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
3514 graph
3515 .commits
3516 .iter()
3517 .enumerate()
3518 .map(|(idx, entry)| (entry.data.sha, idx))
3519 .collect()
3520 }
3521
3522 fn verify_commit_order(
3523 graph: &GraphData,
3524 commits: &[Arc<InitialGraphCommitData>],
3525 ) -> Result<()> {
3526 if graph.commits.len() != commits.len() {
3527 bail!(
3528 "Commit count mismatch: graph has {} commits, expected {}",
3529 graph.commits.len(),
3530 commits.len()
3531 );
3532 }
3533
3534 for (idx, (graph_commit, expected_commit)) in
3535 graph.commits.iter().zip(commits.iter()).enumerate()
3536 {
3537 if graph_commit.data.sha != expected_commit.sha {
3538 bail!(
3539 "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
3540 idx,
3541 graph_commit.data.sha,
3542 expected_commit.sha
3543 );
3544 }
3545 }
3546
3547 Ok(())
3548 }
3549
3550 fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
3551 for line in &graph.lines {
3552 let child_row = *oid_to_row
3553 .get(&line.child)
3554 .context("Line references non-existent child commit")?;
3555
3556 let parent_row = *oid_to_row
3557 .get(&line.parent)
3558 .context("Line references non-existent parent commit")?;
3559
3560 if child_row >= parent_row {
3561 bail!(
3562 "child_row ({}) must be < parent_row ({})",
3563 child_row,
3564 parent_row
3565 );
3566 }
3567
3568 if line.full_interval.start != child_row {
3569 bail!(
3570 "full_interval.start ({}) != child_row ({})",
3571 line.full_interval.start,
3572 child_row
3573 );
3574 }
3575
3576 if line.full_interval.end != parent_row {
3577 bail!(
3578 "full_interval.end ({}) != parent_row ({})",
3579 line.full_interval.end,
3580 parent_row
3581 );
3582 }
3583
3584 if let Some(last_segment) = line.segments.last() {
3585 let segment_end_row = match last_segment {
3586 CommitLineSegment::Straight { to_row } => *to_row,
3587 CommitLineSegment::Curve { on_row, .. } => *on_row,
3588 };
3589
3590 if segment_end_row != line.full_interval.end {
3591 bail!(
3592 "last segment ends at row {} but full_interval.end is {}",
3593 segment_end_row,
3594 line.full_interval.end
3595 );
3596 }
3597 }
3598 }
3599
3600 Ok(())
3601 }
3602
3603 fn verify_column_correctness(
3604 graph: &GraphData,
3605 oid_to_row: &HashMap<Oid, usize>,
3606 ) -> Result<()> {
3607 for line in &graph.lines {
3608 let child_row = *oid_to_row
3609 .get(&line.child)
3610 .context("Line references non-existent child commit")?;
3611
3612 let parent_row = *oid_to_row
3613 .get(&line.parent)
3614 .context("Line references non-existent parent commit")?;
3615
3616 let child_lane = graph.commits[child_row].lane;
3617 if line.child_column != child_lane {
3618 bail!(
3619 "child_column ({}) != child's lane ({})",
3620 line.child_column,
3621 child_lane
3622 );
3623 }
3624
3625 let mut current_column = line.child_column;
3626 for segment in &line.segments {
3627 if let CommitLineSegment::Curve { to_column, .. } = segment {
3628 current_column = *to_column;
3629 }
3630 }
3631
3632 let parent_lane = graph.commits[parent_row].lane;
3633 if current_column != parent_lane {
3634 bail!(
3635 "ending column ({}) != parent's lane ({})",
3636 current_column,
3637 parent_lane
3638 );
3639 }
3640 }
3641
3642 Ok(())
3643 }
3644
3645 fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
3646 for line in &graph.lines {
3647 if line.segments.is_empty() {
3648 bail!("Line has no segments");
3649 }
3650
3651 let mut current_row = line.full_interval.start;
3652
3653 for (idx, segment) in line.segments.iter().enumerate() {
3654 let segment_end_row = match segment {
3655 CommitLineSegment::Straight { to_row } => *to_row,
3656 CommitLineSegment::Curve { on_row, .. } => *on_row,
3657 };
3658
3659 if segment_end_row < current_row {
3660 bail!(
3661 "segment {} ends at row {} which is before current row {}",
3662 idx,
3663 segment_end_row,
3664 current_row
3665 );
3666 }
3667
3668 current_row = segment_end_row;
3669 }
3670 }
3671
3672 Ok(())
3673 }
3674
3675 fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
3676 for line in &graph.lines {
3677 let child_row = line.full_interval.start;
3678
3679 let mut current_column = line.child_column;
3680 let mut current_row = child_row;
3681
3682 for segment in &line.segments {
3683 match segment {
3684 CommitLineSegment::Straight { to_row } => {
3685 for row in (current_row + 1)..*to_row {
3686 if row < graph.commits.len() {
3687 let commit_at_row = &graph.commits[row];
3688 if commit_at_row.lane == current_column {
3689 bail!(
3690 "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
3691 current_row,
3692 to_row,
3693 current_column,
3694 commit_at_row.data.sha,
3695 row
3696 );
3697 }
3698 }
3699 }
3700 current_row = *to_row;
3701 }
3702 CommitLineSegment::Curve {
3703 to_column, on_row, ..
3704 } => {
3705 current_column = *to_column;
3706 current_row = *on_row;
3707 }
3708 }
3709 }
3710 }
3711
3712 Ok(())
3713 }
3714
3715 fn verify_coverage(graph: &GraphData) -> Result<()> {
3716 let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
3717 for entry in &graph.commits {
3718 for parent in &entry.data.parents {
3719 expected_edges.insert((entry.data.sha, *parent));
3720 }
3721 }
3722
3723 let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
3724 for line in &graph.lines {
3725 let edge = (line.child, line.parent);
3726
3727 if !found_edges.insert(edge) {
3728 bail!(
3729 "Duplicate line found for edge {:?} -> {:?}",
3730 line.child,
3731 line.parent
3732 );
3733 }
3734
3735 if !expected_edges.contains(&edge) {
3736 bail!(
3737 "Orphan line found: {:?} -> {:?} is not in the commit graph",
3738 line.child,
3739 line.parent
3740 );
3741 }
3742 }
3743
3744 for (child, parent) in &expected_edges {
3745 if !found_edges.contains(&(*child, *parent)) {
3746 bail!("Missing line for edge {:?} -> {:?}", child, parent);
3747 }
3748 }
3749
3750 assert_eq!(
3751 expected_edges.symmetric_difference(&found_edges).count(),
3752 0,
3753 "The symmetric difference should be zero"
3754 );
3755
3756 Ok(())
3757 }
3758
3759 fn verify_merge_line_optimality(
3760 graph: &GraphData,
3761 oid_to_row: &HashMap<Oid, usize>,
3762 ) -> Result<()> {
3763 for line in &graph.lines {
3764 let first_segment = line.segments.first();
3765 let is_merge_line = matches!(
3766 first_segment,
3767 Some(CommitLineSegment::Curve {
3768 curve_kind: CurveKind::Merge,
3769 ..
3770 })
3771 );
3772
3773 if !is_merge_line {
3774 continue;
3775 }
3776
3777 let child_row = *oid_to_row
3778 .get(&line.child)
3779 .context("Line references non-existent child commit")?;
3780
3781 let parent_row = *oid_to_row
3782 .get(&line.parent)
3783 .context("Line references non-existent parent commit")?;
3784
3785 let parent_lane = graph.commits[parent_row].lane;
3786
3787 let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
3788 continue;
3789 };
3790
3791 let curves_directly_to_parent = *to_column == parent_lane;
3792
3793 if !curves_directly_to_parent {
3794 continue;
3795 }
3796
3797 let curve_row = child_row + 1;
3798 let has_commits_in_path = graph.commits[curve_row..parent_row]
3799 .iter()
3800 .any(|c| c.lane == parent_lane);
3801
3802 if has_commits_in_path {
3803 bail!(
3804 "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
3805 line.child,
3806 line.parent,
3807 parent_lane,
3808 curve_row,
3809 parent_row
3810 );
3811 }
3812
3813 let curve_ends_at_parent = curve_row == parent_row;
3814
3815 if curve_ends_at_parent {
3816 if line.segments.len() != 1 {
3817 bail!(
3818 "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
3819 line.child,
3820 line.parent,
3821 line.segments.len()
3822 );
3823 }
3824 } else {
3825 if line.segments.len() != 2 {
3826 bail!(
3827 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
3828 line.child,
3829 line.parent,
3830 line.segments.len()
3831 );
3832 }
3833
3834 let is_straight_segment = matches!(
3835 line.segments.get(1),
3836 Some(CommitLineSegment::Straight { .. })
3837 );
3838
3839 if !is_straight_segment {
3840 bail!(
3841 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
3842 line.child,
3843 line.parent
3844 );
3845 }
3846 }
3847 }
3848
3849 Ok(())
3850 }
3851
3852 fn verify_all_invariants(
3853 graph: &GraphData,
3854 commits: &[Arc<InitialGraphCommitData>],
3855 ) -> Result<()> {
3856 let oid_to_row = build_oid_to_row_map(graph);
3857
3858 verify_commit_order(graph, commits).context("commit order")?;
3859 verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
3860 verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
3861 verify_segment_continuity(graph).context("segment continuity")?;
3862 verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
3863 verify_coverage(graph).context("coverage")?;
3864 verify_line_overlaps(graph).context("line overlaps")?;
3865 Ok(())
3866 }
3867
3868 #[test]
3869 fn test_git_graph_merge_commits() {
3870 let mut rng = StdRng::seed_from_u64(42);
3871
3872 let oid1 = Oid::random(&mut rng);
3873 let oid2 = Oid::random(&mut rng);
3874 let oid3 = Oid::random(&mut rng);
3875 let oid4 = Oid::random(&mut rng);
3876
3877 let commits = vec![
3878 Arc::new(InitialGraphCommitData {
3879 sha: oid1,
3880 parents: smallvec![oid2, oid3],
3881 ref_names: vec!["HEAD".into()],
3882 }),
3883 Arc::new(InitialGraphCommitData {
3884 sha: oid2,
3885 parents: smallvec![oid4],
3886 ref_names: vec![],
3887 }),
3888 Arc::new(InitialGraphCommitData {
3889 sha: oid3,
3890 parents: smallvec![oid4],
3891 ref_names: vec![],
3892 }),
3893 Arc::new(InitialGraphCommitData {
3894 sha: oid4,
3895 parents: smallvec![],
3896 ref_names: vec![],
3897 }),
3898 ];
3899
3900 let mut graph_data = GraphData::new(8);
3901 graph_data.add_commits(&commits);
3902
3903 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3904 panic!("Graph invariant violation for merge commits:\n{}", error);
3905 }
3906 }
3907
3908 #[test]
3909 fn test_git_graph_linear_commits() {
3910 let mut rng = StdRng::seed_from_u64(42);
3911
3912 let oid1 = Oid::random(&mut rng);
3913 let oid2 = Oid::random(&mut rng);
3914 let oid3 = Oid::random(&mut rng);
3915
3916 let commits = vec![
3917 Arc::new(InitialGraphCommitData {
3918 sha: oid1,
3919 parents: smallvec![oid2],
3920 ref_names: vec!["HEAD".into()],
3921 }),
3922 Arc::new(InitialGraphCommitData {
3923 sha: oid2,
3924 parents: smallvec![oid3],
3925 ref_names: vec![],
3926 }),
3927 Arc::new(InitialGraphCommitData {
3928 sha: oid3,
3929 parents: smallvec![],
3930 ref_names: vec![],
3931 }),
3932 ];
3933
3934 let mut graph_data = GraphData::new(8);
3935 graph_data.add_commits(&commits);
3936
3937 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3938 panic!("Graph invariant violation for linear commits:\n{}", error);
3939 }
3940 }
3941
3942 #[test]
3943 fn test_git_graph_random_commits() {
3944 for seed in 0..100 {
3945 let mut rng = StdRng::seed_from_u64(seed);
3946
3947 let adversarial = rng.random_bool(0.2);
3948 let num_commits = if adversarial {
3949 rng.random_range(10..100)
3950 } else {
3951 rng.random_range(5..50)
3952 };
3953
3954 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3955
3956 assert_eq!(
3957 num_commits,
3958 commits.len(),
3959 "seed={}: Generate random commit dag didn't generate the correct amount of commits",
3960 seed
3961 );
3962
3963 let mut graph_data = GraphData::new(8);
3964 graph_data.add_commits(&commits);
3965
3966 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3967 panic!(
3968 "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
3969 seed, adversarial, num_commits, error
3970 );
3971 }
3972 }
3973 }
3974
3975 // The full integration test has less iterations because it's significantly slower
3976 // than the random commit test
3977 #[gpui::test(iterations = 10)]
3978 async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
3979 init_test(cx);
3980
3981 let adversarial = rng.random_bool(0.2);
3982 let num_commits = if adversarial {
3983 rng.random_range(10..100)
3984 } else {
3985 rng.random_range(5..50)
3986 };
3987
3988 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3989
3990 let fs = FakeFs::new(cx.executor());
3991 fs.insert_tree(
3992 Path::new("/project"),
3993 json!({
3994 ".git": {},
3995 "file.txt": "content",
3996 }),
3997 )
3998 .await;
3999
4000 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
4001
4002 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4003 cx.run_until_parked();
4004
4005 let repository = project.read_with(cx, |project, cx| {
4006 project
4007 .active_repository(cx)
4008 .expect("should have a repository")
4009 });
4010
4011 repository.update(cx, |repo, cx| {
4012 repo.graph_data(
4013 crate::LogSource::default(),
4014 crate::LogOrder::default(),
4015 0..usize::MAX,
4016 cx,
4017 );
4018 });
4019 cx.run_until_parked();
4020
4021 let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
4022 repo.graph_data(
4023 crate::LogSource::default(),
4024 crate::LogOrder::default(),
4025 0..usize::MAX,
4026 cx,
4027 )
4028 .commits
4029 .to_vec()
4030 });
4031
4032 let mut graph_data = GraphData::new(8);
4033 graph_data.add_commits(&graph_commits);
4034
4035 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
4036 panic!(
4037 "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
4038 adversarial, num_commits, error
4039 );
4040 }
4041 }
4042
4043 #[gpui::test]
4044 async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
4045 init_test(cx);
4046
4047 let fs = FakeFs::new(cx.executor());
4048 fs.insert_tree(
4049 Path::new("/project"),
4050 json!({
4051 ".git": {},
4052 "file.txt": "content",
4053 }),
4054 )
4055 .await;
4056
4057 let mut rng = StdRng::seed_from_u64(42);
4058 let commits = generate_random_commit_dag(&mut rng, 10, false);
4059 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
4060
4061 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4062 let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
4063 project.update(cx, |project, cx| {
4064 let observed_repository_events = observed_repository_events.clone();
4065 cx.subscribe(project.git_store(), move |_, _, event, _| {
4066 if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
4067 observed_repository_events
4068 .lock()
4069 .expect("repository event mutex should be available")
4070 .push(repository_event.clone());
4071 }
4072 })
4073 .detach();
4074 });
4075
4076 let repository = project.read_with(cx, |project, cx| {
4077 project
4078 .active_repository(cx)
4079 .expect("should have a repository")
4080 });
4081
4082 repository.update(cx, |repo, cx| {
4083 repo.graph_data(
4084 crate::LogSource::default(),
4085 crate::LogOrder::default(),
4086 0..usize::MAX,
4087 cx,
4088 );
4089 });
4090
4091 project
4092 .update(cx, |project, cx| project.git_scans_complete(cx))
4093 .await;
4094 cx.run_until_parked();
4095
4096 let observed_repository_events = observed_repository_events
4097 .lock()
4098 .expect("repository event mutex should be available");
4099 assert!(
4100 observed_repository_events
4101 .iter()
4102 .any(|event| matches!(event, RepositoryEvent::HeadChanged)),
4103 "initial repository scan should emit HeadChanged"
4104 );
4105 let commit_count_after = repository.read_with(cx, |repo, _| {
4106 repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
4107 .map(|data| data.commit_data.len())
4108 .unwrap()
4109 });
4110 assert_eq!(
4111 commits.len(),
4112 commit_count_after,
4113 "initial_graph_data should remain populated after events emitted by initial repository scan"
4114 );
4115 }
4116
4117 #[gpui::test]
4118 async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
4119 init_test(cx);
4120
4121 let fs = FakeFs::new(cx.executor());
4122 fs.insert_tree(
4123 Path::new("/project_a"),
4124 json!({
4125 ".git": {},
4126 "file.txt": "content",
4127 }),
4128 )
4129 .await;
4130 fs.insert_tree(
4131 Path::new("/project_b"),
4132 json!({
4133 ".git": {},
4134 "other.txt": "content",
4135 }),
4136 )
4137 .await;
4138
4139 let mut rng = StdRng::seed_from_u64(42);
4140 let commits = generate_random_commit_dag(&mut rng, 10, false);
4141 fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
4142
4143 let project = Project::test(
4144 fs.clone(),
4145 [Path::new("/project_a"), Path::new("/project_b")],
4146 cx,
4147 )
4148 .await;
4149 cx.run_until_parked();
4150
4151 let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
4152 let mut first_repository = None;
4153 let mut second_repository = None;
4154
4155 for repository in project.repositories(cx).values() {
4156 let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
4157 if work_directory_abs_path.as_ref() == Path::new("/project_a") {
4158 first_repository = Some(repository.clone());
4159 } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
4160 second_repository = Some(repository.clone());
4161 }
4162 }
4163
4164 (
4165 first_repository.expect("should have repository for /project_a"),
4166 second_repository.expect("should have repository for /project_b"),
4167 )
4168 });
4169 first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
4170 cx.run_until_parked();
4171
4172 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4173 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4174 });
4175
4176 let workspace_weak =
4177 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4178 let git_graph = cx.new_window_entity(|window, cx| {
4179 GitGraph::new(
4180 first_repository.read(cx).id,
4181 project.read(cx).git_store().clone(),
4182 workspace_weak,
4183 None,
4184 window,
4185 cx,
4186 )
4187 });
4188 cx.run_until_parked();
4189
4190 // Verify initial graph data is loaded
4191 let initial_commit_count =
4192 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
4193 assert!(
4194 initial_commit_count > 0,
4195 "graph data should have been loaded, got 0 commits"
4196 );
4197
4198 git_graph.update(cx, |graph, cx| {
4199 graph.set_repo_id(second_repository.read(cx).id, cx)
4200 });
4201 cx.run_until_parked();
4202
4203 let commit_count_after_clear =
4204 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
4205 assert_eq!(
4206 commit_count_after_clear, 0,
4207 "graph_data should be cleared after switching away"
4208 );
4209
4210 git_graph.update(cx, |graph, cx| {
4211 graph.set_repo_id(first_repository.read(cx).id, cx)
4212 });
4213 cx.run_until_parked();
4214
4215 cx.draw(
4216 point(px(0.), px(0.)),
4217 gpui::size(px(1200.), px(800.)),
4218 |_, _| git_graph.clone().into_any_element(),
4219 );
4220 cx.run_until_parked();
4221
4222 // Verify graph data is reloaded from repository cache on switch back
4223 let reloaded_commit_count =
4224 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
4225 assert_eq!(
4226 reloaded_commit_count,
4227 commits.len(),
4228 "graph data should be reloaded after switching back"
4229 );
4230 }
4231
4232 #[gpui::test]
4233 async fn test_file_history_action_uses_focused_source_and_reuses_matching_graph(
4234 cx: &mut TestAppContext,
4235 ) {
4236 init_test(cx);
4237
4238 let fs = FakeFs::new(cx.executor());
4239 fs.insert_tree(
4240 Path::new("/project"),
4241 json!({
4242 ".git": {},
4243 "tracked1.txt": "tracked 1",
4244 "tracked2.txt": "tracked 2",
4245 }),
4246 )
4247 .await;
4248
4249 let commits = vec![Arc::new(InitialGraphCommitData {
4250 sha: Oid::from_bytes(&[1; 20]).unwrap(),
4251 parents: smallvec![],
4252 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4253 })];
4254 fs.set_graph_commits(Path::new("/project/.git"), commits);
4255
4256 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4257 cx.run_until_parked();
4258
4259 let repository = project.read_with(cx, |project, cx| {
4260 project
4261 .active_repository(cx)
4262 .expect("should have active repository")
4263 });
4264 let tracked1_repo_path = RepoPath::new(&"tracked1.txt").unwrap();
4265 let tracked2_repo_path = RepoPath::new(&"tracked2.txt").unwrap();
4266 let tracked1 = repository
4267 .read_with(cx, |repository, cx| {
4268 repository.repo_path_to_project_path(&tracked1_repo_path, cx)
4269 })
4270 .expect("tracked1 should resolve to project path");
4271 let tracked2 = repository
4272 .read_with(cx, |repository, cx| {
4273 repository.repo_path_to_project_path(&tracked2_repo_path, cx)
4274 })
4275 .expect("tracked2 should resolve to project path");
4276
4277 let workspace_window = cx.add_window(|window, cx| {
4278 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4279 });
4280 let workspace = workspace_window
4281 .read_with(cx, |multi, _| multi.workspace().clone())
4282 .expect("workspace should exist");
4283
4284 let (weak_workspace, async_window_cx) = workspace_window
4285 .update(cx, |multi, window, cx| {
4286 (multi.workspace().downgrade(), window.to_async(cx))
4287 })
4288 .expect("window should be available");
4289 cx.background_executor.allow_parking();
4290 let project_panel = cx
4291 .foreground_executor()
4292 .clone()
4293 .block_test(ProjectPanel::load(
4294 weak_workspace.clone(),
4295 async_window_cx.clone(),
4296 ))
4297 .expect("project panel should load");
4298 let git_panel = cx
4299 .foreground_executor()
4300 .clone()
4301 .block_test(git_ui::git_panel::GitPanel::load(
4302 weak_workspace,
4303 async_window_cx,
4304 ))
4305 .expect("git panel should load");
4306 cx.background_executor.forbid_parking();
4307
4308 workspace_window
4309 .update(cx, |multi, window, cx| {
4310 let workspace = multi.workspace();
4311 workspace.update(cx, |workspace, cx| {
4312 workspace.add_panel(project_panel.clone(), window, cx);
4313 workspace.add_panel(git_panel.clone(), window, cx);
4314 });
4315 })
4316 .expect("workspace window should be available");
4317 cx.run_until_parked();
4318
4319 workspace_window
4320 .update(cx, |multi, window, cx| {
4321 let workspace = multi.workspace();
4322 project_panel.update(cx, |panel, cx| {
4323 panel.select_path_for_test(tracked1.clone(), cx)
4324 });
4325 workspace.update(cx, |workspace, cx| {
4326 workspace.focus_panel::<ProjectPanel>(window, cx);
4327 });
4328 })
4329 .expect("workspace window should be available");
4330 cx.run_until_parked();
4331 workspace_window
4332 .update(cx, |_, window, cx| {
4333 window.dispatch_action(Box::new(git::FileHistory), cx);
4334 })
4335 .expect("workspace window should be available");
4336 cx.run_until_parked();
4337
4338 workspace.read_with(cx, |workspace, cx| {
4339 let graphs = workspace.items_of_type::<GitGraph>(cx).collect::<Vec<_>>();
4340 assert_eq!(graphs.len(), 1);
4341 assert_eq!(
4342 graphs[0].read(cx).log_source,
4343 LogSource::File(tracked1_repo_path.clone())
4344 );
4345 });
4346
4347 workspace_window
4348 .update(cx, |multi, window, cx| {
4349 let workspace = multi.workspace();
4350 git_panel.update(cx, |panel, cx| {
4351 panel.select_entry_by_path(tracked1.clone(), window, cx);
4352 });
4353 workspace.update(cx, |workspace, cx| {
4354 workspace.focus_panel::<git_ui::git_panel::GitPanel>(window, cx);
4355 });
4356 })
4357 .expect("workspace window should be available");
4358 cx.run_until_parked();
4359 workspace_window
4360 .update(cx, |_, window, cx| {
4361 window.dispatch_action(Box::new(git::FileHistory), cx);
4362 })
4363 .expect("workspace window should be available");
4364 cx.run_until_parked();
4365
4366 workspace.read_with(cx, |workspace, cx| {
4367 let graphs = workspace.items_of_type::<GitGraph>(cx).collect::<Vec<_>>();
4368 assert_eq!(graphs.len(), 1);
4369 assert_eq!(
4370 graphs[0].read(cx).log_source,
4371 LogSource::File(tracked1_repo_path.clone())
4372 );
4373 });
4374
4375 let tracked1_buffer = project
4376 .update(cx, |project, cx| project.open_buffer(tracked1.clone(), cx))
4377 .await
4378 .expect("tracked1 buffer should open");
4379 let tracked2_buffer = project
4380 .update(cx, |project, cx| project.open_buffer(tracked2.clone(), cx))
4381 .await
4382 .expect("tracked2 buffer should open");
4383 workspace_window
4384 .update(cx, |multi, window, cx| {
4385 let workspace = multi.workspace();
4386 let multibuffer = cx.new(|cx| {
4387 let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite);
4388 multibuffer.set_excerpts_for_buffer(
4389 tracked1_buffer.clone(),
4390 [Default::default()..tracked1_buffer.read(cx).max_point()],
4391 0,
4392 cx,
4393 );
4394 multibuffer.set_excerpts_for_buffer(
4395 tracked2_buffer.clone(),
4396 [Default::default()..tracked2_buffer.read(cx).max_point()],
4397 0,
4398 cx,
4399 );
4400 multibuffer
4401 });
4402 let editor = cx.new(|cx| {
4403 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx)
4404 });
4405 workspace.update(cx, |workspace, cx| {
4406 workspace.add_item_to_active_pane(
4407 Box::new(editor.clone()),
4408 None,
4409 true,
4410 window,
4411 cx,
4412 );
4413 });
4414 editor.update(cx, |editor, cx| {
4415 let snapshot = editor.buffer().read(cx).snapshot(cx);
4416 let second_excerpt_point = snapshot
4417 .range_for_buffer(tracked2_buffer.read(cx).remote_id())
4418 .expect("tracked2 excerpt should exist")
4419 .start;
4420 let anchor = snapshot.anchor_before(second_excerpt_point);
4421 editor.change_selections(
4422 editor::SelectionEffects::no_scroll(),
4423 window,
4424 cx,
4425 |selections| {
4426 selections.select_anchor_ranges([anchor..anchor]);
4427 },
4428 );
4429 window.focus(&editor.focus_handle(cx), cx);
4430 });
4431 })
4432 .expect("workspace window should be available");
4433 cx.run_until_parked();
4434
4435 workspace_window
4436 .update(cx, |_, window, cx| {
4437 window.dispatch_action(Box::new(git::FileHistory), cx);
4438 })
4439 .expect("workspace window should be available");
4440 cx.run_until_parked();
4441
4442 workspace.read_with(cx, |workspace, cx| {
4443 let graphs = workspace.items_of_type::<GitGraph>(cx).collect::<Vec<_>>();
4444 assert_eq!(graphs.len(), 2);
4445 let latest = graphs
4446 .into_iter()
4447 .max_by_key(|graph| graph.entity_id())
4448 .expect("expected a git graph");
4449 assert_eq!(
4450 latest.read(cx).log_source,
4451 LogSource::File(tracked2_repo_path)
4452 );
4453 });
4454 }
4455
4456 #[gpui::test]
4457 fn test_serialized_state_roundtrip(_cx: &mut TestAppContext) {
4458 use persistence::SerializedGitGraphState;
4459
4460 let file_path = RepoPath::new(&"src/main.rs").unwrap();
4461 let sha = Oid::from_bytes(&[0xab; 20]).unwrap();
4462
4463 let state = SerializedGitGraphState {
4464 log_source_type: Some(persistence::LOG_SOURCE_FILE),
4465 log_source_value: Some("src/main.rs".to_string()),
4466 log_order: Some(persistence::LOG_ORDER_TOPO),
4467 selected_sha: Some(sha.to_string()),
4468 search_query: Some("fix bug".to_string()),
4469 search_case_sensitive: Some(true),
4470 };
4471
4472 assert_eq!(
4473 persistence::deserialize_log_source(&state),
4474 LogSource::File(file_path)
4475 );
4476 assert!(matches!(
4477 persistence::deserialize_log_order(&state),
4478 LogOrder::TopoOrder
4479 ));
4480 assert_eq!(
4481 state.selected_sha.as_deref(),
4482 Some(sha.to_string()).as_deref()
4483 );
4484 assert_eq!(state.search_query.as_deref(), Some("fix bug"));
4485 assert_eq!(state.search_case_sensitive, Some(true));
4486
4487 let all_state = SerializedGitGraphState {
4488 log_source_type: Some(persistence::LOG_SOURCE_ALL),
4489 log_source_value: None,
4490 log_order: Some(persistence::LOG_ORDER_DATE),
4491 selected_sha: None,
4492 search_query: None,
4493 search_case_sensitive: None,
4494 };
4495 assert_eq!(
4496 persistence::deserialize_log_source(&all_state),
4497 LogSource::All
4498 );
4499 assert!(matches!(
4500 persistence::deserialize_log_order(&all_state),
4501 LogOrder::DateOrder
4502 ));
4503
4504 let branch_state = SerializedGitGraphState {
4505 log_source_type: Some(persistence::LOG_SOURCE_BRANCH),
4506 log_source_value: Some("refs/heads/main".to_string()),
4507 ..Default::default()
4508 };
4509 assert_eq!(
4510 persistence::deserialize_log_source(&branch_state),
4511 LogSource::Branch("refs/heads/main".into())
4512 );
4513
4514 let sha_state = SerializedGitGraphState {
4515 log_source_type: Some(persistence::LOG_SOURCE_SHA),
4516 log_source_value: Some(sha.to_string()),
4517 ..Default::default()
4518 };
4519 assert_eq!(
4520 persistence::deserialize_log_source(&sha_state),
4521 LogSource::Sha(sha)
4522 );
4523
4524 let empty_state = SerializedGitGraphState::default();
4525 assert_eq!(
4526 persistence::deserialize_log_source(&empty_state),
4527 LogSource::All
4528 );
4529 assert!(matches!(
4530 persistence::deserialize_log_order(&empty_state),
4531 LogOrder::DateOrder
4532 ));
4533 }
4534
4535 #[gpui::test]
4536 async fn test_git_graph_state_persists_across_serialization_roundtrip(cx: &mut TestAppContext) {
4537 init_test(cx);
4538
4539 let fs = FakeFs::new(cx.executor());
4540 fs.insert_tree(
4541 Path::new("/project"),
4542 json!({
4543 ".git": {},
4544 "file.txt": "content",
4545 }),
4546 )
4547 .await;
4548
4549 let mut rng = StdRng::seed_from_u64(99);
4550 let commits = generate_random_commit_dag(&mut rng, 20, false);
4551 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
4552
4553 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4554 cx.run_until_parked();
4555
4556 let repository = project.read_with(cx, |project, cx| {
4557 project
4558 .active_repository(cx)
4559 .expect("should have a repository")
4560 });
4561
4562 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4563 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4564 });
4565 let workspace_weak =
4566 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4567
4568 let git_graph = cx.new_window_entity(|window, cx| {
4569 GitGraph::new(
4570 repository.read(cx).id,
4571 project.read(cx).git_store().clone(),
4572 workspace_weak.clone(),
4573 None,
4574 window,
4575 cx,
4576 )
4577 });
4578 cx.run_until_parked();
4579
4580 cx.draw(
4581 point(px(0.), px(0.)),
4582 gpui::size(px(1200.), px(800.)),
4583 |_, _| git_graph.clone().into_any_element(),
4584 );
4585 cx.run_until_parked();
4586
4587 let commit_count = git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
4588 assert!(commit_count > 0, "graph should have loaded commits, got 0");
4589
4590 let target_sha = commits[5].sha;
4591 git_graph.update(cx, |graph, _| {
4592 graph.selected_entry_idx = Some(5);
4593 });
4594
4595 let selected_sha = git_graph.read_with(&*cx, |graph, _| {
4596 graph
4597 .selected_entry_idx
4598 .and_then(|idx| graph.graph_data.commits.get(idx))
4599 .map(|c| c.data.sha.to_string())
4600 });
4601 assert_eq!(selected_sha, Some(target_sha.to_string()));
4602
4603 let item_id = workspace::ItemId::from(999_u64);
4604 let workspace_db = cx.read(|cx| workspace::WorkspaceDb::global(cx));
4605 let workspace_id = workspace_db
4606 .next_id()
4607 .await
4608 .expect("should create workspace id");
4609 let db = cx.read(|cx| persistence::GitGraphsDb::global(cx));
4610 db.save_git_graph(
4611 item_id,
4612 workspace_id,
4613 "/project".to_string(),
4614 Some(persistence::LOG_SOURCE_ALL),
4615 None,
4616 Some(persistence::LOG_ORDER_DATE),
4617 selected_sha.clone(),
4618 Some("some query".to_string()),
4619 Some(true),
4620 )
4621 .await
4622 .expect("save should succeed");
4623
4624 let restored_graph = cx
4625 .update(|window, cx| {
4626 <GitGraph as workspace::SerializableItem>::deserialize(
4627 project.clone(),
4628 workspace_weak,
4629 workspace_id,
4630 item_id,
4631 window,
4632 cx,
4633 )
4634 })
4635 .await
4636 .expect("deserialization should succeed");
4637 cx.run_until_parked();
4638
4639 cx.draw(
4640 point(px(0.), px(0.)),
4641 gpui::size(px(1200.), px(800.)),
4642 |_, _| restored_graph.clone().into_any_element(),
4643 );
4644 cx.run_until_parked();
4645
4646 let restored_commit_count =
4647 restored_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
4648 assert_eq!(
4649 restored_commit_count, commit_count,
4650 "restored graph should have the same number of commits"
4651 );
4652
4653 restored_graph.read_with(&*cx, |graph, _| {
4654 assert_eq!(
4655 graph.log_source,
4656 LogSource::All,
4657 "log_source should be restored"
4658 );
4659
4660 let restored_selected_sha = graph
4661 .selected_entry_idx
4662 .and_then(|idx| graph.graph_data.commits.get(idx))
4663 .map(|c| c.data.sha.to_string());
4664 assert_eq!(
4665 restored_selected_sha, selected_sha,
4666 "selected commit should be restored via pending_select_sha"
4667 );
4668
4669 assert_eq!(
4670 graph.search_state.case_sensitive, true,
4671 "search case sensitivity should be restored"
4672 );
4673 });
4674
4675 restored_graph.read_with(&*cx, |graph, cx| {
4676 let editor_text = graph.search_state.editor.read(cx).text(cx);
4677 assert_eq!(
4678 editor_text, "some query",
4679 "search query text should be restored in editor"
4680 );
4681 });
4682 }
4683
4684 #[gpui::test]
4685 async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) {
4686 init_test(cx);
4687
4688 let fs = FakeFs::new(cx.executor());
4689 fs.insert_tree(
4690 Path::new("/project"),
4691 json!({
4692 ".git": {},
4693 "file.txt": "content",
4694 }),
4695 )
4696 .await;
4697
4698 let initial_head = Oid::from_bytes(&[1; 20]).unwrap();
4699 let initial_stash = Oid::from_bytes(&[2; 20]).unwrap();
4700 let updated_head = Oid::from_bytes(&[3; 20]).unwrap();
4701 let updated_stash = Oid::from_bytes(&[4; 20]).unwrap();
4702
4703 fs.set_graph_commits(
4704 Path::new("/project/.git"),
4705 vec![
4706 Arc::new(InitialGraphCommitData {
4707 sha: initial_head,
4708 parents: smallvec![initial_stash],
4709 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4710 }),
4711 Arc::new(InitialGraphCommitData {
4712 sha: initial_stash,
4713 parents: smallvec![],
4714 ref_names: vec!["refs/stash".into()],
4715 }),
4716 ],
4717 );
4718 fs.with_git_state(Path::new("/project/.git"), true, |state| {
4719 state.stash_entries = git::stash::GitStash {
4720 entries: vec![git::stash::StashEntry {
4721 index: 0,
4722 oid: initial_stash,
4723 message: "initial stash".to_string(),
4724 branch: Some("main".to_string()),
4725 timestamp: 1,
4726 }]
4727 .into(),
4728 };
4729 })
4730 .unwrap();
4731
4732 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4733 cx.run_until_parked();
4734
4735 let repository = project.read_with(cx, |project, cx| {
4736 project
4737 .active_repository(cx)
4738 .expect("should have a repository")
4739 });
4740
4741 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4742 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4743 });
4744 let workspace_weak =
4745 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4746 let git_graph = cx.new_window_entity(|window, cx| {
4747 GitGraph::new(
4748 repository.read(cx).id,
4749 project.read(cx).git_store().clone(),
4750 workspace_weak,
4751 None,
4752 window,
4753 cx,
4754 )
4755 });
4756 cx.run_until_parked();
4757
4758 let initial_shas = git_graph.read_with(&*cx, |graph, _| {
4759 graph
4760 .graph_data
4761 .commits
4762 .iter()
4763 .map(|commit| commit.data.sha)
4764 .collect::<Vec<_>>()
4765 });
4766 assert_eq!(initial_shas, vec![initial_head, initial_stash]);
4767
4768 fs.set_graph_commits(
4769 Path::new("/project/.git"),
4770 vec![
4771 Arc::new(InitialGraphCommitData {
4772 sha: updated_head,
4773 parents: smallvec![updated_stash],
4774 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4775 }),
4776 Arc::new(InitialGraphCommitData {
4777 sha: updated_stash,
4778 parents: smallvec![],
4779 ref_names: vec!["refs/stash".into()],
4780 }),
4781 ],
4782 );
4783 fs.with_git_state(Path::new("/project/.git"), true, |state| {
4784 state.stash_entries = git::stash::GitStash {
4785 entries: vec![git::stash::StashEntry {
4786 index: 0,
4787 oid: updated_stash,
4788 message: "updated stash".to_string(),
4789 branch: Some("main".to_string()),
4790 timestamp: 1,
4791 }]
4792 .into(),
4793 };
4794 })
4795 .unwrap();
4796
4797 project
4798 .update(cx, |project, cx| project.git_scans_complete(cx))
4799 .await;
4800 cx.run_until_parked();
4801
4802 cx.draw(
4803 point(px(0.), px(0.)),
4804 gpui::size(px(1200.), px(800.)),
4805 |_, _| git_graph.clone().into_any_element(),
4806 );
4807 cx.run_until_parked();
4808
4809 let reloaded_shas = git_graph.read_with(&*cx, |graph, _| {
4810 graph
4811 .graph_data
4812 .commits
4813 .iter()
4814 .map(|commit| commit.data.sha)
4815 .collect::<Vec<_>>()
4816 });
4817 assert_eq!(reloaded_shas, vec![updated_head, updated_stash]);
4818 }
4819
4820 #[gpui::test]
4821 async fn test_git_graph_row_at_position_rounding(cx: &mut TestAppContext) {
4822 init_test(cx);
4823
4824 let fs = FakeFs::new(cx.executor());
4825 fs.insert_tree(
4826 Path::new("/project"),
4827 serde_json::json!({
4828 ".git": {},
4829 "file.txt": "content",
4830 }),
4831 )
4832 .await;
4833
4834 let mut rng = StdRng::seed_from_u64(42);
4835 let commits = generate_random_commit_dag(&mut rng, 10, false);
4836 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
4837
4838 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4839 cx.run_until_parked();
4840
4841 let repository = project.read_with(cx, |project, cx| {
4842 project
4843 .active_repository(cx)
4844 .expect("should have a repository")
4845 });
4846
4847 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4848 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4849 });
4850
4851 let workspace_weak =
4852 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4853
4854 let git_graph = cx.new_window_entity(|window, cx| {
4855 GitGraph::new(
4856 repository.read(cx).id,
4857 project.read(cx).git_store().clone(),
4858 workspace_weak,
4859 None,
4860 window,
4861 cx,
4862 )
4863 });
4864 cx.run_until_parked();
4865
4866 git_graph.update(cx, |graph, cx| {
4867 assert!(
4868 graph.graph_data.commits.len() >= 10,
4869 "graph should load dummy commits"
4870 );
4871
4872 graph.row_height = px(20.0);
4873 let origin_y = px(100.0);
4874 graph.graph_canvas_bounds.set(Some(Bounds {
4875 origin: point(px(0.0), origin_y),
4876 size: gpui::size(px(100.0), px(1000.0)),
4877 }));
4878
4879 graph.table_interaction_state.update(cx, |state, _| {
4880 state.set_scroll_offset(point(px(0.0), px(-15.0)))
4881 });
4882 let pos_y = origin_y + px(10.0);
4883 let absolute_calc_row = graph.row_at_position(pos_y, cx);
4884
4885 assert_eq!(
4886 absolute_calc_row,
4887 Some(1),
4888 "Row calculation should yield absolute row exactly"
4889 );
4890 });
4891 }
4892}