Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:31:40.955Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

git_graph.rs

7556 lines · 284.8 KB · rust
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(&current_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
Served at tenant.openagents/omega Member data and write actions are omitted.