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