Skip to repository content7556 lines · 284.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:28:28.063Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
git_graph.rs
1pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView};
2use crate::{
3 commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu},
4 commit_tooltip::CommitAvatar,
5 commit_view::CommitView,
6 git_status_icon,
7};
8use collections::{BTreeMap, HashMap, IndexSet};
9use editor::Editor;
10use file_icons::FileIcons;
11use git::{
12 BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
13 parse_git_remote_url,
14 repository::{
15 CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath,
16 SearchCommitArgs,
17 },
18 status::{FileStatus, StatusCode, TrackedStatus},
19};
20use gpui::{
21 Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent,
22 DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla,
23 MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy,
24 ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement,
25 UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
26 px, uniform_list,
27};
28use language::line_diff;
29use markdown::{Markdown, MarkdownElement};
30use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
31use picker::{Picker, PickerDelegate};
32use project::{
33 ProjectPath,
34 git_store::{
35 CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository,
36 RepositoryEvent, RepositoryId,
37 },
38};
39use search::{
40 SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
41 ToggleCaseSensitive, buffer_search,
42};
43use smallvec::{SmallVec, smallvec};
44use std::{
45 cell::Cell,
46 ops::Range,
47 rc::Rc,
48 sync::{Arc, OnceLock},
49 time::{Duration, Instant},
50};
51
52use theme::AccentColors;
53use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
54use ui::{
55 Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
56 HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing,
57 RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
58 TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns,
59 prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths,
60 render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow,
61};
62use util::{ResultExt, debug_panic};
63use workspace::{
64 ModalView, Workspace,
65 item::{Item, ItemEvent, TabTooltipContent},
66};
67
68const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);
69const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
70const LANE_WIDTH: Pixels = px(16.0);
71const LEFT_PADDING: Pixels = px(12.0);
72const LINE_WIDTH: Pixels = px(1.5);
73const RESIZE_HANDLE_WIDTH: f32 = 8.0;
74const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
75const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.);
76const TREE_INDENT: f32 = 20.0;
77const TABLE_COLUMN_COUNT: usize = 4;
78const ROW_VERTICAL_PADDING: Pixels = px(4.0);
79
80struct CopiedState {
81 copied_at: Option<Instant>,
82}
83
84impl CopiedState {
85 fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
86 Self { copied_at: None }
87 }
88
89 fn is_copied(&self) -> bool {
90 self.copied_at
91 .map(|t| t.elapsed() < COPIED_STATE_DURATION)
92 .unwrap_or(false)
93 }
94
95 fn mark_copied(&mut self) {
96 self.copied_at = Some(Instant::now());
97 }
98}
99
100struct DraggedSplitHandle;
101
102struct CommitTagPicker {
103 picker: Entity<Picker<CommitTagPickerDelegate>>,
104}
105
106impl CommitTagPicker {
107 fn new(tag_names: Vec<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
108 let delegate = CommitTagPickerDelegate {
109 picker: cx.entity().downgrade(),
110 tag_names,
111 selected_index: 0,
112 };
113 let picker = cx.new(|cx| {
114 Picker::nonsearchable_uniform_list(delegate, window, cx)
115 .initial_width(COMMIT_TAG_LIST_WIDTH_IN_REMS)
116 });
117 Self { picker }
118 }
119}
120
121impl EventEmitter<DismissEvent> for CommitTagPicker {}
122impl ModalView for CommitTagPicker {}
123
124impl Focusable for CommitTagPicker {
125 fn focus_handle(&self, cx: &App) -> FocusHandle {
126 self.picker.focus_handle(cx)
127 }
128}
129
130impl Render for CommitTagPicker {
131 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
132 v_flex().child(self.picker.clone())
133 }
134}
135
136struct CommitTagPickerDelegate {
137 picker: WeakEntity<CommitTagPicker>,
138 tag_names: Vec<SharedString>,
139 selected_index: usize,
140}
141
142impl PickerDelegate for CommitTagPickerDelegate {
143 type ListItem = ListItem;
144
145 fn name() -> &'static str {
146 "commit-tag"
147 }
148
149 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
150 "Copy Tag".into()
151 }
152
153 fn match_count(&self) -> usize {
154 self.tag_names.len()
155 }
156
157 fn selected_index(&self) -> usize {
158 self.selected_index
159 }
160
161 fn set_selected_index(
162 &mut self,
163 ix: usize,
164 _window: &mut Window,
165 _cx: &mut Context<Picker<Self>>,
166 ) {
167 self.selected_index = ix;
168 }
169
170 fn update_matches(
171 &mut self,
172 _query: String,
173 _window: &mut Window,
174 _cx: &mut Context<Picker<Self>>,
175 ) -> Task<()> {
176 Task::ready(())
177 }
178
179 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
180 if let Some(tag_name) = self.tag_names.get(self.selected_index) {
181 cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string()));
182 }
183 self.dismissed(window, cx);
184 }
185
186 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
187 self.picker
188 .update(cx, |_this, cx| cx.emit(DismissEvent))
189 .ok();
190 }
191
192 fn render_match(
193 &self,
194 ix: usize,
195 selected: bool,
196 _window: &mut Window,
197 _cx: &mut Context<Picker<Self>>,
198 ) -> Option<Self::ListItem> {
199 Some(
200 ListItem::new(ix)
201 .inset(true)
202 .spacing(ListItemSpacing::Sparse)
203 .toggle_state(selected)
204 .child(Label::new(self.tag_names.get(ix)?.clone())),
205 )
206 }
207}
208
209#[derive(Clone)]
210struct ChangedFileEntry {
211 status: FileStatus,
212 file_name: SharedString,
213 dir_path: SharedString,
214 repo_path: RepoPath,
215}
216
217impl ChangedFileEntry {
218 fn from_commit_file(file: &CommitFile, _cx: &App) -> Self {
219 let file_name: SharedString = file
220 .path
221 .file_name()
222 .map(|n| n.to_string())
223 .unwrap_or_default()
224 .into();
225 let dir_path: SharedString = file
226 .path
227 .parent()
228 .map(|p| p.as_unix_str().to_string())
229 .unwrap_or_default()
230 .into();
231
232 let status_code = match (&file.old_text, &file.new_text) {
233 (None, Some(_)) => StatusCode::Added,
234 (Some(_), None) => StatusCode::Deleted,
235 _ => StatusCode::Modified,
236 };
237
238 let status = FileStatus::Tracked(TrackedStatus {
239 index_status: status_code,
240 worktree_status: StatusCode::Unmodified,
241 });
242
243 Self {
244 status,
245 file_name,
246 dir_path,
247 repo_path: file.path.clone(),
248 }
249 }
250
251 fn open_in_commit_view(
252 &self,
253 commit_sha: &SharedString,
254 repository: &WeakEntity<Repository>,
255 workspace: &WeakEntity<Workspace>,
256 window: &mut Window,
257 cx: &mut App,
258 ) {
259 CommitView::open(
260 commit_sha.to_string(),
261 repository.clone(),
262 workspace.clone(),
263 None,
264 Some(self.repo_path.clone()),
265 window,
266 cx,
267 );
268 }
269
270 fn render(
271 &self,
272 ix: usize,
273 depth: usize,
274 directory_label: Option<SharedString>,
275 commit_sha: SharedString,
276 repository: WeakEntity<Repository>,
277 workspace: WeakEntity<Workspace>,
278 _cx: &App,
279 ) -> AnyElement {
280 let file_name = self.file_name.clone();
281 let dir_path = self.dir_path.clone();
282
283 ListItem::new(("changed-file", ix))
284 .spacing(ListItemSpacing::Sparse)
285 .indent_level(depth)
286 .indent_step_size(px(TREE_INDENT))
287 .start_slot(git_status_icon(self.status))
288 .child(
289 Label::new(file_name.clone())
290 .size(LabelSize::Small)
291 .truncate(),
292 )
293 .when_some(directory_label, |this, directory_label| {
294 this.child(
295 Label::new(directory_label)
296 .size(LabelSize::Small)
297 .color(Color::Muted)
298 .truncate_start(),
299 )
300 })
301 .tooltip({
302 let meta = if dir_path.is_empty() {
303 file_name
304 } else {
305 format!("{}/{}", dir_path, file_name).into()
306 };
307 move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx)
308 })
309 .on_click({
310 let entry = self.clone();
311 move |_, window, cx| {
312 entry.open_in_commit_view(&commit_sha, &repository, &workspace, window, cx);
313 }
314 })
315 .into_any_element()
316 }
317}
318
319enum ChangedFileTreeEntry {
320 Directory(ChangedFileDirectoryEntry),
321 File(ChangedFileTreeStatusEntry),
322}
323
324struct ChangedFileTreeStatusEntry {
325 entry: ChangedFileEntry,
326 depth: usize,
327}
328
329#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
330enum ChangedFilesViewMode {
331 Flat,
332 #[default]
333 Tree,
334}
335
336impl ChangedFilesViewMode {
337 fn toggled(self) -> Self {
338 match self {
339 Self::Flat => Self::Tree,
340 Self::Tree => Self::Flat,
341 }
342 }
343
344 fn is_tree(self) -> bool {
345 matches!(self, Self::Tree)
346 }
347}
348
349struct ChangedFileDirectoryEntry {
350 path: RepoPath,
351 name: SharedString,
352 depth: usize,
353 expanded: bool,
354}
355
356impl ChangedFileDirectoryEntry {
357 fn render(&self, ix: usize, git_graph: WeakEntity<GitGraph>, cx: &App) -> AnyElement {
358 let path = self.path.clone();
359 let expanded = self.expanded;
360 let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx)
361 .map(|icon| {
362 Icon::from_path(icon)
363 .size(IconSize::Small)
364 .color(Color::Muted)
365 })
366 .unwrap_or_else(|| {
367 let icon = if expanded {
368 IconName::FolderOpen
369 } else {
370 IconName::Folder
371 };
372 Icon::new(icon).size(IconSize::Small).color(Color::Muted)
373 });
374
375 ListItem::new(("changed-file-dir", ix))
376 .spacing(ListItemSpacing::Sparse)
377 .indent_level(self.depth)
378 .indent_step_size(px(TREE_INDENT))
379 .start_slot(folder_icon)
380 .child(
381 Label::new(self.name.clone())
382 .size(LabelSize::Small)
383 .color(Color::Muted)
384 .truncate(),
385 )
386 .tooltip({
387 let name = self.name.clone();
388 move |_, cx| Tooltip::with_meta("Toggle Folder", None, name.clone(), cx)
389 })
390 .on_click(move |_, _, cx| {
391 git_graph
392 .update(cx, |git_graph, cx| {
393 git_graph
394 .changed_files_expanded_dirs
395 .insert(path.clone(), !expanded);
396 cx.notify();
397 })
398 .ok();
399 })
400 .into_any_element()
401 }
402}
403
404#[derive(Default)]
405struct ChangedFileTreeNode {
406 name: SharedString,
407 path: Option<RepoPath>,
408 children: BTreeMap<SharedString, ChangedFileTreeNode>,
409 files: Vec<ChangedFileEntry>,
410}
411
412fn build_changed_file_tree_entries(
413 mut files: Vec<ChangedFileEntry>,
414 expanded_dirs: &HashMap<RepoPath, bool>,
415) -> Vec<ChangedFileTreeEntry> {
416 files.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
417
418 let mut root = ChangedFileTreeNode::default();
419 for file in files {
420 let components: Vec<&str> = file.repo_path.components().collect();
421 if components.is_empty() {
422 root.files.push(file);
423 continue;
424 }
425
426 let mut current = &mut root;
427 let mut current_path = String::new();
428
429 for (ix, component) in components.iter().enumerate() {
430 if ix == components.len() - 1 {
431 current.files.push(file.clone());
432 } else {
433 if !current_path.is_empty() {
434 current_path.push('/');
435 }
436 current_path.push_str(component);
437
438 let Ok(dir_path) = RepoPath::new(¤t_path) else {
439 continue;
440 };
441 let component = SharedString::from(component.to_string());
442
443 current = current
444 .children
445 .entry(component.clone())
446 .or_insert_with(|| ChangedFileTreeNode {
447 name: component,
448 path: Some(dir_path),
449 ..Default::default()
450 });
451 }
452 }
453 }
454
455 flatten_changed_file_tree(&root, 0, expanded_dirs)
456}
457
458fn flatten_changed_file_tree(
459 node: &ChangedFileTreeNode,
460 depth: usize,
461 expanded_dirs: &HashMap<RepoPath, bool>,
462) -> Vec<ChangedFileTreeEntry> {
463 let mut entries = Vec::new();
464
465 for child in node.children.values() {
466 let (terminal, name) = compact_changed_file_directory_chain(child);
467 let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else {
468 continue;
469 };
470 let expanded = *expanded_dirs.get(&path).unwrap_or(&true);
471 let child_entries = flatten_changed_file_tree(terminal, depth + 1, expanded_dirs);
472
473 entries.push(ChangedFileTreeEntry::Directory(ChangedFileDirectoryEntry {
474 path,
475 name,
476 depth,
477 expanded,
478 }));
479
480 if expanded {
481 entries.extend(child_entries);
482 }
483 }
484
485 entries.extend(
486 node.files
487 .iter()
488 .cloned()
489 .map(|entry| ChangedFileTreeEntry::File(ChangedFileTreeStatusEntry { entry, depth })),
490 );
491 entries
492}
493
494fn compact_changed_file_directory_chain(
495 mut node: &ChangedFileTreeNode,
496) -> (&ChangedFileTreeNode, SharedString) {
497 let mut parts = vec![node.name.clone()];
498 while node.files.is_empty() && node.children.len() == 1 {
499 let Some(child) = node.children.values().next() else {
500 continue;
501 };
502 if child.path.is_none() {
503 break;
504 }
505 parts.push(child.name.clone());
506 node = child;
507 }
508 (node, SharedString::from(parts.join("/")))
509}
510
511enum QueryState {
512 Pending(SharedString),
513 Confirmed((SharedString, Task<()>)),
514 Empty,
515}
516
517impl QueryState {
518 fn next_state(&mut self) {
519 match self {
520 Self::Confirmed((query, _)) => *self = Self::Pending(std::mem::take(query)),
521 _ => {}
522 };
523 }
524}
525
526struct SearchState {
527 case_sensitive: bool,
528 editor: Entity<Editor>,
529 state: QueryState,
530 matches: IndexSet<Oid>,
531 selected_index: Option<usize>,
532}
533
534struct SplitState {
535 left_ratio: f32,
536 visible_left_ratio: f32,
537}
538
539impl SplitState {
540 fn new() -> Self {
541 Self {
542 left_ratio: 1.0,
543 visible_left_ratio: 1.0,
544 }
545 }
546
547 fn right_ratio(&self) -> f32 {
548 1.0 - self.visible_left_ratio
549 }
550
551 fn on_drag_move(
552 &mut self,
553 drag_event: &DragMoveEvent<DraggedSplitHandle>,
554 _window: &mut Window,
555 _cx: &mut Context<Self>,
556 ) {
557 let drag_position = drag_event.event.position;
558 let bounds = drag_event.bounds;
559 let bounds_width = bounds.right() - bounds.left();
560
561 let min_ratio = 0.1;
562 let max_ratio = 0.9;
563
564 let new_ratio = (drag_position.x - bounds.left()) / bounds_width;
565 self.visible_left_ratio = new_ratio.clamp(min_ratio, max_ratio);
566 }
567
568 fn commit_ratio(&mut self) {
569 self.left_ratio = self.visible_left_ratio;
570 }
571
572 fn on_double_click(&mut self) {
573 self.left_ratio = 1.0;
574 self.visible_left_ratio = 1.0;
575 }
576}
577
578actions!(
579 git_graph,
580 [
581 /// Opens the Git Graph Tab.
582 Open,
583 /// Focuses the search field.
584 FocusSearch,
585 /// Focuses the next git graph tab stop.
586 FocusNextTabStop,
587 /// Focuses the previous git graph tab stop.
588 FocusPreviousTabStop,
589 /// Selects a commit half a page above the current selection.
590 ScrollUp,
591 /// Selects a commit half a page below the current selection.
592 ScrollDown,
593 /// Toggles the selected commit's changed files between flat and tree views.
594 ToggleChangedFilesView,
595 ]
596);
597
598/// Opens the Git Graph Tab at a specific commit.
599#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)]
600#[action(namespace = git_graph)]
601pub struct OpenAtCommit {
602 pub sha: String,
603}
604
605fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
606 static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
607 FORMAT.get_or_init(|| {
608 time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
609 .unwrap_or_default()
610 })
611}
612
613fn format_timestamp(timestamp: i64) -> String {
614 let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
615 return "Unknown".to_string();
616 };
617
618 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
619 let local_datetime = datetime.to_offset(local_offset);
620
621 local_datetime
622 .format(timestamp_format())
623 .unwrap_or_default()
624}
625
626fn accent_colors_count(accents: &AccentColors) -> usize {
627 accents.0.len()
628}
629
630#[derive(Copy, Clone, Debug)]
631struct BranchColor(u8);
632
633#[derive(Debug)]
634enum LaneState {
635 Empty,
636 Active {
637 child: Oid,
638 parent: Oid,
639 color: Option<BranchColor>,
640 starting_row: usize,
641 starting_col: usize,
642 destination_column: Option<usize>,
643 segments: SmallVec<[CommitLineSegment; 1]>,
644 },
645}
646
647impl LaneState {
648 fn to_commit_lines(
649 &mut self,
650 ending_row: usize,
651 lane_column: usize,
652 parent_column: usize,
653 parent_color: BranchColor,
654 ) -> Option<CommitLine> {
655 let state = std::mem::replace(self, LaneState::Empty);
656
657 match state {
658 LaneState::Active {
659 #[cfg_attr(not(test), allow(unused_variables))]
660 parent,
661 #[cfg_attr(not(test), allow(unused_variables))]
662 child,
663 color,
664 starting_row,
665 starting_col,
666 destination_column,
667 mut segments,
668 } => {
669 let final_destination = destination_column.unwrap_or(parent_column);
670 let final_color = color.unwrap_or(parent_color);
671
672 Some(CommitLine {
673 #[cfg(test)]
674 child,
675 #[cfg(test)]
676 parent,
677 child_column: starting_col,
678 full_interval: starting_row..ending_row,
679 color_idx: final_color.0 as usize,
680 segments: {
681 match segments.last_mut() {
682 Some(CommitLineSegment::Straight { to_row })
683 if *to_row == usize::MAX =>
684 {
685 if final_destination != lane_column {
686 *to_row = ending_row - 1;
687
688 let curved_line = CommitLineSegment::Curve {
689 to_column: final_destination,
690 on_row: ending_row,
691 curve_kind: CurveKind::Checkout,
692 };
693
694 if *to_row == starting_row {
695 let last_index = segments.len() - 1;
696 segments[last_index] = curved_line;
697 } else {
698 segments.push(curved_line);
699 }
700 } else {
701 *to_row = ending_row;
702 }
703 }
704 Some(CommitLineSegment::Curve {
705 on_row,
706 to_column,
707 curve_kind,
708 }) if *on_row == usize::MAX => {
709 if *to_column == usize::MAX {
710 *to_column = final_destination;
711 }
712 if matches!(curve_kind, CurveKind::Merge) {
713 *on_row = starting_row + 1;
714 if *on_row < ending_row {
715 if *to_column != final_destination {
716 segments.push(CommitLineSegment::Straight {
717 to_row: ending_row - 1,
718 });
719 segments.push(CommitLineSegment::Curve {
720 to_column: final_destination,
721 on_row: ending_row,
722 curve_kind: CurveKind::Checkout,
723 });
724 } else {
725 segments.push(CommitLineSegment::Straight {
726 to_row: ending_row,
727 });
728 }
729 } else if *to_column != final_destination {
730 segments.push(CommitLineSegment::Curve {
731 to_column: final_destination,
732 on_row: ending_row,
733 curve_kind: CurveKind::Checkout,
734 });
735 }
736 } else {
737 *on_row = ending_row;
738 if *to_column != final_destination {
739 segments.push(CommitLineSegment::Straight {
740 to_row: ending_row,
741 });
742 segments.push(CommitLineSegment::Curve {
743 to_column: final_destination,
744 on_row: ending_row,
745 curve_kind: CurveKind::Checkout,
746 });
747 }
748 }
749 }
750 Some(CommitLineSegment::Curve {
751 on_row, to_column, ..
752 }) => {
753 if *on_row < ending_row {
754 if *to_column != final_destination {
755 segments.push(CommitLineSegment::Straight {
756 to_row: ending_row - 1,
757 });
758 segments.push(CommitLineSegment::Curve {
759 to_column: final_destination,
760 on_row: ending_row,
761 curve_kind: CurveKind::Checkout,
762 });
763 } else {
764 segments.push(CommitLineSegment::Straight {
765 to_row: ending_row,
766 });
767 }
768 } else if *to_column != final_destination {
769 segments.push(CommitLineSegment::Curve {
770 to_column: final_destination,
771 on_row: ending_row,
772 curve_kind: CurveKind::Checkout,
773 });
774 }
775 }
776 _ => {}
777 }
778
779 segments
780 },
781 })
782 }
783 LaneState::Empty => None,
784 }
785 }
786
787 fn is_empty(&self) -> bool {
788 match self {
789 LaneState::Empty => true,
790 LaneState::Active { .. } => false,
791 }
792 }
793}
794
795struct CommitEntry {
796 data: Arc<InitialGraphCommitData>,
797 lane: usize,
798 color_idx: usize,
799}
800
801type ActiveLaneIdx = usize;
802
803enum AllCommitCount {
804 NotLoaded,
805 Loading(usize),
806 FullyLoaded(usize),
807}
808
809#[derive(Debug)]
810enum CurveKind {
811 Merge,
812 Checkout,
813}
814
815#[derive(Debug)]
816enum CommitLineSegment {
817 Straight {
818 to_row: usize,
819 },
820 Curve {
821 to_column: usize,
822 on_row: usize,
823 curve_kind: CurveKind,
824 },
825}
826
827#[derive(Debug)]
828struct CommitLine {
829 #[cfg(test)]
830 child: Oid,
831 #[cfg(test)]
832 parent: Oid,
833 child_column: usize,
834 full_interval: Range<usize>,
835 color_idx: usize,
836 segments: SmallVec<[CommitLineSegment; 1]>,
837}
838
839impl CommitLine {
840 fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
841 if first_visible_row > self.full_interval.end {
842 return None;
843 } else if first_visible_row <= self.full_interval.start {
844 return Some((0, self.child_column));
845 }
846
847 let mut current_column = self.child_column;
848
849 for (idx, segment) in self.segments.iter().enumerate() {
850 match segment {
851 CommitLineSegment::Straight { to_row } => {
852 if *to_row >= first_visible_row {
853 return Some((idx, current_column));
854 }
855 }
856 CommitLineSegment::Curve {
857 to_column, on_row, ..
858 } => {
859 if *on_row >= first_visible_row {
860 return Some((idx, current_column));
861 }
862 current_column = *to_column;
863 }
864 }
865 }
866
867 None
868 }
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
872struct CommitLineKey {
873 child: Oid,
874 parent: Oid,
875}
876
877struct GraphData {
878 lane_states: SmallVec<[LaneState; 8]>,
879 lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
880 parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
881 next_color: BranchColor,
882 accent_colors_count: usize,
883 commits: Vec<Rc<CommitEntry>>,
884 max_commit_count: AllCommitCount,
885 max_lanes: usize,
886 lines: Vec<Rc<CommitLine>>,
887 active_commit_lines: HashMap<CommitLineKey, usize>,
888 active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
889}
890
891impl GraphData {
892 fn new(accent_colors_count: usize) -> Self {
893 GraphData {
894 lane_states: SmallVec::default(),
895 lane_colors: HashMap::default(),
896 parent_to_lanes: HashMap::default(),
897 next_color: BranchColor(0),
898 accent_colors_count,
899 commits: Vec::default(),
900 max_commit_count: AllCommitCount::NotLoaded,
901 max_lanes: 0,
902 lines: Vec::default(),
903 active_commit_lines: HashMap::default(),
904 active_commit_lines_by_parent: HashMap::default(),
905 }
906 }
907
908 fn clear(&mut self) {
909 self.lane_states.clear();
910 self.lane_colors.clear();
911 self.parent_to_lanes.clear();
912 self.commits.clear();
913 self.lines.clear();
914 self.active_commit_lines.clear();
915 self.active_commit_lines_by_parent.clear();
916 self.next_color = BranchColor(0);
917 self.max_commit_count = AllCommitCount::NotLoaded;
918 self.max_lanes = 0;
919 }
920
921 fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
922 self.lane_states
923 .iter()
924 .position(LaneState::is_empty)
925 .unwrap_or_else(|| {
926 self.lane_states.push(LaneState::Empty);
927 self.lane_states.len() - 1
928 })
929 }
930
931 fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
932 let accent_colors_count = self.accent_colors_count;
933 *self.lane_colors.entry(lane_idx).or_insert_with(|| {
934 let color_idx = self.next_color;
935 self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
936 color_idx
937 })
938 }
939
940 fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
941 self.commits.reserve(commits.len());
942 self.lines.reserve(commits.len() / 2);
943
944 for commit in commits.iter() {
945 let commit_row = self.commits.len();
946
947 let commit_lane = self
948 .parent_to_lanes
949 .get(&commit.sha)
950 .and_then(|lanes| lanes.iter().min().copied());
951
952 let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
953
954 let commit_color = self.get_lane_color(commit_lane);
955
956 if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
957 for lane_column in lanes {
958 let state = &mut self.lane_states[lane_column];
959
960 if let LaneState::Active {
961 starting_row,
962 segments,
963 ..
964 } = state
965 {
966 if let Some(CommitLineSegment::Curve {
967 to_column,
968 curve_kind: CurveKind::Merge,
969 ..
970 }) = segments.first_mut()
971 {
972 let curve_row = *starting_row + 1;
973 let would_overlap =
974 if lane_column != commit_lane && curve_row < commit_row {
975 self.commits[curve_row..commit_row]
976 .iter()
977 .any(|c| c.lane == commit_lane)
978 } else {
979 false
980 };
981
982 if would_overlap {
983 *to_column = lane_column;
984 }
985 }
986 }
987
988 if let Some(commit_line) =
989 state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color)
990 {
991 self.lines.push(Rc::new(commit_line));
992 }
993 }
994 }
995
996 commit
997 .parents
998 .iter()
999 .enumerate()
1000 .for_each(|(parent_idx, parent)| {
1001 if parent_idx == 0 {
1002 self.lane_states[commit_lane] = LaneState::Active {
1003 parent: *parent,
1004 child: commit.sha,
1005 color: Some(commit_color),
1006 starting_col: commit_lane,
1007 starting_row: commit_row,
1008 destination_column: None,
1009 segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
1010 };
1011
1012 self.parent_to_lanes
1013 .entry(*parent)
1014 .or_default()
1015 .push(commit_lane);
1016 } else {
1017 let new_lane = self.first_empty_lane_idx();
1018
1019 self.lane_states[new_lane] = LaneState::Active {
1020 parent: *parent,
1021 child: commit.sha,
1022 color: None,
1023 starting_col: commit_lane,
1024 starting_row: commit_row,
1025 destination_column: None,
1026 segments: smallvec![CommitLineSegment::Curve {
1027 to_column: usize::MAX,
1028 on_row: usize::MAX,
1029 curve_kind: CurveKind::Merge,
1030 },],
1031 };
1032
1033 self.parent_to_lanes
1034 .entry(*parent)
1035 .or_default()
1036 .push(new_lane);
1037 }
1038 });
1039
1040 self.max_lanes = self.max_lanes.max(self.lane_states.len());
1041
1042 self.commits.push(Rc::new(CommitEntry {
1043 data: commit.clone(),
1044 lane: commit_lane,
1045 color_idx: commit_color.0 as usize,
1046 }));
1047 }
1048
1049 self.max_commit_count = AllCommitCount::Loading(self.commits.len());
1050 }
1051}
1052
1053pub fn init(cx: &mut App) {
1054 workspace::register_serializable_item::<GitGraph>(cx);
1055
1056 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
1057 workspace.register_action_renderer(|div, workspace, window, cx| {
1058 div.when_some(
1059 resolve_file_history_target(workspace, window, cx),
1060 |div, (repo_id, log_source)| {
1061 let git_store = workspace.project().read(cx).git_store().clone();
1062 let workspace = workspace.weak_handle();
1063
1064 div.on_action(move |_: &git::FileHistory, window, cx| {
1065 let git_store = git_store.clone();
1066 workspace
1067 .update(cx, |workspace, cx| {
1068 open_or_reuse_graph(
1069 workspace,
1070 repo_id,
1071 git_store,
1072 log_source.clone(),
1073 None,
1074 window,
1075 cx,
1076 );
1077 })
1078 .ok();
1079 })
1080 },
1081 )
1082 .when(
1083 workspace.project().read(cx).active_repository(cx).is_some(),
1084 |div| {
1085 let workspace = workspace.weak_handle();
1086
1087 div.on_action({
1088 let workspace = workspace.clone();
1089 move |_: &Open, window, cx| {
1090 workspace
1091 .update(cx, |workspace, cx| {
1092 let Some(repo) =
1093 workspace.project().read(cx).active_repository(cx)
1094 else {
1095 return;
1096 };
1097 let selected_repo_id = repo.read(cx).id;
1098
1099 let git_store =
1100 workspace.project().read(cx).git_store().clone();
1101 open_or_reuse_graph(
1102 workspace,
1103 selected_repo_id,
1104 git_store,
1105 LogSource::All,
1106 None,
1107 window,
1108 cx,
1109 );
1110 })
1111 .ok();
1112 }
1113 })
1114 .on_action(move |action: &OpenAtCommit, window, cx| {
1115 let sha = action.sha.clone();
1116 workspace
1117 .update(cx, |workspace, cx| {
1118 let Some(repo) = workspace.project().read(cx).active_repository(cx)
1119 else {
1120 return;
1121 };
1122 let selected_repo_id = repo.read(cx).id;
1123
1124 let git_store = workspace.project().read(cx).git_store().clone();
1125 open_or_reuse_graph(
1126 workspace,
1127 selected_repo_id,
1128 git_store,
1129 LogSource::All,
1130 Some(sha),
1131 window,
1132 cx,
1133 );
1134 })
1135 .ok();
1136 })
1137 },
1138 )
1139 });
1140 })
1141 .detach();
1142}
1143
1144/// Resolves a `git::FileHistory` target from a known project path (used by
1145/// callers like `project_panel` that own a focused selection but cannot be
1146/// referenced from this module due to dependency direction).
1147pub fn resolve_file_history_target_from_project_path(
1148 workspace: &Workspace,
1149 project_path: &ProjectPath,
1150 cx: &App,
1151) -> Option<(RepositoryId, LogSource)> {
1152 let git_store = workspace.project().read(cx).git_store();
1153 let (repo, repo_path) = git_store
1154 .read(cx)
1155 .repository_and_path_for_project_path(project_path, cx)?;
1156 let log_source = if repo_path.is_empty() {
1157 LogSource::All
1158 } else {
1159 LogSource::Path(repo_path)
1160 };
1161 Some((repo.read(cx).id, log_source))
1162}
1163
1164fn resolve_file_history_target(
1165 workspace: &Workspace,
1166 window: &Window,
1167 cx: &App,
1168) -> Option<(RepositoryId, LogSource)> {
1169 if let Some(panel) = workspace.panel::<crate::git_panel::GitPanel>(cx)
1170 && panel.read(cx).focus_handle(cx).contains_focused(window, cx)
1171 && let Some((repository, repo_path)) = panel.read(cx).selected_file_history_target()
1172 {
1173 return Some((repository.read(cx).id, LogSource::Path(repo_path)));
1174 }
1175
1176 let editor = workspace.active_item_as::<Editor>(cx)?;
1177
1178 let file = editor
1179 .read(cx)
1180 .file_at(editor.read(cx).selections.newest_anchor().head(), cx)?;
1181 let project_path = ProjectPath {
1182 worktree_id: file.worktree_id(cx),
1183 path: file.path().clone(),
1184 };
1185
1186 let git_store = workspace.project().read(cx).git_store();
1187 let (repo, repo_path) = git_store
1188 .read(cx)
1189 .repository_and_path_for_project_path(&project_path, cx)?;
1190 Some((repo.read(cx).id, LogSource::Path(repo_path)))
1191}
1192
1193pub fn open_or_reuse_graph(
1194 workspace: &mut Workspace,
1195 repo_id: RepositoryId,
1196 git_store: Entity<GitStore>,
1197 log_source: LogSource,
1198 sha: Option<String>,
1199 window: &mut Window,
1200 cx: &mut Context<Workspace>,
1201) {
1202 let existing = workspace.items_of_type::<GitGraph>(cx).find(|graph| {
1203 let graph = graph.read(cx);
1204 graph.repo_id == repo_id && graph.log_source == log_source
1205 });
1206
1207 let git_graph = if let Some(existing) = existing {
1208 workspace.activate_item(&existing, true, true, window, cx);
1209 existing
1210 } else {
1211 let workspace_handle = workspace.weak_handle();
1212 let git_graph = cx.new(|cx| {
1213 GitGraph::new(
1214 repo_id,
1215 git_store,
1216 workspace_handle,
1217 Some(log_source),
1218 window,
1219 cx,
1220 )
1221 });
1222 workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
1223 git_graph
1224 };
1225
1226 if let Some(sha) = sha {
1227 cx.defer(move |cx| {
1228 git_graph.update(cx, |graph, cx| {
1229 graph.select_commit_by_sha(sha.as_str(), cx);
1230 });
1231 });
1232 }
1233}
1234
1235fn lane_center_x(bounds: Bounds<Pixels>, lane: f32) -> Pixels {
1236 bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0
1237}
1238
1239fn to_row_center(
1240 to_row: usize,
1241 row_height: Pixels,
1242 scroll_offset: Pixels,
1243 bounds: Bounds<Pixels>,
1244) -> Pixels {
1245 bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
1246}
1247
1248fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
1249 let radius = COMMIT_CIRCLE_RADIUS;
1250
1251 let mut builder = PathBuilder::fill();
1252
1253 // Start at the rightmost point of the circle
1254 builder.move_to(point(center_x + radius, center_y));
1255
1256 // Draw the circle using two arc_to calls (top half, then bottom half)
1257 builder.arc_to(
1258 point(radius, radius),
1259 px(0.),
1260 false,
1261 true,
1262 point(center_x - radius, center_y),
1263 );
1264 builder.arc_to(
1265 point(radius, radius),
1266 px(0.),
1267 false,
1268 true,
1269 point(center_x + radius, center_y),
1270 );
1271 builder.close();
1272
1273 if let Ok(path) = builder.build() {
1274 window.paint_path(path, color);
1275 }
1276}
1277
1278fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
1279 diff.files.iter().fold((0, 0), |(added, removed), file| {
1280 let old_text = file.old_text.as_deref().unwrap_or("");
1281 let new_text = file.new_text.as_deref().unwrap_or("");
1282 let hunks = line_diff(old_text, new_text);
1283 hunks
1284 .iter()
1285 .fold((added, removed), |(a, r), (old_range, new_range)| {
1286 (
1287 a + (new_range.end - new_range.start) as usize,
1288 r + (old_range.end - old_range.start) as usize,
1289 )
1290 })
1291 })
1292}
1293
1294struct GitGraphContextMenu {
1295 menu: Entity<ContextMenu>,
1296 position: Point<Pixels>,
1297 target_entry_index: Option<usize>,
1298 _subscription: Subscription,
1299}
1300
1301struct DetailPanelCommitMessage {
1302 sha: Oid,
1303 message: Entity<Markdown>,
1304 scroll_handle: ScrollHandle,
1305}
1306
1307pub struct GitGraph {
1308 focus_handle: FocusHandle,
1309 search_state: SearchState,
1310 graph_data: GraphData,
1311 git_store: Entity<GitStore>,
1312 workspace: WeakEntity<Workspace>,
1313 context_menu: Option<GitGraphContextMenu>,
1314 table_interaction_state: Entity<TableInteractionState>,
1315 column_widths: Entity<RedistributableColumnsState>,
1316 /// Per-column visibility mask owned by the view (not the resize state) so columns can be
1317 /// hidden regardless of whether the table is resizable. `true` means the column is hidden.
1318 column_visibility: TableRow<bool>,
1319 selected_entry_idx: Option<usize>,
1320 hovered_entry_idx: Option<usize>,
1321 graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
1322 log_source: LogSource,
1323 log_order: LogOrder,
1324 selected_commit_diff: Option<CommitDiff>,
1325 selected_commit_diff_stats: Option<(usize, usize)>,
1326 _commit_diff_task: Option<Task<()>>,
1327 selected_commit_message: Option<DetailPanelCommitMessage>,
1328 _selected_commit_message_task: Option<Task<()>>,
1329 commit_details_split_state: Entity<SplitState>,
1330 repo_id: RepositoryId,
1331 changed_files_scroll_handle: UniformListScrollHandle,
1332 changed_files_view_mode: ChangedFilesViewMode,
1333 changed_files_expanded_dirs: HashMap<RepoPath, bool>,
1334 pending_select_sha: Option<Oid>,
1335}
1336
1337impl GitGraph {
1338 fn invalidate_state(&mut self, cx: &mut Context<Self>) {
1339 self.graph_data.clear();
1340 self.search_state.matches.clear();
1341 self.search_state.selected_index = None;
1342 self.search_state.state.next_state();
1343 self.context_menu = None;
1344 cx.emit(ItemEvent::Edit);
1345 cx.notify();
1346 }
1347
1348 /// Computes the height of a single commit row in the git graph.
1349 ///
1350 /// The returned value is snapped to the nearest physical pixel. This is
1351 /// required so that the canvas's float math and the `uniform_list` layout
1352 /// (which snaps to device pixels) agree on row positions; otherwise rows
1353 /// drift apart as the user scrolls when `ui_font_size` is fractional.
1354 fn row_height(window: &Window, _cx: &App) -> Pixels {
1355 let rem_size = window.rem_size();
1356 let line_height = window.text_style().line_height_in_pixels(rem_size);
1357 let raw = line_height + ROW_VERTICAL_PADDING;
1358 let scale = window.scale_factor();
1359
1360 (raw * scale).round() / scale
1361 }
1362
1363 fn visible_row_count(&self, window: &Window, cx: &App) -> usize {
1364 let row_height = Self::row_height(window, cx);
1365 let viewport_height = self
1366 .table_interaction_state
1367 .read(cx)
1368 .scroll_handle
1369 .0
1370 .borrow()
1371 .last_item_size
1372 .map_or(window.viewport_size().height, |size| size.item.height);
1373
1374 ((viewport_height / row_height).ceil() as usize).min(self.graph_data.commits.len())
1375 }
1376
1377 fn graph_canvas_content_width(&self) -> Pixels {
1378 (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0
1379 }
1380
1381 fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] {
1382 let raw = self
1383 .column_widths
1384 .read(cx)
1385 .preview_fractions(window.rem_size());
1386 let fractions = redistribute_hidden_fractions(&raw, Some(&self.column_visibility));
1387
1388 // Hidden columns occupy no space in the layout, so report them as zero here even though
1389 // the shared redistribution helper preserves their stored width for when they return.
1390 let value = |idx: usize| {
1391 if self.column_visibility.get(idx).copied().unwrap_or(false) {
1392 0.0
1393 } else {
1394 fractions[idx]
1395 }
1396 };
1397
1398 let is_path_history = matches!(self.log_source, LogSource::Path(_));
1399 let graph_fraction = if is_path_history { 0.0 } else { value(0) };
1400 let offset = if is_path_history { 0 } else { 1 };
1401
1402 [
1403 graph_fraction,
1404 value(offset),
1405 value(offset + 1),
1406 value(offset + 2),
1407 value(offset + 3),
1408 ]
1409 }
1410
1411 fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig {
1412 let [_, description, date, author, commit] = self.preview_column_fractions(window, cx);
1413 let table_total = description + date + author + commit;
1414
1415 let widths = if table_total > 0.0 {
1416 vec![
1417 DefiniteLength::Fraction(description / table_total),
1418 DefiniteLength::Fraction(date / table_total),
1419 DefiniteLength::Fraction(author / table_total),
1420 DefiniteLength::Fraction(commit / table_total),
1421 ]
1422 } else {
1423 vec![
1424 DefiniteLength::Fraction(0.25),
1425 DefiniteLength::Fraction(0.25),
1426 DefiniteLength::Fraction(0.25),
1427 DefiniteLength::Fraction(0.25),
1428 ]
1429 };
1430
1431 ColumnWidthConfig::explicit(widths)
1432 }
1433
1434 fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels {
1435 let container = self.column_widths.read(cx).cached_container_width();
1436 let graph_fraction = self.preview_column_fractions(window, cx)[0];
1437 if container > px(0.) && graph_fraction > 0.0 {
1438 container * graph_fraction
1439 } else {
1440 self.graph_canvas_content_width()
1441 }
1442 }
1443
1444 pub fn new(
1445 repo_id: RepositoryId,
1446 git_store: Entity<GitStore>,
1447 workspace: WeakEntity<Workspace>,
1448 log_source: Option<LogSource>,
1449 window: &mut Window,
1450 cx: &mut Context<Self>,
1451 ) -> Self {
1452 let focus_handle = cx.focus_handle();
1453 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
1454 .detach();
1455
1456 let accent_colors = cx.theme().accents();
1457 let graph = GraphData::new(accent_colors_count(accent_colors));
1458 let log_source = log_source.unwrap_or_default();
1459 let log_order = LogOrder::default();
1460
1461 cx.subscribe(&git_store, |this, _, event, cx| match event {
1462 GitStoreEvent::RepositoryUpdated(updated_repo_id, repo_event, _) => {
1463 if this.repo_id == *updated_repo_id {
1464 if let Some(repository) = this.get_repository(cx) {
1465 this.on_repository_event(repository, repo_event, cx);
1466 }
1467 }
1468 }
1469 _ => {}
1470 })
1471 .detach();
1472
1473 let search_editor = cx.new(|cx| {
1474 let mut editor = Editor::single_line(window, cx);
1475 editor.set_placeholder_text("Search commits…", window, cx);
1476 editor
1477 });
1478
1479 let table_interaction_state = cx.new(|cx| {
1480 let mut state = TableInteractionState::new(cx);
1481 state.focus_handle = state.focus_handle.tab_index(1).tab_stop(true);
1482 state
1483 });
1484
1485 let column_widths = if matches!(log_source, LogSource::Path(_)) {
1486 cx.new(|_cx| {
1487 RedistributableColumnsState::new(
1488 4,
1489 vec![
1490 DefiniteLength::Fraction(0.72),
1491 DefiniteLength::Fraction(0.12),
1492 DefiniteLength::Fraction(0.1),
1493 DefiniteLength::Fraction(0.06),
1494 ],
1495 vec![
1496 TableResizeBehavior::Resizable,
1497 TableResizeBehavior::Resizable,
1498 TableResizeBehavior::Resizable,
1499 TableResizeBehavior::Resizable,
1500 ],
1501 )
1502 })
1503 } else {
1504 cx.new(|_cx| {
1505 RedistributableColumnsState::new(
1506 5,
1507 vec![
1508 DefiniteLength::Fraction(0.14),
1509 DefiniteLength::Fraction(0.6192),
1510 DefiniteLength::Fraction(0.1032),
1511 DefiniteLength::Fraction(0.086),
1512 DefiniteLength::Fraction(0.0516),
1513 ],
1514 vec![
1515 TableResizeBehavior::Resizable,
1516 TableResizeBehavior::Resizable,
1517 TableResizeBehavior::Resizable,
1518 TableResizeBehavior::Resizable,
1519 TableResizeBehavior::Resizable,
1520 ],
1521 )
1522 })
1523 };
1524 let column_visibility = TableRow::from_element(
1525 false,
1526 if matches!(log_source, LogSource::Path(_)) {
1527 TABLE_COLUMN_COUNT
1528 } else {
1529 TABLE_COLUMN_COUNT + 1
1530 },
1531 );
1532 let mut row_height = Self::row_height(window, cx);
1533
1534 cx.observe_global_in::<settings::SettingsStore>(window, move |this, window, cx| {
1535 let new_row_height = Self::row_height(window, cx);
1536 if new_row_height != row_height {
1537 // The `uniform_list` powering the table caches the item size
1538 // from its last layout; invalidate it so it re-measures with
1539 // the new row height on the next frame.
1540 this.table_interaction_state.update(cx, |state, _cx| {
1541 state.scroll_handle.0.borrow_mut().last_item_size = None;
1542 });
1543 row_height = new_row_height;
1544 cx.notify();
1545 }
1546 })
1547 .detach();
1548
1549 let mut this = GitGraph {
1550 focus_handle,
1551 git_store,
1552 search_state: SearchState {
1553 case_sensitive: false,
1554 editor: search_editor,
1555 matches: IndexSet::default(),
1556 selected_index: None,
1557 state: QueryState::Empty,
1558 },
1559 workspace,
1560 graph_data: graph,
1561 _commit_diff_task: None,
1562 context_menu: None,
1563 table_interaction_state,
1564 column_widths,
1565 column_visibility,
1566 selected_entry_idx: None,
1567 hovered_entry_idx: None,
1568 graph_canvas_bounds: Rc::new(Cell::new(None)),
1569 selected_commit_diff: None,
1570 selected_commit_diff_stats: None,
1571 selected_commit_message: None,
1572 _selected_commit_message_task: None,
1573 log_source,
1574 log_order,
1575 commit_details_split_state: cx.new(|_cx| SplitState::new()),
1576 repo_id,
1577 changed_files_scroll_handle: UniformListScrollHandle::new(),
1578 changed_files_view_mode: ChangedFilesViewMode::default(),
1579 changed_files_expanded_dirs: HashMap::default(),
1580 pending_select_sha: None,
1581 };
1582
1583 this.fetch_initial_graph_data(cx);
1584 this
1585 }
1586
1587 fn on_repository_event(
1588 &mut self,
1589 repository: Entity<Repository>,
1590 event: &RepositoryEvent,
1591 cx: &mut Context<Self>,
1592 ) {
1593 match event {
1594 RepositoryEvent::GraphEvent((source, order), event)
1595 if source == &self.log_source && order == &self.log_order =>
1596 {
1597 match event {
1598 GitGraphEvent::FullyLoaded => {
1599 if let Some(pending_sha_index) =
1600 self.pending_select_sha.take().and_then(|oid| {
1601 repository
1602 .read(cx)
1603 .get_graph_data(source.clone(), *order)
1604 .and_then(|data| data.commit_oid_to_index.get(&oid).copied())
1605 })
1606 {
1607 self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx);
1608 }
1609 let count = match self.graph_data.max_commit_count {
1610 AllCommitCount::FullyLoaded(count) | AllCommitCount::Loading(count) => {
1611 count
1612 }
1613 AllCommitCount::NotLoaded => 0,
1614 };
1615 self.graph_data.max_commit_count = AllCommitCount::FullyLoaded(count);
1616 cx.notify();
1617 }
1618 GitGraphEvent::LoadingError => {
1619 cx.notify();
1620 }
1621 GitGraphEvent::CountUpdated(commit_count) => {
1622 let old_count = self.graph_data.commits.len();
1623
1624 if let Some(pending_selection_index) =
1625 repository.update(cx, |repository, cx| {
1626 let GraphDataResponse {
1627 commits,
1628 is_loading,
1629 error: _,
1630 } = repository.graph_data(
1631 source.clone(),
1632 *order,
1633 old_count..*commit_count,
1634 cx,
1635 );
1636 self.graph_data.add_commits(commits);
1637
1638 let pending_sha_index = self.pending_select_sha.and_then(|oid| {
1639 repository.get_graph_data(source.clone(), *order).and_then(
1640 |data| data.commit_oid_to_index.get(&oid).copied(),
1641 )
1642 });
1643
1644 if !is_loading && pending_sha_index.is_none() {
1645 self.pending_select_sha.take();
1646 }
1647
1648 pending_sha_index
1649 })
1650 {
1651 self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx);
1652 self.pending_select_sha.take();
1653 }
1654
1655 cx.notify();
1656 }
1657 }
1658 }
1659 RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => {
1660 // Only invalidate if we scanned atleast once,
1661 // meaning we are not inside the initial repo loading state
1662 // NOTE: this fixes an loading performance regression
1663 if repository.read(cx).scan_id > 1 {
1664 self.pending_select_sha = None;
1665 self.invalidate_state(cx);
1666 }
1667 }
1668 RepositoryEvent::StashEntriesChanged if self.log_source == LogSource::All => {
1669 // Stash entries initial's scan id is 2, so we don't want to invalidate the graph before that
1670 if repository.read(cx).scan_id > 2 {
1671 self.pending_select_sha = None;
1672 self.invalidate_state(cx);
1673 }
1674 }
1675 RepositoryEvent::GraphEvent(_, _) => {}
1676 _ => {}
1677 }
1678 }
1679
1680 fn fetch_initial_graph_data(&mut self, cx: &mut App) {
1681 if let Some(repository) = self.get_repository(cx) {
1682 repository.update(cx, |repository, cx| {
1683 let commits = repository
1684 .graph_data(self.log_source.clone(), self.log_order, 0..usize::MAX, cx)
1685 .commits;
1686 self.graph_data.add_commits(commits);
1687 });
1688 }
1689 }
1690
1691 fn get_repository(&self, cx: &App) -> Option<Entity<Repository>> {
1692 let git_store = self.git_store.read(cx);
1693 git_store.repositories().get(&self.repo_id).cloned()
1694 }
1695
1696 /// Checks whether a ref name from git's `%D` decoration
1697 /// format refers to the currently checked-out branch.
1698 fn is_head_ref(ref_name: &str, head_branch_name: &Option<SharedString>) -> bool {
1699 head_branch_name.as_ref().is_some_and(|head| {
1700 ref_name == head.as_ref() || ref_name.strip_prefix("HEAD -> ") == Some(head.as_ref())
1701 })
1702 }
1703
1704 /// Extracts a ref name (branch, remote ref, or tag) from a decoration in
1705 /// git's `%D` format, returning `None` for a detached `HEAD`.
1706 fn ref_name_from_decoration(decoration: &str) -> Option<SharedString> {
1707 let name = decoration
1708 .strip_prefix("tag: ")
1709 .or_else(|| decoration.strip_prefix("HEAD -> "))
1710 .unwrap_or(decoration);
1711 if name.is_empty() || name == "HEAD" {
1712 return None;
1713 }
1714 Some(SharedString::from(name.to_string()))
1715 }
1716
1717 fn render_chip(
1718 &self,
1719 name: &SharedString,
1720 accent_color: gpui::Hsla,
1721 is_head: bool,
1722 ) -> impl IntoElement {
1723 Chip::new(name.clone())
1724 .label_size(LabelSize::Small)
1725 .truncate()
1726 .map(|chip| {
1727 if is_head {
1728 chip.icon(IconName::Check)
1729 .bg_color(accent_color.opacity(0.25))
1730 .border_color(accent_color.opacity(0.5))
1731 } else {
1732 chip.bg_color(accent_color.opacity(0.08))
1733 .border_color(accent_color.opacity(0.25))
1734 }
1735 })
1736 }
1737
1738 /// Renders a ref chip for the commit at `commit_idx`. Chips that name a ref
1739 /// (branch, remote ref, or tag) get a right-click handler that opens a
1740 /// ref-specific context menu, so that custom commands can be resolved
1741 /// against the clicked ref.
1742 fn render_ref_chip(
1743 &self,
1744 name: &SharedString,
1745 accent_color: gpui::Hsla,
1746 is_head: bool,
1747 commit_idx: usize,
1748 cx: &mut Context<Self>,
1749 ) -> AnyElement {
1750 let chip = self.render_chip(name, accent_color, is_head);
1751 let Some(ref_name) = Self::ref_name_from_decoration(name) else {
1752 return chip.into_any_element();
1753 };
1754 div()
1755 .child(chip)
1756 .on_mouse_down(
1757 MouseButton::Right,
1758 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
1759 this.deploy_entry_context_menu(
1760 event.position,
1761 commit_idx,
1762 Some(ref_name.clone()),
1763 window,
1764 cx,
1765 );
1766 cx.stop_propagation();
1767 }),
1768 )
1769 .into_any_element()
1770 }
1771
1772 fn render_table_rows(
1773 &mut self,
1774 range: Range<usize>,
1775 window: &mut Window,
1776 cx: &mut Context<Self>,
1777 ) -> Vec<Vec<AnyElement>> {
1778 let repository = self.get_repository(cx);
1779
1780 let head_branch_name: Option<SharedString> = repository.as_ref().and_then(|repo| {
1781 repo.read(cx)
1782 .snapshot()
1783 .branch
1784 .as_ref()
1785 .map(|branch| SharedString::from(branch.name().to_string()))
1786 });
1787
1788 let row_height = Self::row_height(window, cx);
1789
1790 // We fetch data outside the visible viewport to avoid loading entries when
1791 // users scroll through the git graph
1792 if let Some(repository) = repository.as_ref() {
1793 const FETCH_RANGE: usize = 100;
1794 repository.update(cx, |repository, cx| {
1795 self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
1796 ..(range.end + FETCH_RANGE)
1797 .min(self.graph_data.commits.len().saturating_sub(1))]
1798 .iter()
1799 .for_each(|commit| {
1800 repository.fetch_commit_data(commit.data.sha, false, cx);
1801 });
1802 });
1803 }
1804
1805 range
1806 .map(|idx| {
1807 let Some((commit, repository)) =
1808 self.graph_data.commits.get(idx).zip(repository.as_ref())
1809 else {
1810 return vec![
1811 div().h(row_height).into_any_element(),
1812 div().h(row_height).into_any_element(),
1813 div().h(row_height).into_any_element(),
1814 div().h(row_height).into_any_element(),
1815 ];
1816 };
1817
1818 let data = repository.update(cx, |repository, cx| {
1819 repository
1820 .fetch_commit_data(commit.data.sha, false, cx)
1821 .clone()
1822 });
1823
1824 let short_sha = commit.data.sha.display_short();
1825 let mut formatted_time = String::new();
1826 let subject: SharedString;
1827 let author_name: SharedString;
1828
1829 if let CommitDataState::Loaded(ref data) = data {
1830 subject = data.subject.clone();
1831 author_name = data.author_name.clone();
1832 formatted_time = format_timestamp(data.commit_timestamp);
1833 } else {
1834 subject = "Loading…".into();
1835 author_name = "".into();
1836 }
1837
1838 let accent_colors = cx.theme().accents();
1839 let accent_color = accent_colors
1840 .0
1841 .get(commit.color_idx)
1842 .copied()
1843 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1844
1845 let is_selected = self.selected_entry_idx == Some(idx);
1846 let is_matched = self.search_state.matches.contains(&commit.data.sha);
1847 let column_label = |label: SharedString| {
1848 Label::new(label)
1849 .when(!is_selected, |c| c.color(Color::Muted))
1850 .truncate()
1851 .into_any_element()
1852 };
1853
1854 let subject_label = if is_matched {
1855 let query = match &self.search_state.state {
1856 QueryState::Confirmed((query, _)) => Some(query.clone()),
1857 _ => None,
1858 };
1859 let highlight_ranges = query
1860 .and_then(|q| {
1861 let ranges = if self.search_state.case_sensitive {
1862 subject
1863 .match_indices(q.as_str())
1864 .map(|(start, matched)| start..start + matched.len())
1865 .collect::<Vec<_>>()
1866 } else {
1867 let q = q.to_lowercase();
1868 let subject_lower = subject.to_lowercase();
1869
1870 subject_lower
1871 .match_indices(&q)
1872 .filter_map(|(start, matched)| {
1873 let end = start + matched.len();
1874 subject.is_char_boundary(start).then_some(()).and_then(
1875 |_| subject.is_char_boundary(end).then_some(start..end),
1876 )
1877 })
1878 .collect::<Vec<_>>()
1879 };
1880
1881 (!ranges.is_empty()).then_some(ranges)
1882 })
1883 .unwrap_or_default();
1884 HighlightedLabel::from_ranges(subject, highlight_ranges)
1885 .when(!is_selected, |c| c.color(Color::Muted))
1886 .truncate()
1887 .into_any_element()
1888 } else {
1889 column_label(subject)
1890 };
1891
1892 vec![
1893 div()
1894 .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1895 .overflow_hidden()
1896 .child(
1897 h_flex()
1898 .gap_2()
1899 .overflow_hidden()
1900 .children((!commit.data.ref_names.is_empty()).then(|| {
1901 h_flex().gap_1().children(commit.data.ref_names.iter().map(
1902 |name| {
1903 let is_head =
1904 Self::is_head_ref(name.as_ref(), &head_branch_name);
1905 self.render_ref_chip(
1906 name,
1907 accent_color,
1908 is_head,
1909 idx,
1910 cx,
1911 )
1912 },
1913 ))
1914 }))
1915 .child(subject_label),
1916 )
1917 .into_any_element(),
1918 column_label(formatted_time.into()),
1919 column_label(author_name),
1920 column_label(short_sha.into()),
1921 ]
1922 })
1923 .collect()
1924 }
1925
1926 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1927 self.selected_entry_idx = None;
1928 self.selected_commit_diff = None;
1929 self.selected_commit_diff_stats = None;
1930 self.changed_files_expanded_dirs.clear();
1931 cx.emit(ItemEvent::Edit);
1932 cx.notify();
1933 }
1934
1935 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1936 self.select_entry(0, ScrollStrategy::Nearest, cx);
1937 }
1938
1939 fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1940 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1941 self.select_entry(
1942 selected_entry_idx.saturating_sub(1),
1943 ScrollStrategy::Nearest,
1944 cx,
1945 );
1946 } else {
1947 self.select_first(&SelectFirst, window, cx);
1948 }
1949 }
1950
1951 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1952 if let Some(selected_entry_idx) = &self.selected_entry_idx {
1953 self.select_entry(
1954 selected_entry_idx
1955 .saturating_add(1)
1956 .min(self.graph_data.commits.len().saturating_sub(1)),
1957 ScrollStrategy::Nearest,
1958 cx,
1959 );
1960 } else {
1961 self.select_prev(&SelectPrevious, window, cx);
1962 }
1963 }
1964
1965 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1966 self.select_entry(
1967 self.graph_data.commits.len().saturating_sub(1),
1968 ScrollStrategy::Nearest,
1969 cx,
1970 );
1971 }
1972
1973 fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
1974 let step = (self.visible_row_count(window, cx) / 2).max(1);
1975 let target_idx = self.selected_entry_idx.unwrap_or(0).saturating_sub(step);
1976
1977 self.select_entry(target_idx, ScrollStrategy::Nearest, cx);
1978 }
1979
1980 fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
1981 let Some(last_entry_idx) = self.graph_data.commits.len().checked_sub(1) else {
1982 return;
1983 };
1984
1985 let step = (self.visible_row_count(window, cx) / 2).max(1);
1986 let target_idx = self
1987 .selected_entry_idx
1988 .unwrap_or(0)
1989 .saturating_add(step)
1990 .min(last_entry_idx);
1991
1992 self.select_entry(target_idx, ScrollStrategy::Nearest, cx);
1993 }
1994
1995 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1996 self.open_selected_commit_view(window, cx);
1997 }
1998
1999 fn toggle_changed_files_view(
2000 &mut self,
2001 _: &ToggleChangedFilesView,
2002 _window: &mut Window,
2003 cx: &mut Context<Self>,
2004 ) {
2005 self.changed_files_view_mode = self.changed_files_view_mode.toggled();
2006 self.changed_files_scroll_handle
2007 .scroll_to_item(0, ScrollStrategy::Top);
2008 cx.notify();
2009 }
2010
2011 fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
2012 let Some(repo) = self.get_repository(cx) else {
2013 return;
2014 };
2015
2016 self.search_state.matches.clear();
2017 self.search_state.selected_index = None;
2018 self.search_state.editor.update(cx, |editor, _cx| {
2019 editor.set_text_style_refinement(Default::default());
2020 });
2021
2022 if query.as_str().is_empty() {
2023 self.search_state.state = QueryState::Empty;
2024 cx.notify();
2025 return;
2026 }
2027
2028 let (request_tx, request_rx) = async_channel::unbounded::<Oid>();
2029
2030 repo.update(cx, |repo, cx| {
2031 repo.search_commits(
2032 self.log_source.clone(),
2033 SearchCommitArgs {
2034 query: query.clone(),
2035 case_sensitive: self.search_state.case_sensitive,
2036 },
2037 request_tx,
2038 cx,
2039 );
2040 });
2041
2042 let search_task = cx.spawn(async move |this, cx| {
2043 while let Ok(first_oid) = request_rx.recv().await {
2044 let mut pending_oids = vec![first_oid];
2045 while let Ok(oid) = request_rx.try_recv() {
2046 pending_oids.push(oid);
2047 }
2048
2049 this.update(cx, |this, cx| {
2050 if this.search_state.selected_index.is_none() {
2051 this.search_state.selected_index = Some(0);
2052 this.select_commit_by_sha(first_oid, cx);
2053 }
2054
2055 this.search_state.matches.extend(pending_oids);
2056 cx.notify();
2057 })
2058 .ok();
2059 }
2060
2061 this.update(cx, |this, cx| {
2062 if this.search_state.matches.is_empty() {
2063 this.search_state.editor.update(cx, |editor, cx| {
2064 editor.set_text_style_refinement(TextStyleRefinement {
2065 color: Some(Color::Error.color(cx)),
2066 ..Default::default()
2067 });
2068 });
2069 }
2070 })
2071 .ok();
2072 });
2073
2074 self.search_state.state = QueryState::Confirmed((query, search_task));
2075 cx.emit(ItemEvent::Edit);
2076 }
2077
2078 fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
2079 let query = self.search_state.editor.read(cx).text(cx).into();
2080 self.search(query, cx);
2081 }
2082
2083 fn activate_search_editor_if_focused(&self, window: &mut Window, cx: &mut Context<Self>) {
2084 self.search_state.editor.update(cx, |editor, cx| {
2085 if editor.is_focused(window) {
2086 editor.select_all(&Default::default(), window, cx);
2087 editor.show_cursor(cx);
2088 }
2089 });
2090 }
2091
2092 fn focus_next_tab_stop(
2093 &mut self,
2094 _: &FocusNextTabStop,
2095 window: &mut Window,
2096 cx: &mut Context<Self>,
2097 ) {
2098 window.focus_next(cx);
2099 self.activate_search_editor_if_focused(window, cx);
2100 cx.stop_propagation();
2101 cx.notify();
2102 }
2103
2104 fn focus_previous_tab_stop(
2105 &mut self,
2106 _: &FocusPreviousTabStop,
2107 window: &mut Window,
2108 cx: &mut Context<Self>,
2109 ) {
2110 window.focus_prev(cx);
2111 self.activate_search_editor_if_focused(window, cx);
2112 cx.stop_propagation();
2113 cx.notify();
2114 }
2115
2116 fn select_entry(
2117 &mut self,
2118 idx: usize,
2119 scroll_strategy: ScrollStrategy,
2120 cx: &mut Context<Self>,
2121 ) {
2122 if self.selected_entry_idx == Some(idx) || idx >= self.graph_data.commits.len() {
2123 debug_assert!(
2124 idx < self.graph_data.commits.len(),
2125 "attempted to select out of bounds index: {idx}, commits.len: {}",
2126 self.graph_data.commits.len()
2127 );
2128 return;
2129 }
2130
2131 self.selected_entry_idx = Some(idx);
2132 self.selected_commit_diff = None;
2133 self.selected_commit_diff_stats = None;
2134 self.changed_files_expanded_dirs.clear();
2135 self.changed_files_scroll_handle
2136 .scroll_to_item(0, ScrollStrategy::Top);
2137 self.table_interaction_state.update(cx, |state, cx| {
2138 state.scroll_handle.scroll_to_item(idx, scroll_strategy);
2139 cx.notify();
2140 });
2141
2142 let Some(commit) = self.graph_data.commits.get(idx) else {
2143 return;
2144 };
2145
2146 let Some(repository) = self.get_repository(cx) else {
2147 return;
2148 };
2149
2150 let commit_message_handle = commit.data.sha;
2151 let diff_handle = commit.data.sha.to_string();
2152
2153 self.load_selected_commit_message(cx, &commit_message_handle, &repository);
2154
2155 let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(diff_handle));
2156
2157 self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
2158 if let Ok(Ok(diff)) = diff_receiver.await {
2159 this.update(cx, |this, cx| {
2160 let stats = compute_diff_stats(&diff);
2161 this.selected_commit_diff = Some(diff);
2162 this.selected_commit_diff_stats = Some(stats);
2163 cx.notify();
2164 })
2165 .ok();
2166 }
2167 }));
2168
2169 cx.emit(ItemEvent::Edit);
2170 cx.notify();
2171 }
2172
2173 fn load_selected_commit_message(
2174 &mut self,
2175 cx: &mut Context<'_, Self>,
2176 sha: &Oid,
2177 repository: &Entity<Repository>,
2178 ) {
2179 if self
2180 .selected_commit_message
2181 .as_ref()
2182 .is_some_and(|old| old.sha == *sha)
2183 {
2184 return;
2185 }
2186
2187 self._selected_commit_message_task = None;
2188 match repository.update(cx, |repo, cx| {
2189 repo.fetch_commit_data(*sha, true, cx).clone()
2190 }) {
2191 CommitDataState::Loaded(commit_data) => {
2192 self.set_selected_commit_message(cx, commit_data.sha, commit_data.message.clone());
2193 }
2194 CommitDataState::Loading(Some(receiver)) => {
2195 self._selected_commit_message_task = Some(cx.spawn(async move |this, cx| {
2196 if let Ok(commit_data) = receiver.await {
2197 this.update(cx, |this, cx| {
2198 this.set_selected_commit_message(
2199 cx,
2200 commit_data.sha,
2201 commit_data.message.clone(),
2202 );
2203 })
2204 .log_err();
2205 }
2206 }))
2207 }
2208 _ => {
2209 debug_panic!(
2210 "Fetched commit data asynchronously, but was not given a listener or cached commit data."
2211 );
2212 }
2213 };
2214 }
2215
2216 fn set_selected_commit_message(
2217 &mut self,
2218 cx: &mut Context<'_, GitGraph>,
2219 sha: Oid,
2220 message: SharedString,
2221 ) {
2222 let languages = self
2223 .workspace
2224 .read_with(cx, |workspace, cx| {
2225 workspace.project().read(cx).languages().clone()
2226 })
2227 .log_err();
2228 self.selected_commit_message = Some(DetailPanelCommitMessage {
2229 sha,
2230 message: cx.new(|cx| Markdown::new(message, languages, None, cx)),
2231 scroll_handle: ScrollHandle::new(),
2232 });
2233 self._selected_commit_message_task = None;
2234 cx.notify();
2235 }
2236
2237 fn select_previous_match(&mut self, cx: &mut Context<Self>) {
2238 if self.search_state.matches.is_empty() {
2239 return;
2240 }
2241
2242 let mut prev_selection = self.search_state.selected_index.unwrap_or_default();
2243
2244 if prev_selection == 0 {
2245 prev_selection = self.search_state.matches.len() - 1;
2246 } else {
2247 prev_selection -= 1;
2248 }
2249
2250 let Some(&oid) = self.search_state.matches.get_index(prev_selection) else {
2251 return;
2252 };
2253
2254 self.search_state.selected_index = Some(prev_selection);
2255 self.select_commit_by_sha(oid, cx);
2256 }
2257
2258 fn select_next_match(&mut self, cx: &mut Context<Self>) {
2259 if self.search_state.matches.is_empty() {
2260 return;
2261 }
2262
2263 let mut next_selection = self
2264 .search_state
2265 .selected_index
2266 .map(|index| index + 1)
2267 .unwrap_or_default();
2268
2269 if next_selection >= self.search_state.matches.len() {
2270 next_selection = 0;
2271 }
2272
2273 let Some(&oid) = self.search_state.matches.get_index(next_selection) else {
2274 return;
2275 };
2276
2277 self.search_state.selected_index = Some(next_selection);
2278 self.select_commit_by_sha(oid, cx);
2279 }
2280
2281 pub fn set_repo_id(&mut self, repo_id: RepositoryId, cx: &mut Context<Self>) {
2282 if repo_id != self.repo_id
2283 && self
2284 .git_store
2285 .read(cx)
2286 .repositories()
2287 .contains_key(&repo_id)
2288 {
2289 self.repo_id = repo_id;
2290 self.invalidate_state(cx);
2291 }
2292 }
2293
2294 pub fn select_commit_by_sha(&mut self, sha: impl TryInto<Oid>, cx: &mut Context<Self>) {
2295 fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
2296 let Some(selected_repository) = this.get_repository(cx) else {
2297 return;
2298 };
2299
2300 let Some(index) = selected_repository
2301 .read(cx)
2302 .get_graph_data(this.log_source.clone(), this.log_order)
2303 .and_then(|data| data.commit_oid_to_index.get(&oid))
2304 .copied()
2305 else {
2306 this.pending_select_sha = Some(oid);
2307 return;
2308 };
2309
2310 this.pending_select_sha = None;
2311 this.select_entry(index, ScrollStrategy::Center, cx);
2312 }
2313
2314 if let Ok(oid) = sha.try_into() {
2315 inner(self, oid, cx);
2316 }
2317 }
2318
2319 fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2320 let Some(selected_entry_index) = self.selected_entry_idx else {
2321 return;
2322 };
2323
2324 self.open_commit_view(selected_entry_index, window, cx);
2325 }
2326
2327 fn open_commit_view(
2328 &mut self,
2329 entry_index: usize,
2330 window: &mut Window,
2331 cx: &mut Context<Self>,
2332 ) {
2333 let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
2334 return;
2335 };
2336
2337 let Some(repository) = self.get_repository(cx) else {
2338 return;
2339 };
2340
2341 CommitView::open(
2342 commit_entry.data.sha.to_string(),
2343 repository.downgrade(),
2344 self.workspace.clone(),
2345 None,
2346 None,
2347 window,
2348 cx,
2349 );
2350 }
2351
2352 fn copy_commit_sha(&mut self, entry_index: usize, cx: &mut Context<Self>) {
2353 let Some(commit) = self.graph_data.commits.get(entry_index) else {
2354 return;
2355 };
2356 cx.write_to_clipboard(ClipboardItem::new_string(commit.data.sha.to_string()));
2357 }
2358
2359 fn copy_selected_commit_sha(
2360 &mut self,
2361 _: &CopyCommitSha,
2362 _: &mut Window,
2363 cx: &mut Context<Self>,
2364 ) {
2365 let Some(selected_entry_index) = self.selected_entry_idx else {
2366 return;
2367 };
2368 self.copy_commit_sha(selected_entry_index, cx);
2369 }
2370
2371 fn copy_commit_tag(&mut self, entry_index: usize, window: &mut Window, cx: &mut Context<Self>) {
2372 let Some(commit) = self.graph_data.commits.get(entry_index) else {
2373 return;
2374 };
2375
2376 let tag_names = commit
2377 .data
2378 .tag_names()
2379 .into_iter()
2380 .map(|tag_name| SharedString::from(tag_name.to_string()))
2381 .collect::<Vec<_>>();
2382
2383 match tag_names.as_slice() {
2384 [] => {}
2385 [tag_name] => cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string())),
2386 _ => {
2387 self.workspace
2388 .update(cx, |workspace, cx| {
2389 workspace.toggle_modal(window, cx, |window, cx| {
2390 CommitTagPicker::new(tag_names, window, cx)
2391 });
2392 })
2393 .ok();
2394 }
2395 }
2396 }
2397
2398 fn copy_selected_commit_tag(
2399 &mut self,
2400 _: &CopyCommitTag,
2401 window: &mut Window,
2402 cx: &mut Context<Self>,
2403 ) {
2404 let Some(selected_entry_index) = self.selected_entry_idx else {
2405 return;
2406 };
2407 self.copy_commit_tag(selected_entry_index, window, cx);
2408 }
2409
2410 fn deploy_entry_context_menu(
2411 &mut self,
2412 position: Point<Pixels>,
2413 index: usize,
2414 ref_name: Option<SharedString>,
2415 window: &mut Window,
2416 cx: &mut Context<Self>,
2417 ) {
2418 let Some(commit) = self.graph_data.commits.get(index) else {
2419 return;
2420 };
2421 let repository = self
2422 .get_repository(cx)
2423 .map(|repository| repository.downgrade());
2424 let context_menu = commit_context_menu(
2425 CommitContextMenuData {
2426 sha: commit.data.sha,
2427 tag_names: commit
2428 .data
2429 .tag_names()
2430 .into_iter()
2431 .map(|tag_name| SharedString::from(tag_name.to_string()))
2432 .collect(),
2433 },
2434 CommitContextMenuSource::GitGraph,
2435 ref_name,
2436 self.focus_handle.clone(),
2437 repository,
2438 self.workspace.clone(),
2439 window,
2440 cx,
2441 );
2442 self.set_context_menu(context_menu, position, Some(index), window, cx);
2443 }
2444
2445 fn set_context_menu(
2446 &mut self,
2447 context_menu: Entity<ContextMenu>,
2448 position: Point<Pixels>,
2449 target_entry_index: Option<usize>,
2450 window: &mut Window,
2451 cx: &mut Context<Self>,
2452 ) {
2453 window.focus(&context_menu.focus_handle(cx), cx);
2454
2455 let subscription = cx.subscribe_in(
2456 &context_menu,
2457 window,
2458 |this, _, _: &DismissEvent, window, cx| {
2459 if this.context_menu.as_ref().is_some_and(|context_menu| {
2460 context_menu
2461 .menu
2462 .focus_handle(cx)
2463 .contains_focused(window, cx)
2464 }) {
2465 cx.focus_self(window);
2466 }
2467 this.context_menu.take();
2468 cx.notify();
2469 },
2470 );
2471 self.context_menu = Some(GitGraphContextMenu {
2472 menu: context_menu,
2473 position,
2474 target_entry_index,
2475 _subscription: subscription,
2476 });
2477 cx.notify();
2478 }
2479
2480 fn toggle_column_visibility(&mut self, col_idx: usize, cx: &mut Context<Self>) {
2481 if let Some(slot) = self.column_visibility.as_mut_slice().get_mut(col_idx) {
2482 *slot = !*slot;
2483 // Column visibility is persisted per item, so schedule a workspace serialization.
2484 cx.emit(ItemEvent::Edit);
2485 }
2486 }
2487
2488 fn deploy_header_context_menu(
2489 &mut self,
2490 position: Point<Pixels>,
2491 window: &mut Window,
2492 cx: &mut Context<Self>,
2493 ) {
2494 let is_path_history = matches!(self.log_source, LogSource::Path(_));
2495 let columns: &[&str] = if is_path_history {
2496 &["Description", "Date", "Author", "Commit"]
2497 } else {
2498 &["Graph", "Description", "Date", "Author", "Commit"]
2499 };
2500
2501 let filter = self.column_visibility.clone();
2502 let visible_count = filter
2503 .as_slice()
2504 .iter()
2505 .filter(|filtered| !**filtered)
2506 .count();
2507
2508 let focus_handle = self.focus_handle.clone();
2509 let git_graph = cx.entity();
2510 let context_menu = ContextMenu::build(window, cx, |mut context_menu, _window, _cx| {
2511 context_menu = context_menu.context(focus_handle).header("Columns");
2512 for (col_idx, label) in columns.iter().enumerate() {
2513 let is_visible = !filter.get(col_idx).copied().unwrap_or(false);
2514 // Disable hiding the last remaining visible column.
2515 let can_toggle = !is_visible || visible_count > 1;
2516 let git_graph = git_graph.clone();
2517 context_menu = context_menu.toggleable_entry_disabled_when(
2518 label.to_string(),
2519 is_visible,
2520 !can_toggle,
2521 IconPosition::End,
2522 None,
2523 move |_window, cx| {
2524 git_graph.update(cx, |this, cx| {
2525 this.toggle_column_visibility(col_idx, cx);
2526 cx.notify();
2527 });
2528 },
2529 );
2530 }
2531 context_menu
2532 });
2533
2534 self.set_context_menu(context_menu, position, None, window, cx);
2535 }
2536
2537 fn render_search_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
2538 let color = cx.theme().colors();
2539 let query_focus_handle = self
2540 .search_state
2541 .editor
2542 .focus_handle(cx)
2543 .tab_index(1)
2544 .tab_stop(true);
2545 let search_options = {
2546 let mut options = SearchOptions::NONE;
2547 options.set(
2548 SearchOptions::CASE_SENSITIVE,
2549 self.search_state.case_sensitive,
2550 );
2551 options
2552 };
2553
2554 h_flex()
2555 .key_context("GitGraphSearchBar")
2556 .tab_index(1)
2557 .tab_group()
2558 .tab_stop(false)
2559 .w_full()
2560 .p_1p5()
2561 .gap_1p5()
2562 .border_b_1()
2563 .border_color(color.border_variant)
2564 .child(
2565 h_flex()
2566 .h_8()
2567 .flex_1()
2568 .min_w_0()
2569 .px_1p5()
2570 .gap_1()
2571 .track_focus(&query_focus_handle)
2572 .border_1()
2573 .border_color(color.border_variant)
2574 .rounded_md()
2575 .bg(color.toolbar_background)
2576 .on_action(cx.listener(Self::confirm_search))
2577 .child(self.search_state.editor.clone())
2578 .child(SearchOption::CaseSensitive.as_button(
2579 search_options,
2580 SearchSource::Buffer,
2581 query_focus_handle,
2582 )),
2583 )
2584 .child(
2585 h_flex()
2586 .min_w_64()
2587 .gap_1()
2588 .child({
2589 let focus_handle = self.focus_handle.clone();
2590 IconButton::new("git-graph-search-prev", IconName::ChevronLeft)
2591 .shape(ui::IconButtonShape::Square)
2592 .icon_size(IconSize::Small)
2593 .tooltip(move |_, cx| {
2594 Tooltip::for_action_in(
2595 "Select Previous Match",
2596 &SelectPreviousMatch,
2597 &focus_handle,
2598 cx,
2599 )
2600 })
2601 .map(|this| {
2602 if self.search_state.matches.is_empty() {
2603 this.disabled(true)
2604 } else {
2605 this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
2606 this.select_previous_match(cx);
2607 }))
2608 }
2609 })
2610 })
2611 .child({
2612 let focus_handle = self.focus_handle.clone();
2613 IconButton::new("git-graph-search-next", IconName::ChevronRight)
2614 .shape(ui::IconButtonShape::Square)
2615 .icon_size(IconSize::Small)
2616 .tooltip(move |_, cx| {
2617 Tooltip::for_action_in(
2618 "Select Next Match",
2619 &SelectNextMatch,
2620 &focus_handle,
2621 cx,
2622 )
2623 })
2624 .map(|this| {
2625 if self.search_state.matches.is_empty() {
2626 this.disabled(true)
2627 } else {
2628 this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
2629 this.select_next_match(cx);
2630 }))
2631 }
2632 })
2633 })
2634 .child(
2635 h_flex()
2636 .gap_1p5()
2637 .child(
2638 Label::new(format!(
2639 "{}/{}",
2640 self.search_state
2641 .selected_index
2642 .map(|index| index + 1)
2643 .unwrap_or(0),
2644 self.search_state.matches.len()
2645 ))
2646 .size(LabelSize::Small)
2647 .when(self.search_state.matches.is_empty(), |this| {
2648 this.color(Color::Disabled)
2649 }),
2650 )
2651 .when(
2652 matches!(
2653 &self.search_state.state,
2654 QueryState::Confirmed((_, task)) if !task.is_ready()
2655 ),
2656 |this| {
2657 this.child(
2658 Icon::new(IconName::ArrowCircle)
2659 .color(Color::Accent)
2660 .size(IconSize::Small)
2661 .with_rotate_animation(2)
2662 .into_any_element(),
2663 )
2664 },
2665 ),
2666 ),
2667 )
2668 }
2669
2670 fn render_loading_spinner(&self, cx: &App) -> AnyElement {
2671 let rems = TextSize::Large.rems(cx);
2672 Icon::new(IconName::LoadCircle)
2673 .size(IconSize::Custom(rems))
2674 .color(Color::Accent)
2675 .with_rotate_animation(3)
2676 .into_any_element()
2677 }
2678
2679 fn render_commit_detail_panel(
2680 &self,
2681 window: &mut Window,
2682 cx: &mut Context<Self>,
2683 ) -> impl IntoElement {
2684 let Some(selected_idx) = self.selected_entry_idx else {
2685 return Empty.into_any_element();
2686 };
2687
2688 let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
2689 return Empty.into_any_element();
2690 };
2691
2692 let Some(repository) = self.get_repository(cx) else {
2693 return Empty.into_any_element();
2694 };
2695
2696 let data = repository.update(cx, |repository, cx| {
2697 repository
2698 .fetch_commit_data(commit_entry.data.sha, false, cx)
2699 .clone()
2700 });
2701
2702 let full_sha: SharedString = commit_entry.data.sha.to_string().into();
2703 let ref_names = commit_entry.data.ref_names.clone();
2704
2705 let head_branch_name: Option<SharedString> = repository
2706 .read(cx)
2707 .snapshot()
2708 .branch
2709 .as_ref()
2710 .map(|branch| SharedString::from(branch.name().to_string()));
2711
2712 let accent_colors = cx.theme().accents();
2713 let accent_color = accent_colors
2714 .0
2715 .get(commit_entry.color_idx)
2716 .copied()
2717 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
2718
2719 let (author_name, author_email, commit_timestamp) = match &data {
2720 CommitDataState::Loaded(data) => (
2721 data.author_name.clone(),
2722 data.author_email.clone(),
2723 Some(data.commit_timestamp),
2724 ),
2725 CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None),
2726 };
2727
2728 let date_string = commit_timestamp
2729 .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
2730 .map(|datetime| {
2731 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
2732 let local_datetime = datetime.to_offset(local_offset);
2733 let format =
2734 time::format_description::parse("[month repr:short] [day], [year]").ok();
2735 format
2736 .and_then(|f| local_datetime.format(&f).ok())
2737 .unwrap_or_default()
2738 })
2739 .unwrap_or_default();
2740
2741 let remote = repository.update(cx, |repo, cx| {
2742 let remote_url = repo.default_remote_url()?;
2743 let provider_registry = GitHostingProviderRegistry::default_global(cx);
2744 let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
2745 Some(GitRemote {
2746 host: provider,
2747 owner: parsed.owner.into(),
2748 repo: parsed.repo.into(),
2749 })
2750 });
2751
2752 let avatar = {
2753 let author_email_for_avatar = if author_email.is_empty() {
2754 None
2755 } else {
2756 Some(author_email.clone())
2757 };
2758
2759 CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
2760 .size(px(32.))
2761 .render(window, cx)
2762 };
2763
2764 let changed_files_count = self
2765 .selected_commit_diff
2766 .as_ref()
2767 .map(|diff| diff.files.len())
2768 .unwrap_or(0);
2769
2770 let (total_lines_added, total_lines_removed) =
2771 self.selected_commit_diff_stats.unwrap_or((0, 0));
2772
2773 let changed_file_entries: Vec<ChangedFileEntry> = self
2774 .selected_commit_diff
2775 .as_ref()
2776 .map(|diff| {
2777 let mut files = diff.files.iter().collect::<Vec<_>>();
2778 if !self.changed_files_view_mode.is_tree() {
2779 files.sort_by_key(|file| file.status());
2780 }
2781 files
2782 .into_iter()
2783 .map(|file| ChangedFileEntry::from_commit_file(file, cx))
2784 .collect()
2785 })
2786 .unwrap_or_default();
2787 let changed_file_entries = Rc::new(changed_file_entries);
2788 let tree_entries: Rc<Vec<ChangedFileTreeEntry>> = if self.changed_files_view_mode.is_tree()
2789 {
2790 Rc::new(build_changed_file_tree_entries(
2791 changed_file_entries.as_ref().clone(),
2792 &self.changed_files_expanded_dirs,
2793 ))
2794 } else {
2795 Rc::default()
2796 };
2797
2798 let is_tree_view = self.changed_files_view_mode.is_tree();
2799 let view_toggle = IconButton::new("toggle-changed-files-view", IconName::ListTree)
2800 .icon_size(IconSize::Small)
2801 .toggle_state(self.changed_files_view_mode.is_tree())
2802 .tooltip({
2803 let tooltip = if is_tree_view {
2804 "Show Flat View"
2805 } else {
2806 "Show Tree View"
2807 };
2808 move |_, cx| Tooltip::for_action(tooltip, &ToggleChangedFilesView, cx)
2809 })
2810 .on_click(cx.listener(|this, _, _window, cx| {
2811 this.changed_files_view_mode = this.changed_files_view_mode.toggled();
2812 this.changed_files_scroll_handle
2813 .scroll_to_item(0, ScrollStrategy::Top);
2814 cx.notify();
2815 }));
2816
2817 v_flex()
2818 .min_w(px(300.))
2819 .h_full()
2820 .bg(cx.theme().colors().editor_background)
2821 .flex_basis(DefiniteLength::Fraction(
2822 self.commit_details_split_state.read(cx).right_ratio(),
2823 ))
2824 .child(
2825 v_flex()
2826 .relative()
2827 .w_full()
2828 .p_2()
2829 .gap_2()
2830 .child(
2831 div().absolute().top_2().right_2().child(
2832 IconButton::new("close-detail", IconName::Close)
2833 .icon_size(IconSize::Small)
2834 .on_click(cx.listener(move |this, _, _, cx| {
2835 this.selected_entry_idx = None;
2836 this.selected_commit_diff = None;
2837 this.selected_commit_diff_stats = None;
2838 this.selected_commit_message = None;
2839 this._selected_commit_message_task = None;
2840 this.changed_files_expanded_dirs.clear();
2841 this._commit_diff_task = None;
2842 cx.notify();
2843 })),
2844 ),
2845 )
2846 .child(
2847 v_flex()
2848 .py_1()
2849 .w_full()
2850 .items_center()
2851 .child(avatar)
2852 .child(Label::new(author_name).mt_1p5())
2853 .child(
2854 Label::new(date_string)
2855 .color(Color::Muted)
2856 .size(LabelSize::Small),
2857 ),
2858 )
2859 .children((!ref_names.is_empty()).then(|| {
2860 h_flex().gap_1().flex_wrap().justify_center().children(
2861 ref_names.iter().map(|name| {
2862 let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name);
2863 self.render_ref_chip(name, accent_color, is_head, selected_idx, cx)
2864 }),
2865 )
2866 }))
2867 .child(
2868 v_flex()
2869 .ml_neg_1()
2870 .gap_1p5()
2871 .when(!author_email.is_empty(), |this| {
2872 let copied_state: Entity<CopiedState> = window.use_keyed_state(
2873 "author-email-copy",
2874 cx,
2875 CopiedState::new,
2876 );
2877 let is_copied = copied_state.read(cx).is_copied();
2878
2879 let (icon, icon_color, tooltip_label) = if is_copied {
2880 (IconName::Check, Color::Success, "Email Copied!")
2881 } else {
2882 (IconName::Envelope, Color::Muted, "Copy Email")
2883 };
2884
2885 let copy_email = author_email.clone();
2886 let author_email_for_tooltip = author_email.clone();
2887
2888 this.child(
2889 Button::new("author-email-copy", author_email.clone())
2890 .start_icon(
2891 Icon::new(icon).size(IconSize::Small).color(icon_color),
2892 )
2893 .label_size(LabelSize::Small)
2894 .truncate(true)
2895 .color(Color::Muted)
2896 .tooltip(move |_, cx| {
2897 Tooltip::with_meta(
2898 tooltip_label,
2899 None,
2900 author_email_for_tooltip.clone(),
2901 cx,
2902 )
2903 })
2904 .on_click(move |_, _, cx| {
2905 copied_state.update(cx, |state, _cx| {
2906 state.mark_copied();
2907 });
2908 cx.write_to_clipboard(ClipboardItem::new_string(
2909 copy_email.to_string(),
2910 ));
2911 let state_id = copied_state.entity_id();
2912 cx.spawn(async move |cx| {
2913 cx.background_executor()
2914 .timer(COPIED_STATE_DURATION)
2915 .await;
2916 cx.update(|cx| {
2917 cx.notify(state_id);
2918 })
2919 })
2920 .detach();
2921 }),
2922 )
2923 })
2924 .child({
2925 let copy_sha = full_sha.clone();
2926 let copied_state: Entity<CopiedState> =
2927 window.use_keyed_state("sha-copy", cx, CopiedState::new);
2928 let is_copied = copied_state.read(cx).is_copied();
2929
2930 let (icon, icon_color, tooltip_label) = if is_copied {
2931 (IconName::Check, Color::Success, "Commit SHA Copied!")
2932 } else {
2933 (IconName::Hash, Color::Muted, "Copy Commit SHA")
2934 };
2935
2936 Button::new("sha-button", &full_sha)
2937 .start_icon(
2938 Icon::new(icon).size(IconSize::Small).color(icon_color),
2939 )
2940 .label_size(LabelSize::Small)
2941 .truncate(true)
2942 .color(Color::Muted)
2943 .tooltip({
2944 let full_sha = full_sha.clone();
2945 move |_, cx| {
2946 Tooltip::with_meta(
2947 tooltip_label,
2948 None,
2949 full_sha.clone(),
2950 cx,
2951 )
2952 }
2953 })
2954 .on_click(move |_, _, cx| {
2955 copied_state.update(cx, |state, _cx| {
2956 state.mark_copied();
2957 });
2958 cx.write_to_clipboard(ClipboardItem::new_string(
2959 copy_sha.to_string(),
2960 ));
2961 let state_id = copied_state.entity_id();
2962 cx.spawn(async move |cx| {
2963 cx.background_executor()
2964 .timer(COPIED_STATE_DURATION)
2965 .await;
2966 cx.update(|cx| {
2967 cx.notify(state_id);
2968 })
2969 })
2970 .detach();
2971 })
2972 })
2973 .when_some(remote.clone(), |this, remote| {
2974 let provider_name = remote.host.name();
2975 let icon = crate::get_provider_icon(provider_name.as_str());
2976 let parsed_remote = ParsedGitRemote {
2977 owner: remote.owner.as_ref().into(),
2978 repo: remote.repo.as_ref().into(),
2979 };
2980 let params = BuildCommitPermalinkParams {
2981 sha: full_sha.as_ref(),
2982 };
2983 let url = remote
2984 .host
2985 .build_commit_permalink(&parsed_remote, params)
2986 .to_string();
2987
2988 this.child(
2989 Button::new(
2990 "view-on-provider",
2991 format!("View on {}", provider_name),
2992 )
2993 .start_icon(
2994 Icon::new(icon).size(IconSize::Small).color(Color::Muted),
2995 )
2996 .label_size(LabelSize::Small)
2997 .truncate(true)
2998 .color(Color::Muted)
2999 .on_click(
3000 move |_, _, cx| {
3001 cx.open_url(&url);
3002 },
3003 ),
3004 )
3005 }),
3006 ),
3007 )
3008 .child(Divider::horizontal())
3009 .child(self.render_commit_message(window, cx))
3010 .child(Divider::horizontal())
3011 .child(
3012 v_flex()
3013 .min_w_0()
3014 .flex_1()
3015 .overflow_hidden()
3016 .child(
3017 h_flex()
3018 .p_2()
3019 .pr_3()
3020 .pb_1()
3021 .gap_1()
3022 .w_full()
3023 .justify_between()
3024 .child(
3025 h_flex()
3026 .gap_1()
3027 .child(
3028 Label::new(format!(
3029 "{} Changed {}",
3030 changed_files_count,
3031 if changed_files_count == 1 {
3032 "File"
3033 } else {
3034 "Files"
3035 }
3036 ))
3037 .size(LabelSize::Small)
3038 .color(Color::Muted),
3039 )
3040 .child(Divider::vertical())
3041 .child(view_toggle),
3042 )
3043 .child(DiffStat::new(
3044 "commit-diff-stat",
3045 total_lines_added,
3046 total_lines_removed,
3047 )),
3048 )
3049 .child(
3050 div()
3051 .id("changed-files-container")
3052 .flex_1()
3053 .min_h_0()
3054 .child({
3055 let flat_entries = changed_file_entries;
3056
3057 let entry_count = if is_tree_view {
3058 tree_entries.len()
3059 } else {
3060 flat_entries.len()
3061 };
3062 let commit_sha = full_sha.clone();
3063 let repository = repository.downgrade();
3064 let workspace = self.workspace.clone();
3065 let git_graph = cx.weak_entity();
3066 let indent_tree_entries = tree_entries.clone();
3067
3068 uniform_list(
3069 "changed-files-list",
3070 entry_count,
3071 move |range, _window, cx| {
3072 range
3073 .map(|ix| {
3074 if is_tree_view {
3075 match &tree_entries[ix] {
3076 ChangedFileTreeEntry::Directory(entry) => {
3077 entry.render(ix, git_graph.clone(), cx)
3078 }
3079 ChangedFileTreeEntry::File(entry) => {
3080 entry.entry.render(
3081 ix,
3082 entry.depth,
3083 None,
3084 commit_sha.clone(),
3085 repository.clone(),
3086 workspace.clone(),
3087 cx,
3088 )
3089 }
3090 }
3091 } else {
3092 let directory_label = (!flat_entries[ix]
3093 .dir_path
3094 .is_empty())
3095 .then(|| flat_entries[ix].dir_path.clone());
3096 flat_entries[ix].render(
3097 ix,
3098 0,
3099 directory_label,
3100 commit_sha.clone(),
3101 repository.clone(),
3102 workspace.clone(),
3103 cx,
3104 )
3105 }
3106 })
3107 .collect()
3108 },
3109 )
3110 .when(is_tree_view, |list| {
3111 list.with_decoration(
3112 ui::indent_guides(
3113 px(TREE_INDENT),
3114 IndentGuideColors::panel(cx),
3115 )
3116 .with_left_offset(
3117 ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET - px(2.),
3118 )
3119 .with_compute_indents_fn(
3120 cx.entity(),
3121 move |_, range, _window, _cx| {
3122 range
3123 .map(|ix| match indent_tree_entries.get(ix) {
3124 Some(ChangedFileTreeEntry::Directory(
3125 entry,
3126 )) => entry.depth,
3127 Some(ChangedFileTreeEntry::File(entry)) => {
3128 entry.depth
3129 }
3130 None => 0,
3131 })
3132 .collect()
3133 },
3134 ),
3135 )
3136 })
3137 .size_full()
3138 .track_scroll(&self.changed_files_scroll_handle)
3139 })
3140 .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
3141 ),
3142 )
3143 .child(Divider::horizontal())
3144 .child(
3145 h_flex().p_1p5().w_full().child(
3146 Button::new("view-commit", "View Commit")
3147 .full_width()
3148 .start_icon(
3149 Icon::new(IconName::GitCommit)
3150 .size(IconSize::Small)
3151 .color(Color::Muted),
3152 )
3153 .style(ButtonStyle::OutlinedGhost)
3154 .on_click(cx.listener(|this, _, window, cx| {
3155 this.open_selected_commit_view(window, cx);
3156 })),
3157 ),
3158 )
3159 .into_any_element()
3160 }
3161
3162 fn render_graph_canvas(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
3163 let row_height = Self::row_height(window, cx);
3164 let visible_row_count = self.visible_row_count(window, cx);
3165 let table_state = self.table_interaction_state.read(cx);
3166 let viewport_height = table_state
3167 .scroll_handle
3168 .0
3169 .borrow()
3170 .last_item_size
3171 .map(|size| size.item.height)
3172 .unwrap_or(window.viewport_size().height);
3173 let loaded_commit_count = self.graph_data.commits.len();
3174
3175 let content_height = row_height * loaded_commit_count;
3176 let max_scroll = (content_height - viewport_height).max(px(0.));
3177 let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
3178
3179 let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
3180 let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
3181
3182 let graph_viewport_width = self.graph_viewport_width(window, cx);
3183 let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
3184 self.graph_canvas_content_width()
3185 } else {
3186 graph_viewport_width
3187 };
3188 let last_visible_row = first_visible_row + visible_row_count + 1;
3189
3190 let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
3191 ..(last_visible_row).min(loaded_commit_count);
3192 let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
3193 let commit_lines: Vec<_> = self
3194 .graph_data
3195 .lines
3196 .iter()
3197 .filter(|line| {
3198 line.full_interval.start <= viewport_range.end
3199 && line.full_interval.end >= viewport_range.start
3200 })
3201 .cloned()
3202 .collect();
3203
3204 let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
3205
3206 let hovered_entry_idx = self.hovered_entry_idx;
3207 let selected_entry_idx = self.selected_entry_idx;
3208 let context_menu_target_index = self
3209 .context_menu
3210 .as_ref()
3211 .and_then(|menu| menu.target_entry_index);
3212 let is_focused = self.focus_handle.is_focused(window);
3213 let graph_canvas_bounds = self.graph_canvas_bounds.clone();
3214
3215 gpui::canvas(
3216 move |_bounds, _window, _cx| {},
3217 move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
3218 graph_canvas_bounds.set(Some(bounds));
3219
3220 window.paint_layer(bounds, |window| {
3221 let accent_colors = cx.theme().accents();
3222
3223 let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
3224 let selected_bg = if is_focused {
3225 cx.theme().colors().element_selected
3226 } else {
3227 cx.theme().colors().element_hover
3228 };
3229
3230 for visible_row_idx in 0..rows.len() {
3231 let absolute_row_idx = first_visible_row + visible_row_idx;
3232 let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
3233 let is_selected = selected_entry_idx == Some(absolute_row_idx);
3234 let is_context_menu_target =
3235 context_menu_target_index == Some(absolute_row_idx);
3236
3237 if is_hovered || is_selected || is_context_menu_target {
3238 let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
3239 - vertical_scroll_offset;
3240
3241 let row_bounds = Bounds::new(
3242 point(bounds.origin.x, row_y),
3243 gpui::Size {
3244 width: bounds.size.width,
3245 height: row_height,
3246 },
3247 );
3248
3249 let bg_color = if is_selected || is_context_menu_target {
3250 selected_bg
3251 } else {
3252 hover_bg
3253 };
3254 window.paint_quad(gpui::fill(row_bounds, bg_color));
3255 }
3256 }
3257
3258 for (row_idx, row) in rows.into_iter().enumerate() {
3259 let row_color = accent_colors.color_for_index(row.color_idx as u32);
3260 let row_y_center =
3261 bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
3262 - vertical_scroll_offset;
3263
3264 let commit_x = lane_center_x(bounds, row.lane as f32);
3265
3266 draw_commit_circle(commit_x, row_y_center, row_color, window);
3267 }
3268
3269 for line in commit_lines {
3270 let Some((start_segment_idx, start_column)) =
3271 line.get_first_visible_segment_idx(first_visible_row)
3272 else {
3273 continue;
3274 };
3275
3276 let line_x = lane_center_x(bounds, start_column as f32);
3277
3278 let start_row = line.full_interval.start as i32 - first_visible_row as i32;
3279
3280 let from_y =
3281 bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
3282 - vertical_scroll_offset
3283 + COMMIT_CIRCLE_RADIUS;
3284
3285 let mut current_row = from_y;
3286 let mut current_column = line_x;
3287
3288 let mut builder = PathBuilder::stroke(LINE_WIDTH);
3289 builder.move_to(point(line_x, from_y));
3290
3291 let segments = &line.segments[start_segment_idx..];
3292 let desired_curve_height = row_height / 3.0;
3293 let desired_curve_width = LANE_WIDTH / 3.0;
3294
3295 for (segment_idx, segment) in segments.iter().enumerate() {
3296 let is_last = segment_idx + 1 == segments.len();
3297
3298 match segment {
3299 CommitLineSegment::Straight { to_row } => {
3300 let mut dest_row = to_row_center(
3301 to_row - first_visible_row,
3302 row_height,
3303 vertical_scroll_offset,
3304 bounds,
3305 );
3306 if is_last {
3307 dest_row -= COMMIT_CIRCLE_RADIUS;
3308 }
3309
3310 let dest_point = point(current_column, dest_row);
3311
3312 current_row = dest_point.y;
3313 builder.line_to(dest_point);
3314 builder.move_to(dest_point);
3315 }
3316 CommitLineSegment::Curve {
3317 to_column,
3318 on_row,
3319 curve_kind,
3320 } => {
3321 let mut to_column = lane_center_x(bounds, *to_column as f32);
3322
3323 let mut to_row = to_row_center(
3324 *on_row - first_visible_row,
3325 row_height,
3326 vertical_scroll_offset,
3327 bounds,
3328 );
3329
3330 // This means that this branch was a checkout
3331 let going_right = to_column > current_column;
3332 let column_shift = if going_right {
3333 COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
3334 } else {
3335 -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
3336 };
3337
3338 match curve_kind {
3339 CurveKind::Checkout => {
3340 if is_last {
3341 to_column -= column_shift;
3342 }
3343
3344 let available_curve_width =
3345 (to_column - current_column).abs();
3346 let available_curve_height =
3347 (to_row - current_row).abs();
3348 let curve_width =
3349 desired_curve_width.min(available_curve_width);
3350 let curve_height =
3351 desired_curve_height.min(available_curve_height);
3352 let signed_curve_width = if going_right {
3353 curve_width
3354 } else {
3355 -curve_width
3356 };
3357 let curve_start =
3358 point(current_column, to_row - curve_height);
3359 let curve_end =
3360 point(current_column + signed_curve_width, to_row);
3361 let curve_control = point(current_column, to_row);
3362
3363 builder.move_to(point(current_column, current_row));
3364 builder.line_to(curve_start);
3365 builder.move_to(curve_start);
3366 builder.curve_to(curve_end, curve_control);
3367 builder.move_to(curve_end);
3368 builder.line_to(point(to_column, to_row));
3369 }
3370 CurveKind::Merge => {
3371 if is_last {
3372 to_row -= COMMIT_CIRCLE_RADIUS;
3373 }
3374
3375 let merge_start = point(
3376 current_column + column_shift,
3377 current_row - COMMIT_CIRCLE_RADIUS,
3378 );
3379 let available_curve_width =
3380 (to_column - merge_start.x).abs();
3381 let available_curve_height =
3382 (to_row - merge_start.y).abs();
3383 let curve_width =
3384 desired_curve_width.min(available_curve_width);
3385 let curve_height =
3386 desired_curve_height.min(available_curve_height);
3387 let signed_curve_width = if going_right {
3388 curve_width
3389 } else {
3390 -curve_width
3391 };
3392 let curve_start = point(
3393 to_column - signed_curve_width,
3394 merge_start.y,
3395 );
3396 let curve_end =
3397 point(to_column, merge_start.y + curve_height);
3398 let curve_control = point(to_column, merge_start.y);
3399
3400 builder.move_to(merge_start);
3401 builder.line_to(curve_start);
3402 builder.move_to(curve_start);
3403 builder.curve_to(curve_end, curve_control);
3404 builder.move_to(curve_end);
3405 builder.line_to(point(to_column, to_row));
3406 }
3407 }
3408 current_row = to_row;
3409 current_column = to_column;
3410 builder.move_to(point(current_column, current_row));
3411 }
3412 }
3413 }
3414
3415 builder.close();
3416 lines.entry(line.color_idx).or_default().push(builder);
3417 }
3418
3419 for (color_idx, builders) in lines {
3420 let line_color = accent_colors.color_for_index(color_idx as u32);
3421
3422 for builder in builders {
3423 if let Ok(path) = builder.build() {
3424 // we paint each color on it's own layer to stop overlapping lines
3425 // of different colors changing the color of a line
3426 window.paint_layer(bounds, |window| {
3427 window.paint_path(path, line_color);
3428 });
3429 }
3430 }
3431 }
3432 })
3433 },
3434 )
3435 .w(graph_width)
3436 .h_full()
3437 }
3438
3439 fn row_at_position(
3440 &self,
3441 position_y: Pixels,
3442 window: &Window,
3443 cx: &Context<Self>,
3444 ) -> Option<usize> {
3445 let canvas_bounds = self.graph_canvas_bounds.get()?;
3446 let table_state = self.table_interaction_state.read(cx);
3447 let scroll_offset_y = -table_state.scroll_offset().y;
3448
3449 let local_y = position_y - canvas_bounds.origin.y;
3450
3451 if local_y >= px(0.) && local_y < canvas_bounds.size.height {
3452 let absolute_y = local_y + scroll_offset_y;
3453 let row_height = Self::row_height(window, cx);
3454 let absolute_row = (absolute_y / row_height).floor() as usize;
3455
3456 if absolute_row < self.graph_data.commits.len() {
3457 return Some(absolute_row);
3458 }
3459 }
3460
3461 None
3462 }
3463
3464 fn handle_graph_mouse_move(
3465 &mut self,
3466 event: &gpui::MouseMoveEvent,
3467 window: &mut Window,
3468 cx: &mut Context<Self>,
3469 ) {
3470 if let Some(row) = self.row_at_position(event.position.y, window, cx) {
3471 if self.hovered_entry_idx != Some(row) {
3472 self.hovered_entry_idx = Some(row);
3473 cx.notify();
3474 }
3475 } else if self.hovered_entry_idx.is_some() {
3476 self.hovered_entry_idx = None;
3477 cx.notify();
3478 }
3479 }
3480
3481 fn handle_entry_click(
3482 &mut self,
3483 entry_idx: usize,
3484 event: &ClickEvent,
3485 scroll_strategy: ScrollStrategy,
3486 focus_handle: Option<&FocusHandle>,
3487 window: &mut Window,
3488 cx: &mut Context<Self>,
3489 ) {
3490 // Right-clicks open the context menu, not the details panel.
3491 if event.is_right_click() {
3492 return;
3493 }
3494
3495 if let Some(focus_handle) = focus_handle {
3496 focus_handle.focus(window, cx);
3497 }
3498
3499 self.select_entry(entry_idx, scroll_strategy, cx);
3500
3501 if event.click_count() >= 2 {
3502 self.open_commit_view(entry_idx, window, cx);
3503 }
3504 }
3505
3506 fn handle_graph_click(
3507 &mut self,
3508 event: &ClickEvent,
3509 window: &mut Window,
3510 cx: &mut Context<Self>,
3511 ) {
3512 if let Some(row) = self.row_at_position(event.position().y, window, cx) {
3513 self.handle_entry_click(row, event, ScrollStrategy::Nearest, None, window, cx);
3514 }
3515 }
3516
3517 fn handle_entry_secondary_mouse_down(
3518 &mut self,
3519 entry_idx: usize,
3520 event: &MouseDownEvent,
3521 window: &mut Window,
3522 cx: &mut Context<Self>,
3523 ) {
3524 self.deploy_entry_context_menu(event.position, entry_idx, None, window, cx);
3525 cx.stop_propagation();
3526 }
3527
3528 fn handle_graph_secondary_mouse_down(
3529 &mut self,
3530 event: &MouseDownEvent,
3531 window: &mut Window,
3532 cx: &mut Context<Self>,
3533 ) {
3534 let Some(row) = self.row_at_position(event.position.y, window, cx) else {
3535 return;
3536 };
3537
3538 self.handle_entry_secondary_mouse_down(row, event, window, cx);
3539 }
3540
3541 fn handle_graph_scroll(
3542 &mut self,
3543 event: &ScrollWheelEvent,
3544 window: &mut Window,
3545 cx: &mut Context<Self>,
3546 ) {
3547 let line_height = window.line_height();
3548 let delta = event.delta.pixel_delta(line_height);
3549
3550 let table_state = self.table_interaction_state.read(cx);
3551 let current_offset = table_state.scroll_offset();
3552
3553 let viewport_height = table_state.scroll_handle.viewport().size.height;
3554
3555 let commit_count = match self.graph_data.max_commit_count {
3556 AllCommitCount::Loading(count) => count,
3557 AllCommitCount::FullyLoaded(count) => count,
3558 AllCommitCount::NotLoaded => self.graph_data.commits.len(),
3559 };
3560 let content_height = Self::row_height(window, cx) * commit_count;
3561 let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
3562
3563 let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
3564 let new_offset = Point::new(current_offset.x, new_y);
3565
3566 if new_offset != current_offset {
3567 table_state.set_scroll_offset(new_offset);
3568 cx.notify();
3569 }
3570 }
3571
3572 fn commit_count_and_loading_state(&mut self, cx: &mut Context<Self>) -> (usize, bool) {
3573 match self.graph_data.max_commit_count {
3574 AllCommitCount::FullyLoaded(count) => (count, false),
3575 AllCommitCount::Loading(count) => {
3576 let is_loading = self
3577 .get_repository(cx)
3578 .map(|repository| {
3579 repository.update(cx, |repository, cx| {
3580 repository
3581 .graph_data(self.log_source.clone(), self.log_order, 0..0, cx)
3582 .is_loading
3583 })
3584 })
3585 .unwrap_or(false);
3586
3587 (count, is_loading)
3588 }
3589 AllCommitCount::NotLoaded => {
3590 let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) {
3591 repository.update(cx, |repository, cx| {
3592 // Start loading the graph data if we haven't started already
3593 let GraphDataResponse {
3594 commits,
3595 is_loading,
3596 error: _,
3597 } = repository.graph_data(
3598 self.log_source.clone(),
3599 self.log_order,
3600 0..usize::MAX,
3601 cx,
3602 );
3603 self.graph_data.add_commits(commits);
3604 (commits.len(), is_loading)
3605 })
3606 } else {
3607 (0, false)
3608 };
3609
3610 (commit_count, is_loading)
3611 }
3612 }
3613 }
3614
3615 fn render_commit_view_resize_handle(
3616 &self,
3617 _window: &mut Window,
3618 cx: &mut Context<Self>,
3619 ) -> AnyElement {
3620 div()
3621 .id("commit-view-split-resize-container")
3622 .relative()
3623 .h_full()
3624 .flex_shrink_0()
3625 .w(px(1.))
3626 .bg(cx.theme().colors().border_variant)
3627 .child(
3628 div()
3629 .id("commit-view-split-resize-handle")
3630 .absolute()
3631 .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
3632 .w(px(RESIZE_HANDLE_WIDTH))
3633 .h_full()
3634 .cursor_col_resize()
3635 .block_mouse_except_scroll()
3636 .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
3637 if event.click_count() >= 2 {
3638 this.commit_details_split_state.update(cx, |state, _| {
3639 state.on_double_click();
3640 });
3641 }
3642 cx.stop_propagation();
3643 }))
3644 .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
3645 )
3646 .into_any_element()
3647 }
3648
3649 fn render_commit_message(
3650 &self,
3651 window: &mut Window,
3652 cx: &mut Context<Self>,
3653 ) -> impl IntoElement {
3654 let Some(DetailPanelCommitMessage {
3655 message,
3656 scroll_handle,
3657 ..
3658 }) = self.selected_commit_message.as_ref()
3659 else {
3660 return Empty.into_any_element();
3661 };
3662
3663 let message_style = editor::hover_markdown_style(window, cx);
3664 let rem_size = window.rem_size();
3665 let line_height = message_style
3666 .base_text_style
3667 .line_height_in_pixels(rem_size);
3668
3669 div()
3670 // Using grid over flexbox because the structure of this side
3671 // panel prvents taffy from calculating a concrete width correctly,
3672 // which causes problems with text reflow when using flexbox.
3673 // grid, on the other hand, doesn't appear to give taffy the same
3674 // problems.
3675 .w_full()
3676 .py_2()
3677 .pl_2()
3678 .grid()
3679 .grid_cols(1)
3680 .gap_1()
3681 .child(
3682 div()
3683 .relative()
3684 .w_full()
3685 .child(
3686 div()
3687 .id("commit-message")
3688 .text_sm()
3689 .w_full()
3690 .max_h(line_height * 12.)
3691 .overflow_y_scroll()
3692 .track_scroll(scroll_handle)
3693 .child(MarkdownElement::new(message.clone(), message_style)),
3694 )
3695 .vertical_scrollbar_for(scroll_handle, window, cx),
3696 )
3697 .into_any_element()
3698 }
3699}
3700
3701impl Render for GitGraph {
3702 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3703 // This happens when we changed branches, we should refresh our search as well
3704 if let QueryState::Pending(query) = &mut self.search_state.state {
3705 let query = std::mem::take(query);
3706 self.search_state.state = QueryState::Empty;
3707 self.search(query, cx);
3708 }
3709 let (commit_count, is_loading) = self.commit_count_and_loading_state(cx);
3710
3711 let error = self.get_repository(cx).and_then(|repo| {
3712 repo.read(cx)
3713 .get_graph_data(self.log_source.clone(), self.log_order)
3714 .and_then(|data| data.error.clone())
3715 });
3716
3717 let content = if commit_count == 0 {
3718 let message = if let Some(error) = &error {
3719 format!("Error loading: {}", error)
3720 } else if is_loading {
3721 "Loading".to_string()
3722 } else {
3723 "No commits found".to_string()
3724 };
3725 let label = Label::new(message)
3726 .color(Color::Muted)
3727 .size(LabelSize::Large);
3728
3729 h_flex()
3730 .size_full()
3731 .gap_1()
3732 .justify_center()
3733 .child(label)
3734 .when(is_loading && error.is_none(), |this| {
3735 this.child(self.render_loading_spinner(cx))
3736 })
3737 } else {
3738 let is_path_history = matches!(self.log_source, LogSource::Path(_));
3739 let header_resize_info =
3740 HeaderResizeInfo::from_redistributable(&self.column_widths, cx);
3741
3742 let column_filter = self.column_visibility.clone();
3743
3744 // The graph column (index 0) only exists in the non-path-history layout and is
3745 // rendered as a separate canvas outside the table.
3746 let graph_visible =
3747 is_path_history || !column_filter.get(0usize).copied().unwrap_or(false);
3748
3749 let table_offset = if is_path_history { 0 } else { 1 };
3750 let table_filter = column_filter
3751 .as_slice()
3752 .get(table_offset..table_offset + TABLE_COLUMN_COUNT)
3753 .map(|slice| TableRow::from_vec(slice.to_vec(), TABLE_COLUMN_COUNT))
3754 .unwrap_or_else(|| TableRow::from_element(false, TABLE_COLUMN_COUNT));
3755 let header_widths = redistribute_hidden_widths(
3756 &self.column_widths.read(cx).widths_to_render(),
3757 Some(&column_filter),
3758 );
3759 let header_context = TableRenderContext::for_column_widths(Some(header_widths), true)
3760 .with_column_filter(Some(column_filter));
3761
3762 let [
3763 graph_fraction,
3764 description_fraction,
3765 date_fraction,
3766 author_fraction,
3767 commit_fraction,
3768 ] = self.preview_column_fractions(window, cx);
3769 let table_fraction =
3770 description_fraction + date_fraction + author_fraction + commit_fraction;
3771 let table_width_config = self.table_column_width_config(window, cx);
3772
3773 let table_collapsed = table_fraction <= f32::EPSILON;
3774 let graph_content_width = self.graph_canvas_content_width();
3775
3776 h_flex()
3777 .size_full()
3778 .child(
3779 v_flex()
3780 .flex_1()
3781 .min_w_0()
3782 .size_full()
3783 .flex()
3784 .flex_col()
3785 .child(
3786 div()
3787 .on_mouse_down(
3788 MouseButton::Right,
3789 cx.listener(|this, event: &MouseDownEvent, window, cx| {
3790 this.deploy_header_context_menu(event.position, window, cx);
3791 cx.stop_propagation();
3792 }),
3793 )
3794 .child(render_table_header(
3795 if !is_path_history {
3796 TableRow::from_vec(
3797 vec![
3798 Label::new("Graph")
3799 .color(Color::Muted)
3800 .truncate()
3801 .into_any_element(),
3802 Label::new("Description")
3803 .color(Color::Muted)
3804 .into_any_element(),
3805 Label::new("Date")
3806 .color(Color::Muted)
3807 .into_any_element(),
3808 Label::new("Author")
3809 .color(Color::Muted)
3810 .into_any_element(),
3811 Label::new("Commit")
3812 .color(Color::Muted)
3813 .into_any_element(),
3814 ],
3815 5,
3816 )
3817 } else {
3818 TableRow::from_vec(
3819 vec![
3820 Label::new("Description")
3821 .color(Color::Muted)
3822 .into_any_element(),
3823 Label::new("Date")
3824 .color(Color::Muted)
3825 .into_any_element(),
3826 Label::new("Author")
3827 .color(Color::Muted)
3828 .into_any_element(),
3829 Label::new("Commit")
3830 .color(Color::Muted)
3831 .into_any_element(),
3832 ],
3833 4,
3834 )
3835 },
3836 header_context,
3837 Some(header_resize_info),
3838 Some(self.column_widths.entity_id()),
3839 cx,
3840 )),
3841 )
3842 .child({
3843 let row_height = Self::row_height(window, cx);
3844 let selected_entry_idx = self.selected_entry_idx;
3845 let hovered_entry_idx = self.hovered_entry_idx;
3846 let context_menu_target_index = self
3847 .context_menu
3848 .as_ref()
3849 .and_then(|menu| menu.target_entry_index);
3850 let weak_self = cx.weak_entity();
3851 let focus_handle = self.focus_handle.clone();
3852 let table_focus_handle =
3853 self.table_interaction_state.read(cx).focus_handle.clone();
3854
3855 let graph_canvas = div()
3856 .id("graph-canvas")
3857 .size_full()
3858 .overflow_hidden()
3859 .cursor_pointer()
3860 .child(
3861 div()
3862 .size_full()
3863 .child(self.render_graph_canvas(window, cx)),
3864 )
3865 .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
3866 .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
3867 .on_click(cx.listener(Self::handle_graph_click))
3868 .on_mouse_down(
3869 MouseButton::Right,
3870 cx.listener(Self::handle_graph_secondary_mouse_down),
3871 )
3872 .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
3873 if !is_hovered && this.hovered_entry_idx.is_some() {
3874 this.hovered_entry_idx = None;
3875 cx.notify();
3876 }
3877 }));
3878
3879 let commits_table = Table::new(4)
3880 .interactable(&self.table_interaction_state)
3881 .hide_row_borders()
3882 .hide_row_hover()
3883 .width_config(table_width_config)
3884 .column_filter(table_filter)
3885 .map_row(move |(index, row), window, cx| {
3886 let is_selected = selected_entry_idx == Some(index);
3887 let is_hovered = hovered_entry_idx == Some(index);
3888 let is_context_menu_target =
3889 context_menu_target_index == Some(index);
3890 let table_focus_handle = table_focus_handle.clone();
3891 let is_focused = focus_handle.is_focused(window)
3892 || table_focus_handle.is_focused(window);
3893 let weak = weak_self.clone();
3894 let weak_for_hover = weak.clone();
3895 let weak_for_context_menu = weak.clone();
3896
3897 let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
3898 let selected_bg = if is_focused {
3899 cx.theme().colors().element_selected
3900 } else {
3901 cx.theme().colors().element_hover
3902 };
3903
3904 row.h(row_height)
3905 .cursor_pointer()
3906 .when(is_selected || is_context_menu_target, |row| {
3907 row.bg(selected_bg)
3908 })
3909 .when(
3910 is_hovered && !is_selected && !is_context_menu_target,
3911 |row| row.bg(hover_bg),
3912 )
3913 .on_hover(move |&is_hovered, _, cx| {
3914 weak_for_hover
3915 .update(cx, |this, cx| {
3916 if is_hovered {
3917 if this.hovered_entry_idx != Some(index) {
3918 this.hovered_entry_idx = Some(index);
3919 cx.notify();
3920 }
3921 } else if this.hovered_entry_idx == Some(index)
3922 {
3923 this.hovered_entry_idx = None;
3924 cx.notify();
3925 }
3926 })
3927 .ok();
3928 })
3929 .on_click(move |event, window, cx| {
3930 weak.update(cx, |this, cx| {
3931 this.handle_entry_click(
3932 index,
3933 event,
3934 ScrollStrategy::Center,
3935 Some(&table_focus_handle),
3936 window,
3937 cx,
3938 );
3939 })
3940 .ok();
3941 })
3942 .on_mouse_down(
3943 MouseButton::Right,
3944 move |event: &MouseDownEvent, window, cx| {
3945 weak_for_context_menu
3946 .update(cx, |this, cx| {
3947 this.handle_entry_secondary_mouse_down(
3948 index, event, window, cx,
3949 );
3950 })
3951 .ok();
3952 },
3953 )
3954 .into_any_element()
3955 })
3956 .uniform_list(
3957 "git-graph-commits",
3958 commit_count,
3959 cx.processor(Self::render_table_rows),
3960 );
3961
3962 bind_redistributable_columns(
3963 div()
3964 .relative()
3965 .flex_1()
3966 .w_full()
3967 .overflow_hidden()
3968 .child(
3969 h_flex()
3970 .size_full()
3971 .when(!is_path_history && graph_visible, |this| {
3972 this.child(
3973 div()
3974 .map(|this| {
3975 if table_collapsed {
3976 this.w(graph_content_width)
3977 } else {
3978 this.w(DefiniteLength::Fraction(
3979 graph_fraction,
3980 ))
3981 }
3982 })
3983 .h_full()
3984 .min_w_0()
3985 .overflow_hidden()
3986 .child(graph_canvas),
3987 )
3988 })
3989 .child(
3990 div()
3991 .tab_index(2)
3992 .tab_group()
3993 .tab_stop(false)
3994 .map(|this| {
3995 if table_collapsed {
3996 this.flex_1()
3997 } else {
3998 this.w(DefiniteLength::Fraction(
3999 table_fraction,
4000 ))
4001 }
4002 })
4003 .h_full()
4004 .min_w_0()
4005 .child(commits_table),
4006 ),
4007 )
4008 .child(render_redistributable_columns_resize_handles(
4009 &self.column_widths,
4010 Some(&self.column_visibility),
4011 window,
4012 cx,
4013 )),
4014 self.column_widths.clone(),
4015 Some(self.column_visibility.clone()),
4016 )
4017 }),
4018 )
4019 .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
4020 this.commit_details_split_state.update(cx, |state, cx| {
4021 state.on_drag_move(event, window, cx);
4022 });
4023 }))
4024 .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
4025 this.commit_details_split_state.update(cx, |state, _cx| {
4026 state.commit_ratio();
4027 });
4028 }))
4029 .when(self.selected_entry_idx.is_some(), |this| {
4030 this.child(self.render_commit_view_resize_handle(window, cx))
4031 .child(self.render_commit_detail_panel(window, cx))
4032 })
4033 };
4034
4035 div()
4036 .key_context("GitGraph")
4037 .track_focus(&self.focus_handle)
4038 .size_full()
4039 .bg(cx.theme().colors().editor_background)
4040 .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
4041 this.open_selected_commit_view(window, cx);
4042 }))
4043 .on_action(cx.listener(Self::copy_selected_commit_sha))
4044 .on_action(cx.listener(Self::copy_selected_commit_tag))
4045 .on_action(cx.listener(Self::cancel))
4046 .on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
4047 this.search_state
4048 .editor
4049 .update(cx, |editor, cx| editor.focus_handle(cx).focus(window, cx));
4050 this.activate_search_editor_if_focused(window, cx);
4051 }))
4052 .on_action(cx.listener(Self::select_first))
4053 .on_action(cx.listener(Self::select_prev))
4054 .on_action(cx.listener(Self::select_next))
4055 .on_action(cx.listener(Self::select_last))
4056 .on_action(cx.listener(Self::scroll_up))
4057 .on_action(cx.listener(Self::scroll_down))
4058 .on_action(cx.listener(Self::confirm))
4059 .on_action(cx.listener(Self::toggle_changed_files_view))
4060 .on_action(cx.listener(Self::focus_next_tab_stop))
4061 .on_action(cx.listener(Self::focus_previous_tab_stop))
4062 .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| {
4063 this.select_next_match(cx);
4064 }))
4065 .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| {
4066 this.select_previous_match(cx);
4067 }))
4068 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| {
4069 this.search_state.case_sensitive = !this.search_state.case_sensitive;
4070 this.search_state.state.next_state();
4071 cx.emit(ItemEvent::Edit);
4072 cx.notify();
4073 }))
4074 .child(
4075 v_flex()
4076 .size_full()
4077 .child(self.render_search_bar(cx))
4078 .child(div().flex_1().child(content)),
4079 )
4080 .children(self.context_menu.as_ref().map(|context_menu| {
4081 deferred(
4082 anchored()
4083 .position(context_menu.position)
4084 .anchor(Anchor::TopLeft)
4085 .child(context_menu.menu.clone()),
4086 )
4087 .with_priority(1)
4088 }))
4089 .on_action(cx.listener(|_, _: &buffer_search::Deploy, window, cx| {
4090 window.dispatch_action(Box::new(FocusSearch), cx);
4091 cx.stop_propagation();
4092 }))
4093 }
4094}
4095
4096impl EventEmitter<ItemEvent> for GitGraph {}
4097
4098impl Focusable for GitGraph {
4099 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4100 self.focus_handle.clone()
4101 }
4102}
4103
4104impl Item for GitGraph {
4105 type Event = ItemEvent;
4106
4107 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
4108 Some(Icon::new(IconName::GitGraph))
4109 }
4110
4111 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
4112 let repo_name = self.get_repository(cx).and_then(|repo| {
4113 repo.read(cx)
4114 .work_directory_abs_path
4115 .file_name()
4116 .map(|name| name.to_string_lossy().to_string())
4117 });
4118 let path_history_path = match &self.log_source {
4119 LogSource::Path(path) => Some(path.as_unix_str().to_string()),
4120 _ => None,
4121 };
4122
4123 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
4124 move |_, _| {
4125 v_flex()
4126 .child(Label::new(if path_history_path.is_some() {
4127 "Path History"
4128 } else {
4129 "Git Graph"
4130 }))
4131 .when_some(path_history_path.clone(), |this, path| {
4132 this.child(Label::new(path).color(Color::Muted).size(LabelSize::Small))
4133 })
4134 .when_some(repo_name.clone(), |this, name| {
4135 this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
4136 })
4137 .into_any_element()
4138 }
4139 }))))
4140 }
4141
4142 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
4143 if let LogSource::Path(path) = &self.log_source {
4144 return path
4145 .as_ref()
4146 .file_name()
4147 .map(|name| SharedString::from(name.to_string()))
4148 .unwrap_or_else(|| SharedString::from(path.as_unix_str().to_string()));
4149 }
4150
4151 self.get_repository(cx)
4152 .and_then(|repo| {
4153 repo.read(cx)
4154 .work_directory_abs_path
4155 .file_name()
4156 .map(|name| name.to_string_lossy().to_string())
4157 })
4158 .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
4159 }
4160
4161 fn show_toolbar(&self) -> bool {
4162 false
4163 }
4164
4165 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
4166 f(*event)
4167 }
4168}
4169
4170impl workspace::SerializableItem for GitGraph {
4171 fn serialized_item_kind() -> &'static str {
4172 "GitGraph"
4173 }
4174
4175 fn cleanup(
4176 workspace_id: workspace::WorkspaceId,
4177 alive_items: Vec<workspace::ItemId>,
4178 _window: &mut Window,
4179 cx: &mut App,
4180 ) -> Task<gpui::Result<()>> {
4181 workspace::delete_unloaded_items(
4182 alive_items,
4183 workspace_id,
4184 "git_graphs",
4185 &persistence::GitGraphsDb::global(cx),
4186 cx,
4187 )
4188 }
4189
4190 fn deserialize(
4191 project: Entity<project::Project>,
4192 workspace: WeakEntity<Workspace>,
4193 workspace_id: workspace::WorkspaceId,
4194 item_id: workspace::ItemId,
4195 window: &mut Window,
4196 cx: &mut App,
4197 ) -> Task<gpui::Result<Entity<Self>>> {
4198 let db = persistence::GitGraphsDb::global(cx);
4199 let Some((
4200 repo_work_path,
4201 log_source_type,
4202 log_source_value,
4203 log_order,
4204 selected_sha,
4205 search_query,
4206 search_case_sensitive,
4207 hidden_columns,
4208 )) = db.get_git_graph(item_id, workspace_id).ok().flatten()
4209 else {
4210 return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")));
4211 };
4212
4213 let state = persistence::SerializedGitGraphState {
4214 log_source_type,
4215 log_source_value,
4216 log_order,
4217 selected_sha,
4218 search_query,
4219 search_case_sensitive,
4220 hidden_columns,
4221 };
4222
4223 let window_handle = window.window_handle();
4224 let project = project.read(cx);
4225 let git_store = project.git_store().clone();
4226 let wait = project.wait_for_initial_scan(cx);
4227
4228 cx.spawn(async move |cx| {
4229 wait.await;
4230
4231 cx.update_window(window_handle, |_, window, cx| {
4232 let path = repo_work_path.as_path();
4233
4234 let repositories = git_store.read(cx).repositories();
4235 let repo_id = repositories.iter().find_map(|(&repo_id, repo)| {
4236 if repo.read(cx).snapshot().work_directory_abs_path.as_ref() == path {
4237 Some(repo_id)
4238 } else {
4239 None
4240 }
4241 });
4242
4243 let Some(repo_id) = repo_id else {
4244 return Err(anyhow::anyhow!("Repository not found for path: {:?}", path));
4245 };
4246
4247 let log_source = persistence::deserialize_log_source(&state);
4248 let log_order = persistence::deserialize_log_order(&state);
4249
4250 let git_graph = cx.new(|cx| {
4251 let mut graph =
4252 GitGraph::new(repo_id, git_store, workspace, Some(log_source), window, cx);
4253 graph.log_order = log_order;
4254
4255 if let Some(sha) = &state.selected_sha {
4256 graph.select_commit_by_sha(sha.as_str(), cx);
4257 }
4258
4259 graph
4260 });
4261
4262 git_graph.update(cx, |graph, cx| {
4263 if let Some(bits) = state.hidden_columns {
4264 let cols = graph.column_visibility.cols();
4265 let mask = persistence::deserialize_hidden_columns(bits, cols);
4266 // Never restore an all-hidden mask (e.g. from corrupt data); the UI
4267 // guarantees at least one column stays visible.
4268 if mask.iter().any(|is_hidden| !is_hidden) {
4269 graph.column_visibility = TableRow::from_vec(mask, cols);
4270 }
4271 }
4272
4273 graph.search_state.case_sensitive =
4274 state.search_case_sensitive.unwrap_or(false);
4275
4276 if let Some(query) = &state.search_query
4277 && !query.is_empty()
4278 {
4279 graph
4280 .search_state
4281 .editor
4282 .update(cx, |editor, cx| editor.set_text(query.as_str(), window, cx));
4283 graph.search(query.clone().into(), cx);
4284 }
4285 });
4286
4287 Ok(git_graph)
4288 })?
4289 })
4290 }
4291
4292 fn serialize(
4293 &mut self,
4294 workspace: &mut Workspace,
4295 item_id: workspace::ItemId,
4296 _closing: bool,
4297 _window: &mut Window,
4298 cx: &mut Context<Self>,
4299 ) -> Option<Task<gpui::Result<()>>> {
4300 let workspace_id = workspace.database_id()?;
4301 let repo = self.get_repository(cx)?;
4302 let repo_working_path = repo
4303 .read(cx)
4304 .snapshot()
4305 .work_directory_abs_path
4306 .to_string_lossy()
4307 .to_string();
4308
4309 let selected_sha = self
4310 .selected_entry_idx
4311 .and_then(|idx| self.graph_data.commits.get(idx))
4312 .map(|commit| commit.data.sha.to_string());
4313
4314 let search_query = self.search_state.editor.read(cx).text(cx);
4315 let search_query = if search_query.is_empty() {
4316 None
4317 } else {
4318 Some(search_query)
4319 };
4320
4321 let log_source_type = Some(persistence::serialize_log_source_type(&self.log_source));
4322 let log_source_value = persistence::serialize_log_source_value(&self.log_source);
4323 let log_order = Some(persistence::serialize_log_order(&self.log_order));
4324 let search_case_sensitive = Some(self.search_state.case_sensitive);
4325 let hidden_columns = Some(persistence::serialize_hidden_columns(
4326 self.column_visibility.as_slice(),
4327 ));
4328
4329 let db = persistence::GitGraphsDb::global(cx);
4330 Some(cx.background_spawn(async move {
4331 db.save_git_graph(
4332 item_id,
4333 workspace_id,
4334 repo_working_path,
4335 log_source_type,
4336 log_source_value,
4337 log_order,
4338 selected_sha,
4339 search_query,
4340 search_case_sensitive,
4341 hidden_columns,
4342 )
4343 .await
4344 }))
4345 }
4346
4347 fn should_serialize(&self, event: &Self::Event) -> bool {
4348 match event {
4349 ItemEvent::UpdateTab | ItemEvent::Edit => true,
4350 _ => false,
4351 }
4352 }
4353}
4354
4355mod persistence {
4356 use std::{path::PathBuf, str::FromStr};
4357
4358 use db::{
4359 query,
4360 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
4361 sqlez_macros::sql,
4362 };
4363 use git::{
4364 Oid,
4365 repository::{LogOrder, LogSource, RepoPath},
4366 };
4367 use workspace::WorkspaceDb;
4368
4369 pub struct GitGraphsDb(ThreadSafeConnection);
4370
4371 impl Domain for GitGraphsDb {
4372 const NAME: &str = stringify!(GitGraphsDb);
4373
4374 const MIGRATIONS: &[&str] = &[
4375 sql!(
4376 CREATE TABLE git_graphs (
4377 workspace_id INTEGER,
4378 item_id INTEGER UNIQUE,
4379 is_open INTEGER DEFAULT FALSE,
4380
4381 PRIMARY KEY(workspace_id, item_id),
4382 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
4383 ON DELETE CASCADE
4384 ) STRICT;
4385 ),
4386 sql!(
4387 ALTER TABLE git_graphs ADD COLUMN repo_working_path TEXT;
4388 ),
4389 sql!(
4390 ALTER TABLE git_graphs ADD COLUMN log_source_type TEXT;
4391 ALTER TABLE git_graphs ADD COLUMN log_source_value TEXT;
4392 ALTER TABLE git_graphs ADD COLUMN log_order TEXT;
4393 ALTER TABLE git_graphs ADD COLUMN selected_sha TEXT;
4394 ALTER TABLE git_graphs ADD COLUMN search_query TEXT;
4395 ALTER TABLE git_graphs ADD COLUMN search_case_sensitive INTEGER;
4396 ),
4397 sql!(
4398 ALTER TABLE git_graphs ADD COLUMN hidden_columns INTEGER;
4399 ),
4400 ];
4401 }
4402
4403 db::static_connection!(GitGraphsDb, [WorkspaceDb]);
4404
4405 pub const LOG_SOURCE_ALL: i32 = 0;
4406 pub const LOG_SOURCE_BRANCH: i32 = 1;
4407 pub const LOG_SOURCE_SHA: i32 = 2;
4408 pub const LOG_SOURCE_PATH: i32 = 3;
4409
4410 pub const LOG_ORDER_DATE: i32 = 0;
4411 pub const LOG_ORDER_TOPO: i32 = 1;
4412 pub const LOG_ORDER_AUTHOR_DATE: i32 = 2;
4413 pub const LOG_ORDER_REVERSE: i32 = 3;
4414
4415 pub fn serialize_log_source_type(log_source: &LogSource) -> i32 {
4416 match log_source {
4417 LogSource::All => LOG_SOURCE_ALL,
4418 LogSource::Branch(_) => LOG_SOURCE_BRANCH,
4419 LogSource::Sha(_) => LOG_SOURCE_SHA,
4420 LogSource::Path(_) => LOG_SOURCE_PATH,
4421 }
4422 }
4423
4424 pub fn serialize_log_source_value(log_source: &LogSource) -> Option<String> {
4425 match log_source {
4426 LogSource::All => None,
4427 LogSource::Branch(branch) => Some(branch.to_string()),
4428 LogSource::Sha(oid) => Some(oid.to_string()),
4429 LogSource::Path(path) => Some(path.as_unix_str().to_string()),
4430 }
4431 }
4432
4433 pub fn serialize_log_order(log_order: &LogOrder) -> i32 {
4434 match log_order {
4435 LogOrder::DateOrder => LOG_ORDER_DATE,
4436 LogOrder::TopoOrder => LOG_ORDER_TOPO,
4437 LogOrder::AuthorDateOrder => LOG_ORDER_AUTHOR_DATE,
4438 LogOrder::ReverseChronological => LOG_ORDER_REVERSE,
4439 }
4440 }
4441
4442 pub fn deserialize_log_source(state: &SerializedGitGraphState) -> LogSource {
4443 match state.log_source_type {
4444 Some(LOG_SOURCE_ALL) => LogSource::All,
4445 Some(LOG_SOURCE_BRANCH) => state
4446 .log_source_value
4447 .as_ref()
4448 .map(|v| LogSource::Branch(v.clone().into()))
4449 .unwrap_or_default(),
4450 Some(LOG_SOURCE_SHA) => state
4451 .log_source_value
4452 .as_ref()
4453 .and_then(|v| Oid::from_str(v).ok())
4454 .map(LogSource::Sha)
4455 .unwrap_or_default(),
4456 Some(LOG_SOURCE_PATH) => state
4457 .log_source_value
4458 .as_ref()
4459 .and_then(|v| RepoPath::new(v).ok())
4460 .map(LogSource::Path)
4461 .unwrap_or_default(),
4462 None | Some(_) => LogSource::default(),
4463 }
4464 }
4465
4466 pub fn deserialize_log_order(state: &SerializedGitGraphState) -> LogOrder {
4467 match state.log_order {
4468 Some(LOG_ORDER_DATE) => LogOrder::DateOrder,
4469 Some(LOG_ORDER_TOPO) => LogOrder::TopoOrder,
4470 Some(LOG_ORDER_AUTHOR_DATE) => LogOrder::AuthorDateOrder,
4471 Some(LOG_ORDER_REVERSE) => LogOrder::ReverseChronological,
4472 _ => LogOrder::default(),
4473 }
4474 }
4475
4476 /// Packs the per-column visibility mask into a bitmask (bit `i` set means column `i` is
4477 /// hidden), so it fits in a single integer database column regardless of column count.
4478 pub fn serialize_hidden_columns(hidden: &[bool]) -> i32 {
4479 hidden.iter().enumerate().fold(
4480 0,
4481 |bits, (idx, &is_hidden)| {
4482 if is_hidden { bits | (1 << idx) } else { bits }
4483 },
4484 )
4485 }
4486
4487 /// Inverse of [`serialize_hidden_columns`]. Bits beyond `cols` are ignored, and missing
4488 /// bits default to visible, so a mask saved with a different column count degrades safely.
4489 pub fn deserialize_hidden_columns(bits: i32, cols: usize) -> Vec<bool> {
4490 (0..cols).map(|idx| bits & (1 << idx) != 0).collect()
4491 }
4492
4493 #[derive(Debug, Default, Clone)]
4494 pub struct SerializedGitGraphState {
4495 pub log_source_type: Option<i32>,
4496 pub log_source_value: Option<String>,
4497 pub log_order: Option<i32>,
4498 pub selected_sha: Option<String>,
4499 pub search_query: Option<String>,
4500 pub search_case_sensitive: Option<bool>,
4501 pub hidden_columns: Option<i32>,
4502 }
4503
4504 impl GitGraphsDb {
4505 query! {
4506 pub async fn save_git_graph(
4507 item_id: workspace::ItemId,
4508 workspace_id: workspace::WorkspaceId,
4509 repo_working_path: String,
4510 log_source_type: Option<i32>,
4511 log_source_value: Option<String>,
4512 log_order: Option<i32>,
4513 selected_sha: Option<String>,
4514 search_query: Option<String>,
4515 search_case_sensitive: Option<bool>,
4516 hidden_columns: Option<i32>
4517 ) -> Result<()> {
4518 INSERT OR REPLACE INTO git_graphs(
4519 item_id, workspace_id, repo_working_path,
4520 log_source_type, log_source_value, log_order,
4521 selected_sha, search_query, search_case_sensitive,
4522 hidden_columns
4523 )
4524 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4525 }
4526 }
4527
4528 query! {
4529 pub fn get_git_graph(
4530 item_id: workspace::ItemId,
4531 workspace_id: workspace::WorkspaceId
4532 ) -> Result<Option<(
4533 PathBuf,
4534 Option<i32>,
4535 Option<String>,
4536 Option<i32>,
4537 Option<String>,
4538 Option<String>,
4539 Option<bool>,
4540 Option<i32>
4541 )>> {
4542 SELECT
4543 repo_working_path,
4544 log_source_type,
4545 log_source_value,
4546 log_order,
4547 selected_sha,
4548 search_query,
4549 search_case_sensitive,
4550 hidden_columns
4551 FROM git_graphs
4552 WHERE item_id = ? AND workspace_id = ?
4553 }
4554 }
4555 }
4556}
4557
4558#[cfg(any(test, feature = "test-support"))]
4559impl GitGraph {
4560 pub fn search_for_test(&mut self, query: SharedString, cx: &mut Context<Self>) {
4561 self.search(query, cx);
4562 }
4563
4564 pub fn search_matches_for_test(&self) -> Vec<Oid> {
4565 self.search_state.matches.iter().copied().collect()
4566 }
4567
4568 pub fn initial_commit_data_for_test(&self) -> Vec<Arc<InitialGraphCommitData>> {
4569 self.graph_data
4570 .commits
4571 .iter()
4572 .map(|commit| commit.data.clone())
4573 .collect()
4574 }
4575
4576 pub fn commit_count_and_loading_state_for_test(
4577 &mut self,
4578 cx: &mut Context<Self>,
4579 ) -> (usize, bool) {
4580 self.commit_count_and_loading_state(cx)
4581 }
4582
4583 pub fn log_source_for_test(&self) -> &LogSource {
4584 &self.log_source
4585 }
4586}
4587
4588/// Generates a random commit DAG suitable for testing git graph rendering.
4589///
4590/// The commits are ordered newest-first (like git log output), so:
4591/// - Index 0 = most recent commit (HEAD)
4592/// - Last index = oldest commit (root, has no parents)
4593/// - Parents of commit at index I must have index > I
4594///
4595/// When `adversarial` is true, generates complex topologies with many branches
4596/// and octopus merges. Otherwise generates more realistic linear histories
4597/// with occasional branches.
4598#[cfg(any(test, feature = "test-support"))]
4599pub fn generate_random_commit_dag(
4600 rng: &mut rand::rngs::StdRng,
4601 num_commits: usize,
4602 adversarial: bool,
4603) -> Vec<Arc<InitialGraphCommitData>> {
4604 use rand::Rng as _;
4605
4606 if num_commits == 0 {
4607 return Vec::new();
4608 }
4609
4610 let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
4611 let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
4612
4613 for i in 0..num_commits {
4614 let sha = oids[i];
4615
4616 let parents = if i == num_commits - 1 {
4617 smallvec![]
4618 } else {
4619 generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
4620 };
4621
4622 let ref_names = if i == 0 {
4623 vec!["HEAD".into(), "main".into()]
4624 } else if adversarial && rng.random_bool(0.1) {
4625 vec![format!("branch-{i}").into()]
4626 } else {
4627 Vec::new()
4628 };
4629
4630 commits.push(Arc::new(InitialGraphCommitData {
4631 sha,
4632 parents,
4633 ref_names,
4634 }));
4635 }
4636
4637 commits
4638}
4639
4640#[cfg(any(test, feature = "test-support"))]
4641fn generate_parents_from_oids(
4642 rng: &mut rand::rngs::StdRng,
4643 oids: &[Oid],
4644 current_idx: usize,
4645 num_commits: usize,
4646 adversarial: bool,
4647) -> SmallVec<[Oid; 1]> {
4648 use rand::{Rng as _, seq::SliceRandom as _};
4649
4650 let remaining = num_commits - current_idx - 1;
4651 if remaining == 0 {
4652 return smallvec![];
4653 }
4654
4655 if adversarial {
4656 let merge_chance = 0.4;
4657 let octopus_chance = 0.15;
4658
4659 if remaining >= 3 && rng.random_bool(octopus_chance) {
4660 let num_parents = rng.random_range(3..=remaining.min(5));
4661 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
4662 parent_indices.shuffle(rng);
4663 parent_indices
4664 .into_iter()
4665 .take(num_parents)
4666 .map(|idx| oids[idx])
4667 .collect()
4668 } else if remaining >= 2 && rng.random_bool(merge_chance) {
4669 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
4670 parent_indices.shuffle(rng);
4671 parent_indices
4672 .into_iter()
4673 .take(2)
4674 .map(|idx| oids[idx])
4675 .collect()
4676 } else {
4677 let parent_idx = rng.random_range(current_idx + 1..num_commits);
4678 smallvec![oids[parent_idx]]
4679 }
4680 } else {
4681 let merge_chance = 0.15;
4682 let skip_chance = 0.1;
4683
4684 if remaining >= 2 && rng.random_bool(merge_chance) {
4685 let first_parent = current_idx + 1;
4686 let second_parent = rng.random_range(current_idx + 2..num_commits);
4687 smallvec![oids[first_parent], oids[second_parent]]
4688 } else if rng.random_bool(skip_chance) && remaining >= 2 {
4689 let skip = rng.random_range(1..remaining.min(3));
4690 smallvec![oids[current_idx + 1 + skip]]
4691 } else {
4692 smallvec![oids[current_idx + 1]]
4693 }
4694 }
4695}
4696
4697#[cfg(test)]
4698mod tests {
4699 use super::*;
4700 use anyhow::{Context, Result, bail};
4701 use collections::{HashMap, HashSet};
4702 use fs::FakeFs;
4703 use git::Oid;
4704 use git::repository::{CommitData, InitialGraphCommitData};
4705 use gpui::{TestAppContext, UpdateGlobal};
4706 use project::git_store::{GitStoreEvent, RepositoryEvent};
4707 use project::{
4708 GIT_COMMAND_TASK_TAG, Project, TaskSourceKind, task_store::TaskSettingsLocation,
4709 };
4710 use rand::prelude::*;
4711 use serde_json::json;
4712 use settings::{SettingsStore, ThemeSettingsContent};
4713 use smallvec::{SmallVec, smallvec};
4714 use std::path::Path;
4715 use std::sync::{Arc, Mutex};
4716
4717 fn init_test(cx: &mut TestAppContext) {
4718 cx.update(|cx| {
4719 let settings_store = SettingsStore::test(cx);
4720 cx.set_global(settings_store);
4721 theme_settings::init(theme::LoadThemes::JustBase, cx);
4722 language_model::init(cx);
4723 crate::init(cx);
4724 });
4725 }
4726
4727 fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
4728 graph
4729 .commits
4730 .iter()
4731 .enumerate()
4732 .map(|(idx, entry)| (entry.data.sha, idx))
4733 .collect()
4734 }
4735
4736 fn verify_commit_order(
4737 graph: &GraphData,
4738 commits: &[Arc<InitialGraphCommitData>],
4739 ) -> Result<()> {
4740 if graph.commits.len() != commits.len() {
4741 bail!(
4742 "Commit count mismatch: graph has {} commits, expected {}",
4743 graph.commits.len(),
4744 commits.len()
4745 );
4746 }
4747
4748 for (idx, (graph_commit, expected_commit)) in
4749 graph.commits.iter().zip(commits.iter()).enumerate()
4750 {
4751 if graph_commit.data.sha != expected_commit.sha {
4752 bail!(
4753 "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
4754 idx,
4755 graph_commit.data.sha,
4756 expected_commit.sha
4757 );
4758 }
4759 }
4760
4761 Ok(())
4762 }
4763
4764 fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
4765 for line in &graph.lines {
4766 let child_row = *oid_to_row
4767 .get(&line.child)
4768 .context("Line references non-existent child commit")?;
4769
4770 let parent_row = *oid_to_row
4771 .get(&line.parent)
4772 .context("Line references non-existent parent commit")?;
4773
4774 if child_row >= parent_row {
4775 bail!(
4776 "child_row ({}) must be < parent_row ({})",
4777 child_row,
4778 parent_row
4779 );
4780 }
4781
4782 if line.full_interval.start != child_row {
4783 bail!(
4784 "full_interval.start ({}) != child_row ({})",
4785 line.full_interval.start,
4786 child_row
4787 );
4788 }
4789
4790 if line.full_interval.end != parent_row {
4791 bail!(
4792 "full_interval.end ({}) != parent_row ({})",
4793 line.full_interval.end,
4794 parent_row
4795 );
4796 }
4797
4798 if let Some(last_segment) = line.segments.last() {
4799 let segment_end_row = match last_segment {
4800 CommitLineSegment::Straight { to_row } => *to_row,
4801 CommitLineSegment::Curve { on_row, .. } => *on_row,
4802 };
4803
4804 if segment_end_row != line.full_interval.end {
4805 bail!(
4806 "last segment ends at row {} but full_interval.end is {}",
4807 segment_end_row,
4808 line.full_interval.end
4809 );
4810 }
4811 }
4812 }
4813
4814 Ok(())
4815 }
4816
4817 fn verify_column_correctness(
4818 graph: &GraphData,
4819 oid_to_row: &HashMap<Oid, usize>,
4820 ) -> Result<()> {
4821 for line in &graph.lines {
4822 let child_row = *oid_to_row
4823 .get(&line.child)
4824 .context("Line references non-existent child commit")?;
4825
4826 let parent_row = *oid_to_row
4827 .get(&line.parent)
4828 .context("Line references non-existent parent commit")?;
4829
4830 let child_lane = graph.commits[child_row].lane;
4831 if line.child_column != child_lane {
4832 bail!(
4833 "child_column ({}) != child's lane ({})",
4834 line.child_column,
4835 child_lane
4836 );
4837 }
4838
4839 let mut current_column = line.child_column;
4840 for segment in &line.segments {
4841 if let CommitLineSegment::Curve { to_column, .. } = segment {
4842 current_column = *to_column;
4843 }
4844 }
4845
4846 let parent_lane = graph.commits[parent_row].lane;
4847 if current_column != parent_lane {
4848 bail!(
4849 "ending column ({}) != parent's lane ({})",
4850 current_column,
4851 parent_lane
4852 );
4853 }
4854 }
4855
4856 Ok(())
4857 }
4858
4859 fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
4860 for line in &graph.lines {
4861 if line.segments.is_empty() {
4862 bail!("Line has no segments");
4863 }
4864
4865 let mut current_row = line.full_interval.start;
4866
4867 for (idx, segment) in line.segments.iter().enumerate() {
4868 let segment_end_row = match segment {
4869 CommitLineSegment::Straight { to_row } => *to_row,
4870 CommitLineSegment::Curve { on_row, .. } => *on_row,
4871 };
4872
4873 if segment_end_row < current_row {
4874 bail!(
4875 "segment {} ends at row {} which is before current row {}",
4876 idx,
4877 segment_end_row,
4878 current_row
4879 );
4880 }
4881
4882 current_row = segment_end_row;
4883 }
4884 }
4885
4886 Ok(())
4887 }
4888
4889 fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
4890 for line in &graph.lines {
4891 let child_row = line.full_interval.start;
4892
4893 let mut current_column = line.child_column;
4894 let mut current_row = child_row;
4895
4896 for segment in &line.segments {
4897 match segment {
4898 CommitLineSegment::Straight { to_row } => {
4899 for row in (current_row + 1)..*to_row {
4900 if row < graph.commits.len() {
4901 let commit_at_row = &graph.commits[row];
4902 if commit_at_row.lane == current_column {
4903 bail!(
4904 "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
4905 current_row,
4906 to_row,
4907 current_column,
4908 commit_at_row.data.sha,
4909 row
4910 );
4911 }
4912 }
4913 }
4914 current_row = *to_row;
4915 }
4916 CommitLineSegment::Curve {
4917 to_column, on_row, ..
4918 } => {
4919 current_column = *to_column;
4920 current_row = *on_row;
4921 }
4922 }
4923 }
4924 }
4925
4926 Ok(())
4927 }
4928
4929 fn verify_keep_shared_parents_on_leftmost_lane(graph: &GraphData) -> Result<()> {
4930 let mut active_lane_parents: Vec<Option<Oid>> = Vec::new();
4931 let mut parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>> = HashMap::default();
4932
4933 for (row, entry) in graph.commits.iter().enumerate() {
4934 let pending_lanes = parent_to_lanes.remove(&entry.data.sha).unwrap_or_default();
4935
4936 if pending_lanes.len() > 1
4937 && let Some(expected_lane) = pending_lanes.iter().copied().min()
4938 && entry.lane != expected_lane
4939 {
4940 bail!(
4941 "commit {:?} at row {} uses lane {}, but shared parent should use leftmost pending lane {} from {:?}",
4942 entry.data.sha,
4943 row,
4944 entry.lane,
4945 expected_lane,
4946 pending_lanes
4947 );
4948 }
4949
4950 for lane in pending_lanes {
4951 let Some(active_lane_parent) = active_lane_parents.get_mut(lane) else {
4952 bail!(
4953 "commit {:?} at row {} was pending on missing lane {}",
4954 entry.data.sha,
4955 row,
4956 lane
4957 );
4958 };
4959
4960 if *active_lane_parent != Some(entry.data.sha) {
4961 bail!(
4962 "commit {:?} at row {} was pending on lane {}, but that lane points to {:?}",
4963 entry.data.sha,
4964 row,
4965 lane,
4966 active_lane_parent
4967 );
4968 }
4969
4970 *active_lane_parent = None;
4971 }
4972
4973 for (parent_index, parent) in entry.data.parents.iter().enumerate() {
4974 let lane = if parent_index == 0 {
4975 entry.lane
4976 } else if let Some(empty_lane) =
4977 active_lane_parents.iter().position(Option::is_none)
4978 {
4979 empty_lane
4980 } else {
4981 active_lane_parents.push(None);
4982 active_lane_parents.len() - 1
4983 };
4984
4985 if lane >= active_lane_parents.len() {
4986 active_lane_parents.resize(lane + 1, None);
4987 }
4988
4989 active_lane_parents[lane] = Some(*parent);
4990 parent_to_lanes.entry(*parent).or_default().push(lane);
4991 }
4992 }
4993
4994 Ok(())
4995 }
4996
4997 fn verify_coverage(graph: &GraphData) -> Result<()> {
4998 let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
4999 for entry in &graph.commits {
5000 for parent in &entry.data.parents {
5001 expected_edges.insert((entry.data.sha, *parent));
5002 }
5003 }
5004
5005 let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
5006 for line in &graph.lines {
5007 let edge = (line.child, line.parent);
5008
5009 if !found_edges.insert(edge) {
5010 bail!(
5011 "Duplicate line found for edge {:?} -> {:?}",
5012 line.child,
5013 line.parent
5014 );
5015 }
5016
5017 if !expected_edges.contains(&edge) {
5018 bail!(
5019 "Orphan line found: {:?} -> {:?} is not in the commit graph",
5020 line.child,
5021 line.parent
5022 );
5023 }
5024 }
5025
5026 for (child, parent) in &expected_edges {
5027 if !found_edges.contains(&(*child, *parent)) {
5028 bail!("Missing line for edge {:?} -> {:?}", child, parent);
5029 }
5030 }
5031
5032 assert_eq!(
5033 expected_edges.symmetric_difference(&found_edges).count(),
5034 0,
5035 "The symmetric difference should be zero"
5036 );
5037
5038 Ok(())
5039 }
5040
5041 fn verify_merge_line_optimality(
5042 graph: &GraphData,
5043 oid_to_row: &HashMap<Oid, usize>,
5044 ) -> Result<()> {
5045 for line in &graph.lines {
5046 let first_segment = line.segments.first();
5047 let is_merge_line = matches!(
5048 first_segment,
5049 Some(CommitLineSegment::Curve {
5050 curve_kind: CurveKind::Merge,
5051 ..
5052 })
5053 );
5054
5055 if !is_merge_line {
5056 continue;
5057 }
5058
5059 let child_row = *oid_to_row
5060 .get(&line.child)
5061 .context("Line references non-existent child commit")?;
5062
5063 let parent_row = *oid_to_row
5064 .get(&line.parent)
5065 .context("Line references non-existent parent commit")?;
5066
5067 let parent_lane = graph.commits[parent_row].lane;
5068
5069 let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
5070 continue;
5071 };
5072
5073 let curves_directly_to_parent = *to_column == parent_lane;
5074
5075 if !curves_directly_to_parent {
5076 continue;
5077 }
5078
5079 let curve_row = child_row + 1;
5080 let has_commits_in_path = graph.commits[curve_row..parent_row]
5081 .iter()
5082 .any(|c| c.lane == parent_lane);
5083
5084 if has_commits_in_path {
5085 bail!(
5086 "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
5087 line.child,
5088 line.parent,
5089 parent_lane,
5090 curve_row,
5091 parent_row
5092 );
5093 }
5094
5095 let curve_ends_at_parent = curve_row == parent_row;
5096
5097 if curve_ends_at_parent {
5098 if line.segments.len() != 1 {
5099 bail!(
5100 "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
5101 line.child,
5102 line.parent,
5103 line.segments.len()
5104 );
5105 }
5106 } else {
5107 if line.segments.len() != 2 {
5108 bail!(
5109 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
5110 line.child,
5111 line.parent,
5112 line.segments.len()
5113 );
5114 }
5115
5116 let is_straight_segment = matches!(
5117 line.segments.get(1),
5118 Some(CommitLineSegment::Straight { .. })
5119 );
5120
5121 if !is_straight_segment {
5122 bail!(
5123 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
5124 line.child,
5125 line.parent
5126 );
5127 }
5128 }
5129 }
5130
5131 Ok(())
5132 }
5133
5134 fn verify_all_invariants(
5135 graph: &GraphData,
5136 commits: &[Arc<InitialGraphCommitData>],
5137 ) -> Result<()> {
5138 let oid_to_row = build_oid_to_row_map(graph);
5139
5140 verify_commit_order(graph, commits).context("commit order")?;
5141 verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
5142 verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
5143 verify_segment_continuity(graph).context("segment continuity")?;
5144 verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
5145 verify_keep_shared_parents_on_leftmost_lane(graph)
5146 .context("keep shared parents on leftmost lane")?;
5147 verify_coverage(graph).context("coverage")?;
5148 verify_line_overlaps(graph).context("line overlaps")?;
5149 Ok(())
5150 }
5151
5152 #[test]
5153 fn test_git_graph_merge_commits() {
5154 let mut rng = StdRng::seed_from_u64(42);
5155
5156 let oid1 = Oid::random(&mut rng);
5157 let oid2 = Oid::random(&mut rng);
5158 let oid3 = Oid::random(&mut rng);
5159 let oid4 = Oid::random(&mut rng);
5160
5161 let commits = vec![
5162 Arc::new(InitialGraphCommitData {
5163 sha: oid1,
5164 parents: smallvec![oid2, oid3],
5165 ref_names: vec!["HEAD".into()],
5166 }),
5167 Arc::new(InitialGraphCommitData {
5168 sha: oid2,
5169 parents: smallvec![oid4],
5170 ref_names: vec![],
5171 }),
5172 Arc::new(InitialGraphCommitData {
5173 sha: oid3,
5174 parents: smallvec![oid4],
5175 ref_names: vec![],
5176 }),
5177 Arc::new(InitialGraphCommitData {
5178 sha: oid4,
5179 parents: smallvec![],
5180 ref_names: vec![],
5181 }),
5182 ];
5183
5184 let mut graph_data = GraphData::new(8);
5185 graph_data.add_commits(&commits);
5186
5187 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
5188 panic!("Graph invariant violation for merge commits:\n{}", error);
5189 }
5190 }
5191
5192 #[test]
5193 fn test_git_graph_linear_commits() {
5194 let mut rng = StdRng::seed_from_u64(42);
5195
5196 let oid1 = Oid::random(&mut rng);
5197 let oid2 = Oid::random(&mut rng);
5198 let oid3 = Oid::random(&mut rng);
5199
5200 let commits = vec![
5201 Arc::new(InitialGraphCommitData {
5202 sha: oid1,
5203 parents: smallvec![oid2],
5204 ref_names: vec!["HEAD".into()],
5205 }),
5206 Arc::new(InitialGraphCommitData {
5207 sha: oid2,
5208 parents: smallvec![oid3],
5209 ref_names: vec![],
5210 }),
5211 Arc::new(InitialGraphCommitData {
5212 sha: oid3,
5213 parents: smallvec![],
5214 ref_names: vec![],
5215 }),
5216 ];
5217
5218 let mut graph_data = GraphData::new(8);
5219 graph_data.add_commits(&commits);
5220
5221 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
5222 panic!("Graph invariant violation for linear commits:\n{}", error);
5223 }
5224 }
5225
5226 #[test]
5227 fn test_git_graph_random_commits() {
5228 for seed in 0..100 {
5229 let mut rng = StdRng::seed_from_u64(seed);
5230
5231 let adversarial = rng.random_bool(0.2);
5232 let num_commits = if adversarial {
5233 rng.random_range(10..100)
5234 } else {
5235 rng.random_range(5..50)
5236 };
5237
5238 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
5239
5240 assert_eq!(
5241 num_commits,
5242 commits.len(),
5243 "seed={}: Generate random commit dag didn't generate the correct amount of commits",
5244 seed
5245 );
5246
5247 let mut graph_data = GraphData::new(8);
5248 graph_data.add_commits(&commits);
5249
5250 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
5251 panic!(
5252 "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
5253 seed, adversarial, num_commits, error
5254 );
5255 }
5256 }
5257 }
5258
5259 // The full integration test has less iterations because it's significantly slower
5260 // than the random commit test
5261 #[gpui::test(iterations = 10)]
5262 async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
5263 init_test(cx);
5264
5265 let adversarial = rng.random_bool(0.2);
5266 let num_commits = if adversarial {
5267 rng.random_range(10..100)
5268 } else {
5269 rng.random_range(5..50)
5270 };
5271
5272 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
5273
5274 let fs = FakeFs::new(cx.executor());
5275 fs.insert_tree(
5276 Path::new("/project"),
5277 json!({
5278 ".git": {},
5279 "file.txt": "content",
5280 }),
5281 )
5282 .await;
5283
5284 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
5285
5286 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5287 cx.run_until_parked();
5288
5289 let repository = project.read_with(cx, |project, cx| {
5290 project
5291 .active_repository(cx)
5292 .expect("should have a repository")
5293 });
5294
5295 repository.update(cx, |repo, cx| {
5296 repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx);
5297 });
5298 cx.run_until_parked();
5299
5300 let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
5301 repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx)
5302 .commits
5303 .to_vec()
5304 });
5305
5306 let mut graph_data = GraphData::new(8);
5307 graph_data.add_commits(&graph_commits);
5308
5309 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
5310 panic!(
5311 "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
5312 adversarial, num_commits, error
5313 );
5314 }
5315 }
5316
5317 #[gpui::test]
5318 async fn test_empty_nested_repository_graph_stops_loading(cx: &mut TestAppContext) {
5319 init_test(cx);
5320
5321 let fs = FakeFs::new(cx.executor());
5322 fs.insert_tree(
5323 Path::new("/project"),
5324 json!({
5325 "repo_a": {
5326 ".git": {},
5327 "file_a.txt": "content",
5328 },
5329 "repo_b": {
5330 ".git": {},
5331 "file_b.txt": "content",
5332 },
5333 }),
5334 )
5335 .await;
5336
5337 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5338 project
5339 .update(cx, |project, cx| project.git_scans_complete(cx))
5340 .await;
5341 cx.run_until_parked();
5342
5343 let repository = project.read_with(cx, |project, cx| {
5344 assert_eq!(project.repositories(cx).len(), 2);
5345 project
5346 .active_repository(cx)
5347 .expect("should have an active repository")
5348 });
5349
5350 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5351 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
5352 });
5353 let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
5354 let git_graph = cx.new_window_entity(|window, cx| {
5355 GitGraph::new(
5356 repository.read(cx).id,
5357 project.read(cx).git_store().clone(),
5358 workspace,
5359 None,
5360 window,
5361 cx,
5362 )
5363 });
5364 cx.run_until_parked();
5365
5366 let (commit_count, is_loading) = git_graph.update(cx, |graph, cx| {
5367 graph.commit_count_and_loading_state_for_test(cx)
5368 });
5369
5370 assert_eq!(commit_count, 0);
5371 assert!(!is_loading, "empty graph data should stop loading");
5372 }
5373
5374 #[gpui::test]
5375 async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
5376 init_test(cx);
5377
5378 let fs = FakeFs::new(cx.executor());
5379 fs.insert_tree(
5380 Path::new("/project"),
5381 json!({
5382 ".git": {},
5383 "file.txt": "content",
5384 }),
5385 )
5386 .await;
5387
5388 let mut rng = StdRng::seed_from_u64(42);
5389 let commits = generate_random_commit_dag(&mut rng, 10, false);
5390 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
5391
5392 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5393 let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
5394 project.update(cx, |project, cx| {
5395 let observed_repository_events = observed_repository_events.clone();
5396 cx.subscribe(project.git_store(), move |_, _, event, _| {
5397 if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
5398 observed_repository_events
5399 .lock()
5400 .expect("repository event mutex should be available")
5401 .push(repository_event.clone());
5402 }
5403 })
5404 .detach();
5405 });
5406
5407 let repository = project.read_with(cx, |project, cx| {
5408 project
5409 .active_repository(cx)
5410 .expect("should have a repository")
5411 });
5412
5413 repository.update(cx, |repo, cx| {
5414 repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx);
5415 });
5416
5417 project
5418 .update(cx, |project, cx| project.git_scans_complete(cx))
5419 .await;
5420 cx.run_until_parked();
5421
5422 let observed_repository_events = observed_repository_events
5423 .lock()
5424 .expect("repository event mutex should be available");
5425 assert!(
5426 observed_repository_events
5427 .iter()
5428 .any(|event| matches!(event, RepositoryEvent::HeadChanged)),
5429 "initial repository scan should emit HeadChanged"
5430 );
5431 let commit_count_after = repository.read_with(cx, |repo, _| {
5432 repo.get_graph_data(LogSource::default(), LogOrder::default())
5433 .map(|data| data.commit_data.len())
5434 .unwrap()
5435 });
5436 assert_eq!(
5437 commits.len(),
5438 commit_count_after,
5439 "initial_graph_data should remain populated after events emitted by initial repository scan"
5440 );
5441 }
5442
5443 #[gpui::test]
5444 async fn test_initial_graph_data_propagates_error(cx: &mut TestAppContext) {
5445 init_test(cx);
5446
5447 let fs = FakeFs::new(cx.executor());
5448 fs.insert_tree(
5449 Path::new("/project"),
5450 json!({
5451 ".git": {},
5452 "file.txt": "content",
5453 }),
5454 )
5455 .await;
5456
5457 fs.set_graph_error(
5458 Path::new("/project/.git"),
5459 Some("fatal: bad default revision 'HEAD'".to_string()),
5460 );
5461
5462 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5463
5464 let repository = project.read_with(cx, |project, cx| {
5465 project
5466 .active_repository(cx)
5467 .expect("should have a repository")
5468 });
5469
5470 repository.update(cx, |repo, cx| {
5471 repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx);
5472 });
5473
5474 cx.run_until_parked();
5475
5476 let error = repository.read_with(cx, |repo, _| {
5477 repo.get_graph_data(LogSource::default(), LogOrder::default())
5478 .and_then(|data| data.error.clone())
5479 });
5480
5481 assert!(
5482 error.is_some(),
5483 "graph data should contain an error after initial_graph_data fails"
5484 );
5485 let error_message = error.unwrap();
5486 assert!(
5487 error_message.contains("bad default revision"),
5488 "error should contain the git error message, got: {}",
5489 error_message
5490 );
5491 }
5492
5493 #[gpui::test]
5494 async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
5495 init_test(cx);
5496
5497 let fs = FakeFs::new(cx.executor());
5498 fs.insert_tree(
5499 Path::new("/project_a"),
5500 json!({
5501 ".git": {},
5502 "file.txt": "content",
5503 }),
5504 )
5505 .await;
5506 fs.insert_tree(
5507 Path::new("/project_b"),
5508 json!({
5509 ".git": {},
5510 "other.txt": "content",
5511 }),
5512 )
5513 .await;
5514
5515 let mut rng = StdRng::seed_from_u64(42);
5516 let commits = generate_random_commit_dag(&mut rng, 10, false);
5517 fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
5518
5519 let project = Project::test(
5520 fs.clone(),
5521 [Path::new("/project_a"), Path::new("/project_b")],
5522 cx,
5523 )
5524 .await;
5525 cx.run_until_parked();
5526
5527 let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
5528 let mut first_repository = None;
5529 let mut second_repository = None;
5530
5531 for repository in project.repositories(cx).values() {
5532 let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
5533 if work_directory_abs_path.as_ref() == Path::new("/project_a") {
5534 first_repository = Some(repository.clone());
5535 } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
5536 second_repository = Some(repository.clone());
5537 }
5538 }
5539
5540 (
5541 first_repository.expect("should have repository for /project_a"),
5542 second_repository.expect("should have repository for /project_b"),
5543 )
5544 });
5545 first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
5546 cx.run_until_parked();
5547
5548 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5549 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
5550 });
5551
5552 let workspace_weak =
5553 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
5554 let git_graph = cx.new_window_entity(|window, cx| {
5555 GitGraph::new(
5556 first_repository.read(cx).id,
5557 project.read(cx).git_store().clone(),
5558 workspace_weak,
5559 None,
5560 window,
5561 cx,
5562 )
5563 });
5564 cx.run_until_parked();
5565
5566 // Verify initial graph data is loaded
5567 let initial_commit_count =
5568 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
5569 assert!(
5570 initial_commit_count > 0,
5571 "graph data should have been loaded, got 0 commits"
5572 );
5573
5574 git_graph.update(cx, |graph, cx| {
5575 graph.set_repo_id(second_repository.read(cx).id, cx)
5576 });
5577 cx.run_until_parked();
5578
5579 let commit_count_after_clear =
5580 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
5581 assert_eq!(
5582 commit_count_after_clear, 0,
5583 "graph_data should be cleared after switching away"
5584 );
5585
5586 git_graph.update(cx, |graph, cx| {
5587 graph.set_repo_id(first_repository.read(cx).id, cx)
5588 });
5589 cx.run_until_parked();
5590
5591 cx.draw(
5592 point(px(0.), px(0.)),
5593 gpui::size(px(1200.), px(800.)),
5594 |_, _| git_graph.clone().into_any_element(),
5595 );
5596 cx.run_until_parked();
5597
5598 // Verify graph data is reloaded from repository cache on switch back
5599 let reloaded_commit_count =
5600 git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
5601 assert_eq!(
5602 reloaded_commit_count,
5603 commits.len(),
5604 "graph data should be reloaded after switching back"
5605 );
5606 }
5607
5608 #[gpui::test]
5609 async fn test_file_history_action_uses_git_panel_and_editor_sources(cx: &mut TestAppContext) {
5610 init_test(cx);
5611
5612 let fs = FakeFs::new(cx.executor());
5613 fs.insert_tree(
5614 Path::new(util::path!("/project")),
5615 json!({
5616 ".git": {},
5617 "tracked1.txt": "tracked 1",
5618 "tracked2.txt": "tracked 2",
5619 }),
5620 )
5621 .await;
5622 fs.set_status_for_repo(
5623 Path::new(util::path!("/project/.git")),
5624 &[
5625 ("tracked1.txt", StatusCode::Modified.worktree()),
5626 ("tracked2.txt", StatusCode::Modified.worktree()),
5627 ],
5628 );
5629
5630 let commits = vec![Arc::new(InitialGraphCommitData {
5631 sha: Oid::from_bytes(&[1; 20]).unwrap(),
5632 parents: smallvec![],
5633 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
5634 })];
5635 fs.set_graph_commits(Path::new(util::path!("/project/.git")), commits);
5636
5637 let project = Project::test(fs.clone(), [Path::new(util::path!("/project"))], cx).await;
5638 cx.run_until_parked();
5639
5640 let repository = project.read_with(cx, |project, cx| {
5641 project
5642 .active_repository(cx)
5643 .expect("should have active repository")
5644 });
5645 let tracked1_repo_path = RepoPath::new(&"tracked1.txt").unwrap();
5646 let tracked2_repo_path = RepoPath::new(&"tracked2.txt").unwrap();
5647 let tracked1 = repository
5648 .read_with(cx, |repository, cx| {
5649 repository.repo_path_to_project_path(&tracked1_repo_path, cx)
5650 })
5651 .expect("tracked1 should resolve to project path");
5652 let tracked2 = repository
5653 .read_with(cx, |repository, cx| {
5654 repository.repo_path_to_project_path(&tracked2_repo_path, cx)
5655 })
5656 .expect("tracked2 should resolve to project path");
5657
5658 let workspace_window = cx.add_window(|window, cx| {
5659 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
5660 });
5661 let workspace = workspace_window
5662 .read_with(cx, |multi, _| multi.workspace().clone())
5663 .expect("workspace should exist");
5664
5665 let (weak_workspace, async_window_cx) = workspace_window
5666 .update(cx, |multi, window, cx| {
5667 (multi.workspace().downgrade(), window.to_async(cx))
5668 })
5669 .expect("window should be available");
5670 cx.background_executor.allow_parking();
5671 let git_panel = cx
5672 .foreground_executor()
5673 .clone()
5674 .block_test(crate::git_panel::GitPanel::load(
5675 weak_workspace,
5676 async_window_cx,
5677 ))
5678 .expect("git panel should load");
5679 cx.background_executor.forbid_parking();
5680
5681 workspace_window
5682 .update(cx, |multi, window, cx| {
5683 let workspace = multi.workspace();
5684 workspace.update(cx, |workspace, cx| {
5685 workspace.add_panel(git_panel.clone(), window, cx);
5686 });
5687 })
5688 .expect("workspace window should be available");
5689 cx.executor().advance_clock(Duration::from_millis(100));
5690 cx.run_until_parked();
5691
5692 workspace_window
5693 .update(cx, |_, window, cx| {
5694 git_panel.update(cx, |panel, cx| {
5695 panel.select_entry_by_path(tracked1.clone(), window, cx);
5696 });
5697 git_panel.update(cx, |panel, cx| {
5698 panel.focus_handle(cx).focus(window, cx);
5699 });
5700 })
5701 .expect("workspace window should be available");
5702 cx.run_until_parked();
5703 workspace_window
5704 .update(cx, |_, window, cx| {
5705 window.dispatch_action(Box::new(git::FileHistory), cx);
5706 })
5707 .expect("workspace window should be available");
5708 cx.run_until_parked();
5709
5710 workspace.read_with(cx, |workspace, cx| {
5711 let graphs = workspace.items_of_type::<GitGraph>(cx).collect::<Vec<_>>();
5712 assert_eq!(graphs.len(), 1);
5713 assert_eq!(
5714 graphs[0].read(cx).log_source,
5715 LogSource::Path(tracked1_repo_path.clone())
5716 );
5717 });
5718
5719 workspace_window
5720 .update(cx, |_, window, cx| {
5721 git_panel.update(cx, |panel, cx| {
5722 panel.select_entry_by_path(tracked1.clone(), window, cx);
5723 });
5724 git_panel.update(cx, |panel, cx| {
5725 panel.focus_handle(cx).focus(window, cx);
5726 });
5727 })
5728 .expect("workspace window should be available");
5729 cx.run_until_parked();
5730 workspace_window
5731 .update(cx, |_, window, cx| {
5732 window.dispatch_action(Box::new(git::FileHistory), cx);
5733 })
5734 .expect("workspace window should be available");
5735 cx.run_until_parked();
5736
5737 workspace.read_with(cx, |workspace, cx| {
5738 let graphs = workspace.items_of_type::<GitGraph>(cx).collect::<Vec<_>>();
5739 assert_eq!(graphs.len(), 1);
5740 assert_eq!(
5741 graphs[0].read(cx).log_source,
5742 LogSource::Path(tracked1_repo_path.clone())
5743 );
5744 });
5745
5746 let tracked1_buffer = project
5747 .update(cx, |project, cx| project.open_buffer(tracked1.clone(), cx))
5748 .await
5749 .expect("tracked1 buffer should open");
5750 let tracked2_buffer = project
5751 .update(cx, |project, cx| project.open_buffer(tracked2.clone(), cx))
5752 .await
5753 .expect("tracked2 buffer should open");
5754 workspace_window
5755 .update(cx, |multi, window, cx| {
5756 let workspace = multi.workspace();
5757 let multibuffer = cx.new(|cx| {
5758 let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite);
5759 multibuffer.set_excerpts_for_buffer(
5760 tracked1_buffer.clone(),
5761 [Default::default()..tracked1_buffer.read(cx).max_point()],
5762 0,
5763 cx,
5764 );
5765 multibuffer.set_excerpts_for_buffer(
5766 tracked2_buffer.clone(),
5767 [Default::default()..tracked2_buffer.read(cx).max_point()],
5768 0,
5769 cx,
5770 );
5771 multibuffer
5772 });
5773 let editor = cx.new(|cx| {
5774 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx)
5775 });
5776 workspace.update(cx, |workspace, cx| {
5777 workspace.add_item_to_active_pane(
5778 Box::new(editor.clone()),
5779 None,
5780 true,
5781 window,
5782 cx,
5783 );
5784 });
5785 editor.update(cx, |editor, cx| {
5786 let snapshot = editor.buffer().read(cx).snapshot(cx);
5787 let second_excerpt_point = snapshot
5788 .range_for_buffer(tracked2_buffer.read(cx).remote_id())
5789 .expect("tracked2 excerpt should exist")
5790 .start;
5791 let anchor = snapshot.anchor_before(second_excerpt_point);
5792 editor.change_selections(
5793 editor::SelectionEffects::no_scroll(),
5794 window,
5795 cx,
5796 |selections| {
5797 selections.select_anchor_ranges([anchor..anchor]);
5798 },
5799 );
5800 window.focus(&editor.focus_handle(cx), cx);
5801 });
5802 })
5803 .expect("workspace window should be available");
5804 cx.run_until_parked();
5805
5806 workspace_window
5807 .update(cx, |_, window, cx| {
5808 window.dispatch_action(Box::new(git::FileHistory), cx);
5809 })
5810 .expect("workspace window should be available");
5811 cx.run_until_parked();
5812
5813 workspace.read_with(cx, |workspace, cx| {
5814 let graphs = workspace.items_of_type::<GitGraph>(cx).collect::<Vec<_>>();
5815 assert_eq!(graphs.len(), 2);
5816 let latest = graphs
5817 .into_iter()
5818 .max_by_key(|graph| graph.entity_id())
5819 .expect("expected a git graph");
5820 assert_eq!(
5821 latest.read(cx).log_source,
5822 LogSource::Path(tracked2_repo_path)
5823 );
5824 });
5825 }
5826
5827 #[gpui::test]
5828 fn test_serialized_state_roundtrip(_cx: &mut TestAppContext) {
5829 use persistence::SerializedGitGraphState;
5830
5831 let path = RepoPath::new(&"src/main.rs").unwrap();
5832 let sha = Oid::from_bytes(&[0xab; 20]).unwrap();
5833
5834 let state = SerializedGitGraphState {
5835 log_source_type: Some(persistence::LOG_SOURCE_PATH),
5836 log_source_value: Some("src/main.rs".to_string()),
5837 log_order: Some(persistence::LOG_ORDER_TOPO),
5838 selected_sha: Some(sha.to_string()),
5839 search_query: Some("fix bug".to_string()),
5840 search_case_sensitive: Some(true),
5841 hidden_columns: None,
5842 };
5843
5844 assert_eq!(
5845 persistence::deserialize_log_source(&state),
5846 LogSource::Path(path)
5847 );
5848 assert!(matches!(
5849 persistence::deserialize_log_order(&state),
5850 LogOrder::TopoOrder
5851 ));
5852 assert_eq!(
5853 state.selected_sha.as_deref(),
5854 Some(sha.to_string()).as_deref()
5855 );
5856 assert_eq!(state.search_query.as_deref(), Some("fix bug"));
5857 assert_eq!(state.search_case_sensitive, Some(true));
5858
5859 let all_state = SerializedGitGraphState {
5860 log_source_type: Some(persistence::LOG_SOURCE_ALL),
5861 log_source_value: None,
5862 log_order: Some(persistence::LOG_ORDER_DATE),
5863 selected_sha: None,
5864 search_query: None,
5865 search_case_sensitive: None,
5866 hidden_columns: None,
5867 };
5868 assert_eq!(
5869 persistence::deserialize_log_source(&all_state),
5870 LogSource::All
5871 );
5872 assert!(matches!(
5873 persistence::deserialize_log_order(&all_state),
5874 LogOrder::DateOrder
5875 ));
5876
5877 let branch_state = SerializedGitGraphState {
5878 log_source_type: Some(persistence::LOG_SOURCE_BRANCH),
5879 log_source_value: Some("refs/heads/main".to_string()),
5880 ..Default::default()
5881 };
5882 assert_eq!(
5883 persistence::deserialize_log_source(&branch_state),
5884 LogSource::Branch("refs/heads/main".into())
5885 );
5886
5887 let sha_state = SerializedGitGraphState {
5888 log_source_type: Some(persistence::LOG_SOURCE_SHA),
5889 log_source_value: Some(sha.to_string()),
5890 ..Default::default()
5891 };
5892 assert_eq!(
5893 persistence::deserialize_log_source(&sha_state),
5894 LogSource::Sha(sha)
5895 );
5896
5897 let empty_state = SerializedGitGraphState::default();
5898 assert_eq!(
5899 persistence::deserialize_log_source(&empty_state),
5900 LogSource::All
5901 );
5902 assert!(matches!(
5903 persistence::deserialize_log_order(&empty_state),
5904 LogOrder::DateOrder
5905 ));
5906 }
5907
5908 #[gpui::test]
5909 fn test_hidden_columns_bitmask_roundtrip(_cx: &mut TestAppContext) {
5910 let mask = [false, true, false, true, false];
5911 let bits = persistence::serialize_hidden_columns(&mask);
5912 assert_eq!(
5913 persistence::deserialize_hidden_columns(bits, mask.len()),
5914 mask.to_vec()
5915 );
5916
5917 assert_eq!(persistence::serialize_hidden_columns(&[false; 5]), 0);
5918 assert_eq!(
5919 persistence::deserialize_hidden_columns(0, 4),
5920 vec![false; 4]
5921 );
5922
5923 // A mask saved with more columns than we restore with is truncated safely, and one
5924 // saved with fewer columns defaults the extra columns to visible.
5925 let bits = persistence::serialize_hidden_columns(&[true, false, true, false, true]);
5926 assert_eq!(
5927 persistence::deserialize_hidden_columns(bits, 4),
5928 vec![true, false, true, false]
5929 );
5930 assert_eq!(
5931 persistence::deserialize_hidden_columns(bits, 6),
5932 vec![true, false, true, false, true, false]
5933 );
5934 }
5935
5936 #[gpui::test]
5937 async fn test_git_graph_state_persists_across_serialization_roundtrip(cx: &mut TestAppContext) {
5938 init_test(cx);
5939
5940 let fs = FakeFs::new(cx.executor());
5941 fs.insert_tree(
5942 Path::new("/project"),
5943 json!({
5944 ".git": {},
5945 "file.txt": "content",
5946 }),
5947 )
5948 .await;
5949
5950 let mut rng = StdRng::seed_from_u64(99);
5951 let commits = generate_random_commit_dag(&mut rng, 20, false);
5952 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
5953
5954 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5955 cx.run_until_parked();
5956
5957 let repository = project.read_with(cx, |project, cx| {
5958 project
5959 .active_repository(cx)
5960 .expect("should have a repository")
5961 });
5962
5963 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5964 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
5965 });
5966 let workspace_weak =
5967 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
5968
5969 let git_graph = cx.new_window_entity(|window, cx| {
5970 GitGraph::new(
5971 repository.read(cx).id,
5972 project.read(cx).git_store().clone(),
5973 workspace_weak.clone(),
5974 None,
5975 window,
5976 cx,
5977 )
5978 });
5979 cx.run_until_parked();
5980
5981 cx.draw(
5982 point(px(0.), px(0.)),
5983 gpui::size(px(1200.), px(800.)),
5984 |_, _| git_graph.clone().into_any_element(),
5985 );
5986 cx.run_until_parked();
5987
5988 let commit_count = git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
5989 assert!(commit_count > 0, "graph should have loaded commits, got 0");
5990
5991 let target_sha = commits[5].sha;
5992 git_graph.update(cx, |graph, _| {
5993 graph.selected_entry_idx = Some(5);
5994 });
5995
5996 let selected_sha = git_graph.read_with(&*cx, |graph, _| {
5997 graph
5998 .selected_entry_idx
5999 .and_then(|idx| graph.graph_data.commits.get(idx))
6000 .map(|c| c.data.sha.to_string())
6001 });
6002 assert_eq!(selected_sha, Some(target_sha.to_string()));
6003
6004 let item_id = workspace::ItemId::from(999_u64);
6005 let workspace_db = cx.read(|cx| workspace::WorkspaceDb::global(cx));
6006 let workspace_id = workspace_db
6007 .next_id()
6008 .await
6009 .expect("should create workspace id");
6010 let db = cx.read(|cx| persistence::GitGraphsDb::global(cx));
6011 // Hide the "Date" column (index 2 in the non-path-history layout).
6012 let hidden_columns =
6013 persistence::serialize_hidden_columns(&[false, false, true, false, false]);
6014 db.save_git_graph(
6015 item_id,
6016 workspace_id,
6017 "/project".to_string(),
6018 Some(persistence::LOG_SOURCE_ALL),
6019 None,
6020 Some(persistence::LOG_ORDER_DATE),
6021 selected_sha.clone(),
6022 Some("some query".to_string()),
6023 Some(true),
6024 Some(hidden_columns),
6025 )
6026 .await
6027 .expect("save should succeed");
6028
6029 let restored_graph = cx
6030 .update(|window, cx| {
6031 <GitGraph as workspace::SerializableItem>::deserialize(
6032 project.clone(),
6033 workspace_weak,
6034 workspace_id,
6035 item_id,
6036 window,
6037 cx,
6038 )
6039 })
6040 .await
6041 .expect("deserialization should succeed");
6042 cx.run_until_parked();
6043
6044 cx.draw(
6045 point(px(0.), px(0.)),
6046 gpui::size(px(1200.), px(800.)),
6047 |_, _| restored_graph.clone().into_any_element(),
6048 );
6049 cx.run_until_parked();
6050
6051 let restored_commit_count =
6052 restored_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
6053 assert_eq!(
6054 restored_commit_count, commit_count,
6055 "restored graph should have the same number of commits"
6056 );
6057
6058 restored_graph.read_with(&*cx, |graph, _| {
6059 assert_eq!(
6060 graph.log_source,
6061 LogSource::All,
6062 "log_source should be restored"
6063 );
6064
6065 let restored_selected_sha = graph
6066 .selected_entry_idx
6067 .and_then(|idx| graph.graph_data.commits.get(idx))
6068 .map(|c| c.data.sha.to_string());
6069 assert_eq!(
6070 restored_selected_sha, selected_sha,
6071 "selected commit should be restored via pending_select_sha"
6072 );
6073
6074 assert_eq!(
6075 graph.search_state.case_sensitive, true,
6076 "search case sensitivity should be restored"
6077 );
6078
6079 assert_eq!(
6080 graph.column_visibility.as_slice(),
6081 &[false, false, true, false, false],
6082 "hidden columns should be restored"
6083 );
6084 });
6085
6086 restored_graph.read_with(&*cx, |graph, cx| {
6087 let editor_text = graph.search_state.editor.read(cx).text(cx);
6088 assert_eq!(
6089 editor_text, "some query",
6090 "search query text should be restored in editor"
6091 );
6092 });
6093 }
6094
6095 #[gpui::test]
6096 async fn test_git_graph_search_matches_commit_hash_prefix(cx: &mut TestAppContext) {
6097 init_test(cx);
6098
6099 let fs = FakeFs::new(cx.executor());
6100 fs.insert_tree(
6101 Path::new("/project"),
6102 json!({
6103 ".git": {},
6104 "file.txt": "content",
6105 }),
6106 )
6107 .await;
6108
6109 let first_sha = Oid::from_bytes(&[1; 20]).unwrap();
6110 let target_sha = Oid::from_bytes(&[2; 20]).unwrap();
6111 let third_sha = Oid::from_bytes(&[3; 20]).unwrap();
6112 let commits = vec![
6113 Arc::new(InitialGraphCommitData {
6114 sha: first_sha,
6115 parents: smallvec![target_sha],
6116 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
6117 }),
6118 Arc::new(InitialGraphCommitData {
6119 sha: target_sha,
6120 parents: smallvec![third_sha],
6121 ref_names: vec![],
6122 }),
6123 Arc::new(InitialGraphCommitData {
6124 sha: third_sha,
6125 parents: smallvec![],
6126 ref_names: vec![],
6127 }),
6128 ];
6129 fs.set_graph_commits(Path::new("/project/.git"), commits);
6130 fs.set_commit_data(
6131 Path::new("/project/.git"),
6132 [
6133 (
6134 CommitData {
6135 sha: first_sha,
6136 parents: smallvec![target_sha],
6137 author_name: "Author".into(),
6138 author_email: "author@example.com".into(),
6139 commit_timestamp: 1,
6140 subject: "Add feature".into(),
6141 message: "Add feature".into(),
6142 },
6143 false,
6144 ),
6145 (
6146 CommitData {
6147 sha: target_sha,
6148 parents: smallvec![third_sha],
6149 author_name: "Author".into(),
6150 author_email: "author@example.com".into(),
6151 commit_timestamp: 2,
6152 subject: "Fix branch loading".into(),
6153 message: "Fix branch loading".into(),
6154 },
6155 false,
6156 ),
6157 (
6158 CommitData {
6159 sha: third_sha,
6160 parents: smallvec![],
6161 author_name: "Author".into(),
6162 author_email: "author@example.com".into(),
6163 commit_timestamp: 3,
6164 subject: "Update docs".into(),
6165 message: "Update docs".into(),
6166 },
6167 false,
6168 ),
6169 ],
6170 );
6171
6172 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6173 cx.run_until_parked();
6174
6175 let repository = project.read_with(cx, |project, cx| {
6176 project
6177 .active_repository(cx)
6178 .expect("should have a repository")
6179 });
6180 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6181 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6182 });
6183 let workspace_weak =
6184 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
6185 let git_graph = cx.new_window_entity(|window, cx| {
6186 GitGraph::new(
6187 repository.read(cx).id,
6188 project.read(cx).git_store().clone(),
6189 workspace_weak,
6190 None,
6191 window,
6192 cx,
6193 )
6194 });
6195 cx.run_until_parked();
6196
6197 git_graph.update(cx, |graph, cx| {
6198 graph.search_for_test("0202020".into(), cx);
6199 });
6200 cx.run_until_parked();
6201
6202 git_graph.read_with(&*cx, |graph, _| {
6203 assert_eq!(graph.search_matches_for_test(), vec![target_sha]);
6204 let selected_sha = graph
6205 .selected_entry_idx
6206 .and_then(|idx| graph.graph_data.commits.get(idx))
6207 .map(|commit| commit.data.sha);
6208 assert_eq!(selected_sha, Some(target_sha));
6209 });
6210
6211 git_graph.update(cx, |graph, cx| {
6212 graph.search_for_test("docs".into(), cx);
6213 });
6214 cx.run_until_parked();
6215
6216 git_graph.read_with(&*cx, |graph, _| {
6217 assert_eq!(graph.search_matches_for_test(), vec![third_sha]);
6218 });
6219 }
6220
6221 #[gpui::test]
6222 async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) {
6223 init_test(cx);
6224
6225 let fs = FakeFs::new(cx.executor());
6226 fs.insert_tree(
6227 Path::new("/project"),
6228 json!({
6229 ".git": {},
6230 "file.txt": "content",
6231 }),
6232 )
6233 .await;
6234
6235 let initial_head = Oid::from_bytes(&[1; 20]).unwrap();
6236 let initial_stash = Oid::from_bytes(&[2; 20]).unwrap();
6237 let updated_head = Oid::from_bytes(&[3; 20]).unwrap();
6238 let updated_stash = Oid::from_bytes(&[4; 20]).unwrap();
6239
6240 fs.set_graph_commits(
6241 Path::new("/project/.git"),
6242 vec![
6243 Arc::new(InitialGraphCommitData {
6244 sha: initial_head,
6245 parents: smallvec![initial_stash],
6246 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
6247 }),
6248 Arc::new(InitialGraphCommitData {
6249 sha: initial_stash,
6250 parents: smallvec![],
6251 ref_names: vec!["refs/stash".into()],
6252 }),
6253 ],
6254 );
6255 fs.with_git_state(Path::new("/project/.git"), true, |state| {
6256 state.stash_entries = git::stash::GitStash {
6257 entries: vec![git::stash::StashEntry {
6258 index: 0,
6259 oid: initial_stash,
6260 message: "initial stash".to_string(),
6261 branch: Some("main".to_string()),
6262 timestamp: 1,
6263 }]
6264 .into(),
6265 };
6266 })
6267 .unwrap();
6268
6269 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6270 cx.run_until_parked();
6271
6272 let repository = project.read_with(cx, |project, cx| {
6273 project
6274 .active_repository(cx)
6275 .expect("should have a repository")
6276 });
6277
6278 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6279 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6280 });
6281 let workspace_weak =
6282 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
6283 let git_graph = cx.new_window_entity(|window, cx| {
6284 GitGraph::new(
6285 repository.read(cx).id,
6286 project.read(cx).git_store().clone(),
6287 workspace_weak,
6288 None,
6289 window,
6290 cx,
6291 )
6292 });
6293 cx.run_until_parked();
6294
6295 let initial_shas = git_graph.read_with(&*cx, |graph, _| {
6296 graph
6297 .graph_data
6298 .commits
6299 .iter()
6300 .map(|commit| commit.data.sha)
6301 .collect::<Vec<_>>()
6302 });
6303 assert_eq!(initial_shas, vec![initial_head, initial_stash]);
6304
6305 fs.set_graph_commits(
6306 Path::new("/project/.git"),
6307 vec![
6308 Arc::new(InitialGraphCommitData {
6309 sha: updated_head,
6310 parents: smallvec![updated_stash],
6311 ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
6312 }),
6313 Arc::new(InitialGraphCommitData {
6314 sha: updated_stash,
6315 parents: smallvec![],
6316 ref_names: vec!["refs/stash".into()],
6317 }),
6318 ],
6319 );
6320 fs.with_git_state(Path::new("/project/.git"), true, |state| {
6321 state.stash_entries = git::stash::GitStash {
6322 entries: vec![git::stash::StashEntry {
6323 index: 0,
6324 oid: updated_stash,
6325 message: "updated stash".to_string(),
6326 branch: Some("main".to_string()),
6327 timestamp: 1,
6328 }]
6329 .into(),
6330 };
6331 })
6332 .unwrap();
6333
6334 project
6335 .update(cx, |project, cx| project.git_scans_complete(cx))
6336 .await;
6337 cx.run_until_parked();
6338
6339 cx.draw(
6340 point(px(0.), px(0.)),
6341 gpui::size(px(1200.), px(800.)),
6342 |_, _| git_graph.clone().into_any_element(),
6343 );
6344 cx.run_until_parked();
6345
6346 let reloaded_shas = git_graph.read_with(&*cx, |graph, _| {
6347 graph
6348 .graph_data
6349 .commits
6350 .iter()
6351 .map(|commit| commit.data.sha)
6352 .collect::<Vec<_>>()
6353 });
6354 assert_eq!(reloaded_shas, vec![updated_head, updated_stash]);
6355 }
6356
6357 #[gpui::test]
6358 async fn test_git_graph_row_at_position_rounding(cx: &mut TestAppContext) {
6359 init_test(cx);
6360
6361 let fs = FakeFs::new(cx.executor());
6362 fs.insert_tree(
6363 Path::new("/project"),
6364 serde_json::json!({
6365 ".git": {},
6366 "file.txt": "content",
6367 }),
6368 )
6369 .await;
6370
6371 let mut rng = StdRng::seed_from_u64(42);
6372 let commits = generate_random_commit_dag(&mut rng, 10, false);
6373 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
6374
6375 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6376 cx.run_until_parked();
6377
6378 let repository = project.read_with(cx, |project, cx| {
6379 project
6380 .active_repository(cx)
6381 .expect("should have a repository")
6382 });
6383
6384 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6385 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6386 });
6387
6388 let workspace_weak =
6389 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
6390
6391 let git_graph = cx.new_window_entity(|window, cx| {
6392 GitGraph::new(
6393 repository.read(cx).id,
6394 project.read(cx).git_store().clone(),
6395 workspace_weak,
6396 None,
6397 window,
6398 cx,
6399 )
6400 });
6401 cx.run_until_parked();
6402
6403 git_graph.update_in(cx, |graph, window, cx| {
6404 assert!(
6405 graph.graph_data.commits.len() >= 10,
6406 "graph should load dummy commits"
6407 );
6408
6409 let row_height = GitGraph::row_height(window, cx);
6410 let origin_y = px(100.0);
6411 graph.graph_canvas_bounds.set(Some(Bounds {
6412 origin: point(px(0.0), origin_y),
6413 size: gpui::size(px(100.0), row_height * 50.0),
6414 }));
6415
6416 // Scroll down by half a row so the row under a position near the
6417 // top of the canvas is row 1 rather than row 0.
6418 let scroll_offset = row_height * 0.75;
6419 graph.table_interaction_state.update(cx, |state, _| {
6420 state.set_scroll_offset(point(px(0.0), -scroll_offset))
6421 });
6422 let pos_y = origin_y + row_height * 0.5;
6423 let absolute_calc_row = graph.row_at_position(pos_y, window, cx);
6424
6425 assert_eq!(
6426 absolute_calc_row,
6427 Some(1),
6428 "Row calculation should yield absolute row exactly"
6429 );
6430 });
6431 }
6432
6433 #[gpui::test]
6434 async fn test_row_height_matches_uniform_list_item_height(cx: &mut TestAppContext) {
6435 init_test(cx);
6436
6437 cx.update(|cx| {
6438 SettingsStore::update_global(cx, |store, cx| {
6439 store.update_user_settings(cx, |settings| {
6440 *settings.theme = ThemeSettingsContent {
6441 ui_font_size: Some(12.7.into()),
6442 ..Default::default()
6443 }
6444 });
6445 })
6446 });
6447
6448 let fs = FakeFs::new(cx.executor());
6449 fs.insert_tree(
6450 Path::new("/project"),
6451 serde_json::json!({
6452 ".git": {},
6453 "file.txt": "content",
6454 }),
6455 )
6456 .await;
6457
6458 let mut rng = StdRng::seed_from_u64(99);
6459 let commits = generate_random_commit_dag(&mut rng, 20, false);
6460 fs.set_graph_commits(Path::new("/project/.git"), commits);
6461
6462 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6463 cx.run_until_parked();
6464
6465 let repository = project.read_with(cx, |project, cx| {
6466 project
6467 .active_repository(cx)
6468 .expect("should have a repository")
6469 });
6470
6471 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6472 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6473 });
6474
6475 let workspace_weak =
6476 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
6477
6478 let git_graph = cx.new_window_entity(|window, cx| {
6479 GitGraph::new(
6480 repository.read(cx).id,
6481 project.read(cx).git_store().clone(),
6482 workspace_weak,
6483 None,
6484 window,
6485 cx,
6486 )
6487 });
6488 cx.run_until_parked();
6489
6490 cx.draw(
6491 point(px(0.), px(0.)),
6492 gpui::size(px(1200.), px(800.)),
6493 |_, _| git_graph.clone().into_any_element(),
6494 );
6495 cx.run_until_parked();
6496
6497 git_graph.update_in(cx, |graph, window, cx| {
6498 let commit_count = graph.graph_data.commits.len();
6499 assert!(
6500 commit_count > 0,
6501 "need at least one commit to measure item height"
6502 );
6503
6504 let table_state = graph.table_interaction_state.read(cx);
6505 let item_size = table_state.scroll_handle.0.borrow().last_item_size.expect(
6506 "uniform_list should have populated last_item_size after draw(); \
6507 the table has not been laid out",
6508 );
6509
6510 let measured_item_height = item_size.contents.height / commit_count as f32;
6511 let computed_row_height = GitGraph::row_height(window, cx);
6512
6513 assert_eq!(
6514 computed_row_height, measured_item_height,
6515 "GitGraph::row_height ({}) must exactly match the height that \
6516 uniform_list measured for each table row ({}). \
6517 A mismatch means the canvas and table rows will drift when scrolling.",
6518 computed_row_height, measured_item_height,
6519 );
6520 });
6521 }
6522
6523 #[gpui::test]
6524 async fn test_copy_selected_commit_tag_with_one_tag_copies_to_clipboard(
6525 cx: &mut TestAppContext,
6526 ) {
6527 init_test(cx);
6528
6529 let fs = FakeFs::new(cx.executor());
6530 fs.insert_tree(
6531 Path::new("/project"),
6532 serde_json::json!({
6533 ".git": {},
6534 "file.txt": "content",
6535 }),
6536 )
6537 .await;
6538
6539 let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
6540 let commits = vec![Arc::new(InitialGraphCommitData {
6541 sha: commit_sha,
6542 parents: smallvec![],
6543 ref_names: vec![
6544 SharedString::from("HEAD -> main"),
6545 SharedString::from("origin/main"),
6546 SharedString::from("tag: v1.0.0"),
6547 ],
6548 })];
6549 fs.set_graph_commits(Path::new("/project/.git"), commits);
6550
6551 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6552 cx.run_until_parked();
6553
6554 let repository = project.read_with(cx, |project, cx| {
6555 project
6556 .active_repository(cx)
6557 .expect("should have a repository")
6558 });
6559
6560 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6561 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6562 });
6563 let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
6564 let workspace_weak = workspace.downgrade();
6565
6566 let git_graph = cx.new_window_entity(|window, cx| {
6567 GitGraph::new(
6568 repository.read(cx).id,
6569 project.read(cx).git_store().clone(),
6570 workspace_weak,
6571 None,
6572 window,
6573 cx,
6574 )
6575 });
6576 cx.run_until_parked();
6577
6578 git_graph.update_in(cx, |graph, window, cx| {
6579 assert_eq!(graph.graph_data.commits.len(), 1);
6580 graph.selected_entry_idx = Some(0);
6581 graph.copy_selected_commit_tag(&CopyCommitTag, window, cx);
6582 });
6583
6584 assert_eq!(
6585 cx.read_from_clipboard().and_then(|item| item.text()),
6586 Some("v1.0.0".to_string())
6587 );
6588 }
6589
6590 #[gpui::test]
6591 async fn test_copy_selected_commit_tag_with_multiple_tags_opens_picker_and_copies_selected_tag(
6592 cx: &mut TestAppContext,
6593 ) {
6594 init_test(cx);
6595
6596 let fs = FakeFs::new(cx.executor());
6597 fs.insert_tree(
6598 Path::new("/project"),
6599 serde_json::json!({
6600 ".git": {},
6601 "file.txt": "content",
6602 }),
6603 )
6604 .await;
6605
6606 let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
6607 let commits = vec![Arc::new(InitialGraphCommitData {
6608 sha: commit_sha,
6609 parents: smallvec![],
6610 ref_names: vec![
6611 SharedString::from("HEAD -> main"),
6612 SharedString::from("origin/main"),
6613 SharedString::from("tag: v1.0.0"),
6614 SharedString::from("tag: v1.1.0"),
6615 ],
6616 })];
6617 fs.set_graph_commits(Path::new("/project/.git"), commits);
6618
6619 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6620 cx.run_until_parked();
6621
6622 let repository = project.read_with(cx, |project, cx| {
6623 project
6624 .active_repository(cx)
6625 .expect("should have a repository")
6626 });
6627
6628 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6629 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6630 });
6631 let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
6632 let workspace_weak = workspace.downgrade();
6633
6634 let git_graph = cx.new_window_entity(|window, cx| {
6635 GitGraph::new(
6636 repository.read(cx).id,
6637 project.read(cx).git_store().clone(),
6638 workspace_weak,
6639 None,
6640 window,
6641 cx,
6642 )
6643 });
6644 cx.run_until_parked();
6645
6646 git_graph.update_in(cx, |graph, window, cx| {
6647 assert_eq!(graph.graph_data.commits.len(), 1);
6648 graph.selected_entry_idx = Some(0);
6649 graph.copy_selected_commit_tag(&CopyCommitTag, window, cx);
6650 });
6651
6652 // Ensure that nothing has been copied at this point
6653 assert_eq!(cx.read_from_clipboard().and_then(|item| item.text()), None);
6654
6655 let picker = workspace.update(cx, |workspace, cx| {
6656 workspace
6657 .active_modal::<CommitTagPicker>(cx)
6658 .expect("commit tag picker is not open")
6659 .read(cx)
6660 .picker
6661 .clone()
6662 });
6663
6664 picker.read_with(cx, |picker, _| {
6665 assert_eq!(picker.delegate.selected_index, 0);
6666 assert_eq!(
6667 picker.delegate.tag_names,
6668 [SharedString::from("v1.0.0"), SharedString::from("v1.1.0")]
6669 );
6670 });
6671
6672 cx.dispatch_action(menu::Confirm);
6673 cx.run_until_parked();
6674
6675 assert_eq!(
6676 cx.read_from_clipboard().and_then(|item| item.text()),
6677 Some("v1.0.0".to_string())
6678 );
6679 }
6680
6681 #[gpui::test]
6682 async fn test_open_at_commit_reuses_loaded_graph(cx: &mut TestAppContext) {
6683 init_test(cx);
6684
6685 let fs = FakeFs::new(cx.executor());
6686 fs.insert_tree(
6687 Path::new("/project"),
6688 json!({ ".git": {}, "file.txt": "content" }),
6689 )
6690 .await;
6691
6692 let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA");
6693 let second_sha = Oid::from_bytes(&[2; 20]).expect("valid commit SHA");
6694 fs.set_graph_commits(
6695 Path::new("/project/.git"),
6696 vec![
6697 Arc::new(InitialGraphCommitData {
6698 sha: second_sha,
6699 parents: smallvec![first_sha],
6700 ref_names: vec!["HEAD -> main".into()],
6701 }),
6702 Arc::new(InitialGraphCommitData {
6703 sha: first_sha,
6704 parents: smallvec![],
6705 ref_names: Vec::new(),
6706 }),
6707 ],
6708 );
6709 fs.set_commit_data(
6710 Path::new("/project/.git"),
6711 [first_sha, second_sha].map(|sha| {
6712 (
6713 CommitData {
6714 sha,
6715 parents: smallvec![],
6716 author_name: "Author".into(),
6717 author_email: "author@example.com".into(),
6718 commit_timestamp: 1_700_000_000,
6719 subject: "Commit subject".into(),
6720 message: "Commit message".into(),
6721 },
6722 false,
6723 )
6724 }),
6725 );
6726
6727 let project = Project::test(fs, [Path::new("/project")], cx).await;
6728 cx.run_until_parked();
6729
6730 let repository = project.read_with(cx, |project, cx| {
6731 project
6732 .active_repository(cx)
6733 .expect("should have a repository")
6734 });
6735 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6736 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6737 });
6738 let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
6739 let git_graph = cx.new_window_entity(|window, cx| {
6740 GitGraph::new(
6741 repository.read(cx).id,
6742 project.read(cx).git_store().clone(),
6743 workspace.downgrade(),
6744 None,
6745 window,
6746 cx,
6747 )
6748 });
6749 workspace.update_in(cx, |workspace, window, cx| {
6750 workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
6751 });
6752 cx.run_until_parked();
6753
6754 git_graph.update(cx, |graph, cx| {
6755 graph.select_commit_by_sha(first_sha, cx);
6756 });
6757 cx.run_until_parked();
6758 git_graph.update(cx, |graph, cx| {
6759 graph.select_commit_by_sha(second_sha, cx);
6760 });
6761 cx.run_until_parked();
6762
6763 workspace.update_in(cx, |workspace, window, cx| {
6764 open_or_reuse_graph(
6765 workspace,
6766 repository.read(cx).id,
6767 project.read(cx).git_store().clone(),
6768 LogSource::All,
6769 Some(first_sha.to_string()),
6770 window,
6771 cx,
6772 );
6773 });
6774 cx.run_until_parked();
6775
6776 git_graph.read_with(&*cx, |graph, _| {
6777 assert_eq!(graph.selected_entry_idx, Some(1));
6778 });
6779 }
6780
6781 #[gpui::test]
6782 async fn test_git_graph_navigation(cx: &mut TestAppContext) {
6783 init_test(cx);
6784
6785 let fs = FakeFs::new(cx.executor());
6786 fs.insert_tree(
6787 Path::new("/project"),
6788 serde_json::json!({
6789 ".git": {},
6790 "file.txt": "content",
6791 }),
6792 )
6793 .await;
6794
6795 let mut rng = StdRng::seed_from_u64(42);
6796 let commits = generate_random_commit_dag(&mut rng, 10, false);
6797 fs.set_graph_commits(Path::new("/project/.git"), commits);
6798
6799 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6800 cx.run_until_parked();
6801
6802 let repository = project.read_with(cx, |project, cx| {
6803 project
6804 .active_repository(cx)
6805 .expect("should have a repository")
6806 });
6807
6808 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6809 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
6810 });
6811
6812 let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
6813 let workspace_weak = workspace.downgrade();
6814
6815 let git_graph = cx.new_window_entity(|window, cx| {
6816 GitGraph::new(
6817 repository.read(cx).id,
6818 project.read(cx).git_store().clone(),
6819 workspace_weak,
6820 None,
6821 window,
6822 cx,
6823 )
6824 });
6825 cx.run_until_parked();
6826
6827 workspace.update_in(cx, |workspace, window, cx| {
6828 workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
6829 });
6830 cx.run_until_parked();
6831
6832 git_graph.update_in(cx, |graph, window, cx| {
6833 graph.focus_handle(cx).focus(window, cx);
6834 });
6835 cx.run_until_parked();
6836
6837 cx.draw(
6838 point(px(0.), px(0.)),
6839 gpui::size(px(1200.), px(800.)),
6840 |_, _| multi_workspace.clone().into_any_element(),
6841 );
6842 cx.run_until_parked();
6843
6844 git_graph.update_in(cx, |graph, window, cx| {
6845 graph.focus_handle(cx).focus(window, cx);
6846 });
6847 cx.run_until_parked();
6848
6849 git_graph.read_with(&*cx, |graph, _| {
6850 assert_eq!(graph.graph_data.commits.len(), 10);
6851 });
6852 git_graph.read_with(&*cx, |graph, _| {
6853 assert_eq!(graph.selected_entry_idx, None);
6854 });
6855
6856 git_graph.update_in(cx, |graph, window, cx| {
6857 graph.select_first(&menu::SelectFirst, window, cx);
6858 });
6859 cx.run_until_parked();
6860 git_graph.read_with(&*cx, |graph, _| {
6861 assert_eq!(graph.selected_entry_idx, Some(0));
6862 });
6863
6864 let scroll_step = git_graph.update_in(cx, |graph, window, cx| {
6865 (graph.visible_row_count(window, cx) / 2).max(1)
6866 });
6867
6868 cx.dispatch_action(ScrollDown);
6869 cx.run_until_parked();
6870 git_graph.read_with(&*cx, |graph, _| {
6871 assert_eq!(graph.selected_entry_idx, Some(scroll_step));
6872 });
6873
6874 cx.dispatch_action(ScrollUp);
6875 cx.run_until_parked();
6876 git_graph.read_with(&*cx, |graph, _| {
6877 assert_eq!(graph.selected_entry_idx, Some(0));
6878 });
6879
6880 git_graph.update_in(cx, |graph, window, cx| {
6881 graph.select_next(&menu::SelectNext, window, cx);
6882 });
6883 cx.run_until_parked();
6884 git_graph.read_with(&*cx, |graph, _| {
6885 assert_eq!(graph.selected_entry_idx, Some(1));
6886 });
6887
6888 git_graph.update_in(cx, |graph, window, cx| {
6889 graph.select_prev(&menu::SelectPrevious, window, cx);
6890 });
6891 cx.run_until_parked();
6892 git_graph.read_with(&*cx, |graph, _| {
6893 assert_eq!(graph.selected_entry_idx, Some(0));
6894 });
6895
6896 git_graph.update_in(cx, |graph, window, cx| {
6897 graph.select_last(&menu::SelectLast, window, cx);
6898 });
6899 cx.run_until_parked();
6900 git_graph.read_with(&*cx, |graph, _| {
6901 assert_eq!(graph.selected_entry_idx, Some(9));
6902 });
6903
6904 cx.dispatch_action(ScrollDown);
6905 cx.run_until_parked();
6906 git_graph.read_with(&*cx, |graph, _| {
6907 assert_eq!(graph.selected_entry_idx, Some(9));
6908 });
6909
6910 git_graph.update_in(cx, |graph, window, cx| {
6911 graph.select_next(&menu::SelectNext, window, cx);
6912 });
6913 cx.run_until_parked();
6914 git_graph.read_with(&*cx, |graph, _| {
6915 assert_eq!(graph.selected_entry_idx, Some(9));
6916 });
6917
6918 git_graph.update_in(cx, |graph, window, cx| {
6919 graph.select_prev(&menu::SelectPrevious, window, cx);
6920 });
6921 cx.run_until_parked();
6922 git_graph.read_with(&*cx, |graph, _| {
6923 assert_eq!(graph.selected_entry_idx, Some(8));
6924 });
6925
6926 git_graph.update(cx, |graph, cx| {
6927 graph.selected_entry_idx = None;
6928 cx.notify();
6929 });
6930 cx.run_until_parked();
6931 git_graph.update_in(cx, |graph, window, cx| {
6932 graph.select_prev(&menu::SelectPrevious, window, cx);
6933 });
6934 cx.run_until_parked();
6935 git_graph.read_with(&*cx, |graph, _| {
6936 assert_eq!(graph.selected_entry_idx, Some(0));
6937 });
6938
6939 git_graph.update(cx, |graph, cx| {
6940 graph.selected_entry_idx = None;
6941 cx.notify();
6942 });
6943 cx.run_until_parked();
6944 git_graph.update_in(cx, |graph, window, cx| {
6945 graph.select_next(&menu::SelectNext, window, cx);
6946 });
6947 cx.run_until_parked();
6948 git_graph.read_with(&*cx, |graph, _| {
6949 assert_eq!(graph.selected_entry_idx, Some(0));
6950 });
6951 }
6952
6953 #[gpui::test]
6954 async fn test_global_git_command_task_runs_from_context_menu(cx: &mut TestAppContext) {
6955 init_test(cx);
6956
6957 let fs = FakeFs::new(cx.executor());
6958 fs.insert_tree(
6959 Path::new("/project"),
6960 json!({
6961 ".git": {},
6962 "file.txt": "content",
6963 }),
6964 )
6965 .await;
6966
6967 let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12")
6968 .expect("commit SHA should be valid");
6969 fs.set_graph_commits(
6970 Path::new("/project/.git"),
6971 vec![Arc::new(InitialGraphCommitData {
6972 sha: commit_sha,
6973 parents: SmallVec::new(),
6974 ref_names: Vec::new(),
6975 })],
6976 );
6977
6978 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6979 cx.run_until_parked();
6980
6981 let repository = project.read_with(cx, |project, cx| {
6982 project
6983 .active_repository(cx)
6984 .expect("project should have an active repository")
6985 });
6986 let task_inventory = project.read_with(cx, |project, cx| {
6987 project
6988 .task_store()
6989 .read(cx)
6990 .task_inventory()
6991 .cloned()
6992 .expect("project should have a task inventory")
6993 });
6994
6995 task_inventory.update(cx, |inventory, _| {
6996 inventory
6997 .update_file_based_tasks(
6998 TaskSettingsLocation::Global(Path::new("/tasks.json")),
6999 Some(
7000 &serde_json::to_string(&json!([
7001 // Tagged global task that should be scheduled from the Git graph context menu.
7002 {
7003 "label": "Git Show $ZED_GIT_SHA_SHORT",
7004 "command": "git",
7005 "args": ["show", "$ZED_GIT_SHA"],
7006 "cwd": "$ZED_GIT_REPOSITORY_PATH",
7007 "env": {
7008 "REPOSITORY": "$ZED_GIT_REPOSITORY_NAME",
7009 },
7010 "tags": [GIT_COMMAND_TASK_TAG],
7011 },
7012 // Untagged task that should not appear in the Git graph context menu.
7013 {
7014 "label": "Git Status",
7015 "command": "git",
7016 "args": ["status"],
7017 },
7018 // Tagged task that still should not appear because Git graph task contexts
7019 // do not provide editor-specific variables.
7020 {
7021 "label": "Print File $ZED_FILE",
7022 "command": "echo",
7023 "args": ["$ZED_FILE"],
7024 "tags": [GIT_COMMAND_TASK_TAG],
7025 },
7026 ]))
7027 .expect("tasks JSON should serialize"),
7028 ),
7029 )
7030 .expect("tasks should parse");
7031 });
7032
7033 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7034 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
7035 });
7036 let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| {
7037 multi_workspace.workspace().clone()
7038 });
7039 let workspace_weak = workspace.downgrade();
7040
7041 let git_graph = cx.new_window_entity(|window, cx| {
7042 GitGraph::new(
7043 repository.read(cx).id,
7044 project.read(cx).git_store().clone(),
7045 workspace_weak,
7046 None,
7047 window,
7048 cx,
7049 )
7050 });
7051 workspace.update_in(cx, |workspace, window, cx| {
7052 workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
7053 });
7054 cx.run_until_parked();
7055
7056 git_graph.update_in(cx, |git_graph, window, cx| {
7057 assert_eq!(git_graph.graph_data.commits.len(), 1);
7058 git_graph.deploy_entry_context_menu(point(px(20.), px(20.)), 0, None, window, cx);
7059 });
7060 cx.run_until_parked();
7061
7062 let context_menu = git_graph.read_with(&*cx, |git_graph, _| {
7063 git_graph
7064 .context_menu
7065 .as_ref()
7066 .expect("context menu should be open")
7067 .menu
7068 .clone()
7069 });
7070 context_menu.update_in(cx, |context_menu, window, cx| {
7071 context_menu
7072 .select_last(window, cx)
7073 .expect("custom Git task should be selectable");
7074 context_menu.confirm(&menu::Confirm, window, cx);
7075 });
7076 cx.run_until_parked();
7077
7078 let (task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| {
7079 inventory
7080 .last_scheduled_task(None)
7081 .expect("custom Git task should be scheduled")
7082 });
7083
7084 assert!(
7085 matches!(task_source_kind, TaskSourceKind::AbsPath { .. }),
7086 "scheduled task should come from global tasks"
7087 );
7088 assert_eq!(resolved_task.resolved_label, "Git Show abcdef1");
7089 assert_eq!(resolved_task.resolved.command, Some("git".to_string()));
7090 assert_eq!(
7091 resolved_task.resolved.args,
7092 vec![
7093 "show".to_string(),
7094 "abcdef1234567890abcdef1234567890abcdef12".to_string(),
7095 ]
7096 );
7097 assert_eq!(
7098 resolved_task.resolved.cwd,
7099 Some(Path::new("/project").to_path_buf())
7100 );
7101 assert_eq!(
7102 resolved_task.resolved.env.get("REPOSITORY"),
7103 Some(&"project".to_string())
7104 );
7105 }
7106
7107 #[gpui::test]
7108 async fn test_global_git_command_task_runs_from_ref_context_menu(cx: &mut TestAppContext) {
7109 init_test(cx);
7110
7111 let fs = FakeFs::new(cx.executor());
7112 fs.insert_tree(
7113 Path::new("/project"),
7114 json!({
7115 ".git": {},
7116 "file.txt": "content",
7117 }),
7118 )
7119 .await;
7120
7121 let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12")
7122 .expect("commit SHA should be valid");
7123 fs.set_graph_commits(
7124 Path::new("/project/.git"),
7125 vec![Arc::new(InitialGraphCommitData {
7126 sha: commit_sha,
7127 parents: SmallVec::new(),
7128 ref_names: vec!["HEAD -> feature-x".into()],
7129 })],
7130 );
7131
7132 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7133 cx.run_until_parked();
7134
7135 let repository = project.read_with(cx, |project, cx| {
7136 project
7137 .active_repository(cx)
7138 .expect("project should have an active repository")
7139 });
7140 let task_inventory = project.read_with(cx, |project, cx| {
7141 project
7142 .task_store()
7143 .read(cx)
7144 .task_inventory()
7145 .cloned()
7146 .expect("project should have a task inventory")
7147 });
7148
7149 task_inventory.update(cx, |inventory, _| {
7150 inventory
7151 .update_file_based_tasks(
7152 TaskSettingsLocation::Global(Path::new("/tasks.json")),
7153 Some(
7154 &serde_json::to_string(&json!([
7155 {
7156 "label": "Check out $ZED_GIT_REF",
7157 "command": "git",
7158 "args": ["checkout", "$ZED_GIT_REF"],
7159 "cwd": "$ZED_GIT_REPOSITORY_PATH",
7160 "tags": [GIT_COMMAND_TASK_TAG],
7161 },
7162 ]))
7163 .expect("tasks JSON should serialize"),
7164 ),
7165 )
7166 .expect("tasks should parse");
7167 });
7168
7169 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7170 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
7171 });
7172 let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| {
7173 multi_workspace.workspace().clone()
7174 });
7175 let workspace_weak = workspace.downgrade();
7176
7177 let git_graph = cx.new_window_entity(|window, cx| {
7178 GitGraph::new(
7179 repository.read(cx).id,
7180 project.read(cx).git_store().clone(),
7181 workspace_weak,
7182 None,
7183 window,
7184 cx,
7185 )
7186 });
7187 workspace.update_in(cx, |workspace, window, cx| {
7188 workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
7189 });
7190 cx.run_until_parked();
7191
7192 git_graph.update_in(cx, |git_graph, window, cx| {
7193 assert_eq!(git_graph.graph_data.commits.len(), 1);
7194 git_graph.deploy_entry_context_menu(
7195 point(px(20.), px(20.)),
7196 0,
7197 Some("feature-x".into()),
7198 window,
7199 cx,
7200 );
7201 });
7202 cx.run_until_parked();
7203
7204 let context_menu = git_graph.read_with(&*cx, |git_graph, _| {
7205 git_graph
7206 .context_menu
7207 .as_ref()
7208 .expect("context menu should be open")
7209 .menu
7210 .clone()
7211 });
7212 context_menu.update_in(cx, |context_menu, window, cx| {
7213 context_menu
7214 .select_last(window, cx)
7215 .expect("custom Git task should be selectable");
7216 context_menu.confirm(&menu::Confirm, window, cx);
7217 });
7218 cx.run_until_parked();
7219
7220 let (_task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| {
7221 inventory
7222 .last_scheduled_task(None)
7223 .expect("custom Git task should be scheduled")
7224 });
7225
7226 assert_eq!(resolved_task.resolved_label, "Check out feature-x");
7227 assert_eq!(
7228 resolved_task.resolved.args,
7229 vec!["checkout".to_string(), "feature-x".to_string()]
7230 );
7231 }
7232
7233 #[test]
7234 fn test_ref_name_from_decoration() {
7235 assert_eq!(
7236 GitGraph::ref_name_from_decoration("HEAD -> main"),
7237 Some("main".into())
7238 );
7239 assert_eq!(
7240 GitGraph::ref_name_from_decoration("main"),
7241 Some("main".into())
7242 );
7243 assert_eq!(
7244 GitGraph::ref_name_from_decoration("origin/main"),
7245 Some("origin/main".into())
7246 );
7247 assert_eq!(
7248 GitGraph::ref_name_from_decoration("tag: v1.0"),
7249 Some("v1.0".into())
7250 );
7251 assert_eq!(GitGraph::ref_name_from_decoration("HEAD"), None);
7252 }
7253
7254 #[gpui::test]
7255 async fn test_commit_message_rendered_as_markdown(cx: &mut TestAppContext) {
7256 init_test(cx);
7257
7258 let fs = FakeFs::new(cx.executor());
7259 fs.insert_tree(
7260 Path::new("/project"),
7261 json!({ ".git": {}, "file.txt": "content" }),
7262 )
7263 .await;
7264
7265 let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
7266 let commits = vec![Arc::new(InitialGraphCommitData {
7267 sha: commit_sha,
7268 parents: smallvec![],
7269 ref_names: vec!["HEAD -> main".into()],
7270 })];
7271 fs.set_graph_commits(Path::new("/project/.git"), commits);
7272 fs.set_commit_data(
7273 Path::new("/project/.git"),
7274 [(
7275 CommitData {
7276 sha: commit_sha,
7277 parents: smallvec![],
7278 author_name: "Author".into(),
7279 author_email: "author@example.com".into(),
7280 commit_timestamp: 1_700_000_000,
7281 subject: "Fix crash".into(),
7282 message: "Fix crash\n\nThis fixes a crash that occurred when...".into(),
7283 },
7284 false,
7285 )],
7286 );
7287
7288 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7289 cx.run_until_parked();
7290
7291 let repository = project.read_with(cx, |project, cx| {
7292 project
7293 .active_repository(cx)
7294 .expect("should have a repository")
7295 });
7296
7297 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7298 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
7299 });
7300 let workspace_weak =
7301 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
7302
7303 let git_graph = cx.new_window_entity(|window, cx| {
7304 GitGraph::new(
7305 repository.read(cx).id,
7306 project.read(cx).git_store().clone(),
7307 workspace_weak,
7308 None,
7309 window,
7310 cx,
7311 )
7312 });
7313 cx.run_until_parked();
7314
7315 // Select the commit to trigger loading the commit message
7316 git_graph.update_in(cx, |graph, window, cx| {
7317 graph.select_first(&menu::SelectFirst, window, cx);
7318 });
7319 cx.run_until_parked();
7320
7321 // Verify the commit message was loaded as markdown
7322 git_graph.read_with(&*cx, |graph, app| {
7323 let message = graph
7324 .selected_commit_message
7325 .as_ref()
7326 .expect("selected_commit_message should be Some");
7327 assert_eq!(message.sha, commit_sha);
7328 let source = message.message.read_with(app, |m, _| m.source().to_owned());
7329 assert!(source.contains("Fix crash"));
7330 assert!(source.contains("This fixes a crash"));
7331 });
7332 }
7333
7334 #[gpui::test]
7335 async fn test_long_commit_message_is_constrained_to_scroll_viewport(cx: &mut TestAppContext) {
7336 init_test(cx);
7337
7338 let fs = FakeFs::new(cx.executor());
7339 fs.insert_tree(
7340 Path::new("/project"),
7341 json!({ ".git": {}, "file.txt": "content" }),
7342 )
7343 .await;
7344
7345 let commit_sha = Oid::from_bytes(&[1; 20]).expect("commit SHA should be valid");
7346 let commits = vec![Arc::new(InitialGraphCommitData {
7347 sha: commit_sha,
7348 parents: smallvec![],
7349 ref_names: vec!["HEAD -> main".into()],
7350 })];
7351 fs.set_graph_commits(Path::new("/project/.git"), commits);
7352
7353 let message = (0..40)
7354 .map(|line_number| {
7355 format!(
7356 "Line {line_number}: This commit message is long enough to require scrolling."
7357 )
7358 })
7359 .collect::<Vec<_>>()
7360 .join("\n\n");
7361 fs.set_commit_data(
7362 Path::new("/project/.git"),
7363 [(
7364 CommitData {
7365 sha: commit_sha,
7366 parents: smallvec![],
7367 author_name: "Author".into(),
7368 author_email: "author@example.com".into(),
7369 commit_timestamp: 1_700_000_000,
7370 subject: "Long commit message".into(),
7371 message: message.into(),
7372 },
7373 false,
7374 )],
7375 );
7376
7377 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7378 cx.run_until_parked();
7379
7380 let repository = project.read_with(cx, |project, cx| {
7381 project
7382 .active_repository(cx)
7383 .expect("should have a repository")
7384 });
7385 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7386 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
7387 });
7388 let workspace_weak =
7389 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
7390 let git_graph = cx.new_window_entity(|window, cx| {
7391 GitGraph::new(
7392 repository.read(cx).id,
7393 project.read(cx).git_store().clone(),
7394 workspace_weak,
7395 None,
7396 window,
7397 cx,
7398 )
7399 });
7400 cx.run_until_parked();
7401
7402 git_graph.update_in(cx, |graph, window, cx| {
7403 graph.select_first(&menu::SelectFirst, window, cx);
7404 });
7405 cx.run_until_parked();
7406
7407 git_graph.update(cx, |graph, cx| {
7408 graph.selected_commit_diff = Some(CommitDiff {
7409 files: vec![CommitFile {
7410 path: RepoPath::new("file.txt").expect("repository path should be valid"),
7411 old_text: Some("content".into()),
7412 new_text: Some("updated content".into()),
7413 is_binary: false,
7414 }],
7415 });
7416 graph.selected_commit_diff_stats = Some((1, 1));
7417 cx.notify();
7418 });
7419
7420 cx.draw(
7421 point(px(0.), px(0.)),
7422 gpui::size(px(1200.), px(800.)),
7423 |_, _| git_graph.clone().into_any_element(),
7424 );
7425 cx.run_until_parked();
7426
7427 let (message_scroll_handle, changed_files_scroll_handle) =
7428 git_graph.read_with(&*cx, |graph, _| {
7429 (
7430 graph
7431 .selected_commit_message
7432 .as_ref()
7433 .expect("selected commit message should be loaded")
7434 .scroll_handle
7435 .clone(),
7436 graph.changed_files_scroll_handle.clone(),
7437 )
7438 });
7439 let maximum_message_height = git_graph.update_in(cx, |_, window, cx| {
7440 editor::hover_markdown_style(window, cx)
7441 .base_text_style
7442 .line_height_in_pixels(window.rem_size())
7443 * 12.
7444 });
7445 let message_bounds = message_scroll_handle.bounds();
7446 let changed_files_bounds = changed_files_scroll_handle.0.borrow().base_handle.bounds();
7447
7448 assert!(
7449 message_bounds.size.height <= maximum_message_height,
7450 "commit message viewport height ({}) should not exceed its maximum ({})",
7451 message_bounds.size.height,
7452 maximum_message_height,
7453 );
7454 assert!(
7455 message_scroll_handle.max_offset().y > px(0.),
7456 "long commit message should be scrollable"
7457 );
7458 assert!(
7459 message_bounds.bottom() <= changed_files_bounds.top(),
7460 "commit message viewport {message_bounds:?} should not overlap changed files {changed_files_bounds:?}"
7461 );
7462 }
7463
7464 #[gpui::test]
7465 async fn test_commit_message_not_reloaded_for_same_sha(cx: &mut TestAppContext) {
7466 init_test(cx);
7467
7468 let fs = FakeFs::new(cx.executor());
7469 fs.insert_tree(
7470 Path::new("/project"),
7471 json!({ ".git": {}, "file.txt": "content" }),
7472 )
7473 .await;
7474
7475 let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
7476 let commits = vec![Arc::new(InitialGraphCommitData {
7477 sha: commit_sha,
7478 parents: smallvec![],
7479 ref_names: vec!["HEAD -> main".into()],
7480 })];
7481 fs.set_graph_commits(Path::new("/project/.git"), commits);
7482 fs.set_commit_data(
7483 Path::new("/project/.git"),
7484 [(
7485 CommitData {
7486 sha: commit_sha,
7487 parents: smallvec![],
7488 author_name: "Author".into(),
7489 author_email: "author@example.com".into(),
7490 commit_timestamp: 1_700_000_000,
7491 subject: "Fix crash".into(),
7492 message: "Fix crash\n\nBody text.".into(),
7493 },
7494 false,
7495 )],
7496 );
7497
7498 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7499 cx.run_until_parked();
7500
7501 let repository = project.read_with(cx, |project, cx| {
7502 project
7503 .active_repository(cx)
7504 .expect("should have a repository")
7505 });
7506
7507 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7508 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
7509 });
7510 let workspace_weak =
7511 multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
7512
7513 let git_graph = cx.new_window_entity(|window, cx| {
7514 GitGraph::new(
7515 repository.read(cx).id,
7516 project.read(cx).git_store().clone(),
7517 workspace_weak,
7518 None,
7519 window,
7520 cx,
7521 )
7522 });
7523 cx.run_until_parked();
7524
7525 // Select the commit to load the message
7526 git_graph.update_in(cx, |graph, window, cx| {
7527 graph.select_first(&menu::SelectFirst, window, cx);
7528 });
7529 cx.run_until_parked();
7530
7531 // Verify message is loaded
7532 let message_entity_id = git_graph.read_with(&*cx, |graph, _| {
7533 graph
7534 .selected_commit_message
7535 .as_ref()
7536 .map(|m| m.message.entity_id())
7537 });
7538 assert!(message_entity_id.is_some());
7539
7540 // Select the same commit again to trigger the early-return logic
7541 git_graph.update_in(cx, |graph, window, cx| {
7542 graph.select_first(&menu::SelectFirst, window, cx);
7543 });
7544 cx.run_until_parked();
7545
7546 // Verify the message entity is the same (not replaced)
7547 git_graph.read_with(&*cx, |graph, _| {
7548 let new_entity_id = graph
7549 .selected_commit_message
7550 .as_ref()
7551 .map(|m| m.message.entity_id());
7552 assert_eq!(message_entity_id, new_entity_id);
7553 });
7554 }
7555}
7556