git_graph.rs

   1use collections::{BTreeMap, HashMap};
   2use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
   3use git::{
   4    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
   5    parse_git_remote_url,
   6    repository::{
   7        CommitDiff, CommitFile, CommitFileStatus, InitialGraphCommitData, LogOrder, LogSource,
   8        RepoPath,
   9    },
  10};
  11use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView};
  12use gpui::{
  13    AnyElement, App, Bounds, ClickEvent, ClipboardItem, Context, Corner, DefiniteLength,
  14    DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight,
  15    Hsla, InteractiveElement, ParentElement, PathBuilder, Pixels, Point, Render, ScrollStrategy,
  16    ScrollWheelEvent, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions,
  17    anchored, deferred, point, px, uniform_list,
  18};
  19use menu::{Cancel, SelectNext, SelectPrevious};
  20use project::{
  21    Project,
  22    git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent, RepositoryId},
  23};
  24use settings::Settings;
  25use smallvec::{SmallVec, smallvec};
  26use std::{ops::Range, rc::Rc, sync::Arc, sync::OnceLock};
  27use theme::{AccentColors, ThemeSettings};
  28use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
  29use ui::{
  30    CommonAnimationExt as _, ContextMenu, ScrollableHandle, Table, TableColumnWidths,
  31    TableInteractionState, TableResizeBehavior, Tooltip, prelude::*,
  32};
  33use workspace::{
  34    Workspace,
  35    item::{Item, ItemEvent, SerializableItem},
  36};
  37
  38const COMMIT_CIRCLE_RADIUS: Pixels = px(4.5);
  39const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
  40const LANE_WIDTH: Pixels = px(16.0);
  41const LEFT_PADDING: Pixels = px(12.0);
  42const LINE_WIDTH: Pixels = px(1.5);
  43const RESIZE_HANDLE_WIDTH: f32 = 8.0;
  44
  45struct DraggedSplitHandle;
  46
  47#[derive(Clone)]
  48struct ChangedFileEntry {
  49    icon_name: IconName,
  50    icon_color: Hsla,
  51    file_name: SharedString,
  52    dir_path: SharedString,
  53    repo_path: RepoPath,
  54}
  55
  56impl ChangedFileEntry {
  57    fn from_commit_file(file: &CommitFile, cx: &App) -> Self {
  58        let file_name: SharedString = file
  59            .path
  60            .file_name()
  61            .map(|n| n.to_string())
  62            .unwrap_or_default()
  63            .into();
  64        let dir_path: SharedString = file
  65            .path
  66            .parent()
  67            .map(|p| p.as_unix_str().to_string())
  68            .unwrap_or_default()
  69            .into();
  70        let colors = cx.theme().colors();
  71        let (icon_name, icon_color) = match file.status() {
  72            CommitFileStatus::Added => (IconName::SquarePlus, colors.version_control_added),
  73            CommitFileStatus::Modified => (IconName::SquareDot, colors.version_control_modified),
  74            CommitFileStatus::Deleted => (IconName::SquareMinus, colors.version_control_deleted),
  75        };
  76        Self {
  77            icon_name,
  78            icon_color,
  79            file_name,
  80            dir_path,
  81            repo_path: file.path.clone(),
  82        }
  83    }
  84
  85    fn open_in_commit_view(
  86        &self,
  87        commit_sha: &SharedString,
  88        repository: &WeakEntity<Repository>,
  89        workspace: &WeakEntity<Workspace>,
  90        window: &mut Window,
  91        cx: &mut App,
  92    ) {
  93        CommitView::open(
  94            commit_sha.to_string(),
  95            repository.clone(),
  96            workspace.clone(),
  97            None,
  98            Some(self.repo_path.clone()),
  99            window,
 100            cx,
 101        );
 102    }
 103
 104    fn render(
 105        &self,
 106        ix: usize,
 107        commit_sha: SharedString,
 108        repository: WeakEntity<Repository>,
 109        workspace: WeakEntity<Workspace>,
 110        cx: &App,
 111    ) -> AnyElement {
 112        h_flex()
 113            .id(("changed-file", ix))
 114            .px_3()
 115            .py_px()
 116            .gap_1()
 117            .min_w_0()
 118            .overflow_hidden()
 119            .cursor_pointer()
 120            .rounded_md()
 121            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
 122            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
 123            .child(
 124                div().flex_none().child(
 125                    Icon::new(self.icon_name)
 126                        .size(IconSize::Small)
 127                        .color(Color::Custom(self.icon_color)),
 128                ),
 129            )
 130            .child(
 131                div().flex_none().child(
 132                    Label::new(self.file_name.clone())
 133                        .size(LabelSize::Small)
 134                        .single_line(),
 135                ),
 136            )
 137            .when(!self.dir_path.is_empty(), |this| {
 138                this.child(
 139                    div().min_w_0().overflow_hidden().child(
 140                        Label::new(self.dir_path.clone())
 141                            .size(LabelSize::Small)
 142                            .color(Color::Muted)
 143                            .truncate()
 144                            .single_line(),
 145                    ),
 146                )
 147            })
 148            .on_click({
 149                let entry = self.clone();
 150                move |_, window, cx| {
 151                    entry.open_in_commit_view(&commit_sha, &repository, &workspace, window, cx);
 152                }
 153            })
 154            .into_any_element()
 155    }
 156}
 157
 158pub struct SplitState {
 159    left_ratio: f32,
 160    visible_left_ratio: f32,
 161}
 162
 163impl SplitState {
 164    pub fn new() -> Self {
 165        Self {
 166            left_ratio: 1.0,
 167            visible_left_ratio: 1.0,
 168        }
 169    }
 170
 171    pub fn right_ratio(&self) -> f32 {
 172        1.0 - self.visible_left_ratio
 173    }
 174
 175    fn on_drag_move(
 176        &mut self,
 177        drag_event: &DragMoveEvent<DraggedSplitHandle>,
 178        _window: &mut Window,
 179        _cx: &mut Context<Self>,
 180    ) {
 181        let drag_position = drag_event.event.position;
 182        let bounds = drag_event.bounds;
 183        let bounds_width = bounds.right() - bounds.left();
 184
 185        let min_ratio = 0.1;
 186        let max_ratio = 0.9;
 187
 188        let new_ratio = (drag_position.x - bounds.left()) / bounds_width;
 189        self.visible_left_ratio = new_ratio.clamp(min_ratio, max_ratio);
 190    }
 191
 192    fn commit_ratio(&mut self) {
 193        self.left_ratio = self.visible_left_ratio;
 194    }
 195
 196    fn on_double_click(&mut self) {
 197        self.left_ratio = 1.0;
 198        self.visible_left_ratio = 1.0;
 199    }
 200}
 201
 202actions!(
 203    git_graph,
 204    [
 205        /// Opens the commit view for the selected commit.
 206        OpenCommitView,
 207    ]
 208);
 209
 210pub struct GitGraphFeatureFlag;
 211
 212impl FeatureFlag for GitGraphFeatureFlag {
 213    const NAME: &'static str = "git-graph";
 214}
 215
 216fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
 217    static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
 218    FORMAT.get_or_init(|| {
 219        time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
 220            .unwrap_or_default()
 221    })
 222}
 223
 224fn format_timestamp(timestamp: i64) -> String {
 225    let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
 226        return "Unknown".to_string();
 227    };
 228
 229    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
 230    let local_datetime = datetime.to_offset(local_offset);
 231
 232    local_datetime
 233        .format(timestamp_format())
 234        .unwrap_or_default()
 235}
 236
 237fn accent_colors_count(accents: &AccentColors) -> usize {
 238    accents.0.len()
 239}
 240
 241#[derive(Copy, Clone, Debug)]
 242struct BranchColor(u8);
 243
 244#[derive(Debug)]
 245enum LaneState {
 246    Empty,
 247    Active {
 248        child: Oid,
 249        parent: Oid,
 250        color: Option<BranchColor>,
 251        starting_row: usize,
 252        starting_col: usize,
 253        destination_column: Option<usize>,
 254        segments: SmallVec<[CommitLineSegment; 1]>,
 255    },
 256}
 257
 258impl LaneState {
 259    fn to_commit_lines(
 260        &mut self,
 261        ending_row: usize,
 262        lane_column: usize,
 263        parent_column: usize,
 264        parent_color: BranchColor,
 265    ) -> Option<CommitLine> {
 266        let state = std::mem::replace(self, LaneState::Empty);
 267
 268        match state {
 269            LaneState::Active {
 270                #[cfg_attr(not(test), allow(unused_variables))]
 271                parent,
 272                #[cfg_attr(not(test), allow(unused_variables))]
 273                child,
 274                color,
 275                starting_row,
 276                starting_col,
 277                destination_column,
 278                mut segments,
 279            } => {
 280                let final_destination = destination_column.unwrap_or(parent_column);
 281                let final_color = color.unwrap_or(parent_color);
 282
 283                Some(CommitLine {
 284                    #[cfg(test)]
 285                    child,
 286                    #[cfg(test)]
 287                    parent,
 288                    child_column: starting_col,
 289                    full_interval: starting_row..ending_row,
 290                    color_idx: final_color.0 as usize,
 291                    segments: {
 292                        match segments.last_mut() {
 293                            Some(CommitLineSegment::Straight { to_row })
 294                                if *to_row == usize::MAX =>
 295                            {
 296                                if final_destination != lane_column {
 297                                    *to_row = ending_row - 1;
 298
 299                                    let curved_line = CommitLineSegment::Curve {
 300                                        to_column: final_destination,
 301                                        on_row: ending_row,
 302                                        curve_kind: CurveKind::Checkout,
 303                                    };
 304
 305                                    if *to_row == starting_row {
 306                                        let last_index = segments.len() - 1;
 307                                        segments[last_index] = curved_line;
 308                                    } else {
 309                                        segments.push(curved_line);
 310                                    }
 311                                } else {
 312                                    *to_row = ending_row;
 313                                }
 314                            }
 315                            Some(CommitLineSegment::Curve {
 316                                on_row,
 317                                to_column,
 318                                curve_kind,
 319                            }) if *on_row == usize::MAX => {
 320                                if *to_column == usize::MAX {
 321                                    *to_column = final_destination;
 322                                }
 323                                if matches!(curve_kind, CurveKind::Merge) {
 324                                    *on_row = starting_row + 1;
 325                                    if *on_row < ending_row {
 326                                        if *to_column != final_destination {
 327                                            segments.push(CommitLineSegment::Straight {
 328                                                to_row: ending_row - 1,
 329                                            });
 330                                            segments.push(CommitLineSegment::Curve {
 331                                                to_column: final_destination,
 332                                                on_row: ending_row,
 333                                                curve_kind: CurveKind::Checkout,
 334                                            });
 335                                        } else {
 336                                            segments.push(CommitLineSegment::Straight {
 337                                                to_row: ending_row,
 338                                            });
 339                                        }
 340                                    } else if *to_column != final_destination {
 341                                        segments.push(CommitLineSegment::Curve {
 342                                            to_column: final_destination,
 343                                            on_row: ending_row,
 344                                            curve_kind: CurveKind::Checkout,
 345                                        });
 346                                    }
 347                                } else {
 348                                    *on_row = ending_row;
 349                                    if *to_column != final_destination {
 350                                        segments.push(CommitLineSegment::Straight {
 351                                            to_row: ending_row,
 352                                        });
 353                                        segments.push(CommitLineSegment::Curve {
 354                                            to_column: final_destination,
 355                                            on_row: ending_row,
 356                                            curve_kind: CurveKind::Checkout,
 357                                        });
 358                                    }
 359                                }
 360                            }
 361                            Some(CommitLineSegment::Curve {
 362                                on_row, to_column, ..
 363                            }) => {
 364                                if *on_row < ending_row {
 365                                    if *to_column != final_destination {
 366                                        segments.push(CommitLineSegment::Straight {
 367                                            to_row: ending_row - 1,
 368                                        });
 369                                        segments.push(CommitLineSegment::Curve {
 370                                            to_column: final_destination,
 371                                            on_row: ending_row,
 372                                            curve_kind: CurveKind::Checkout,
 373                                        });
 374                                    } else {
 375                                        segments.push(CommitLineSegment::Straight {
 376                                            to_row: ending_row,
 377                                        });
 378                                    }
 379                                } else if *to_column != final_destination {
 380                                    segments.push(CommitLineSegment::Curve {
 381                                        to_column: final_destination,
 382                                        on_row: ending_row,
 383                                        curve_kind: CurveKind::Checkout,
 384                                    });
 385                                }
 386                            }
 387                            _ => {}
 388                        }
 389
 390                        segments
 391                    },
 392                })
 393            }
 394            LaneState::Empty => None,
 395        }
 396    }
 397
 398    fn is_empty(&self) -> bool {
 399        match self {
 400            LaneState::Empty => true,
 401            LaneState::Active { .. } => false,
 402        }
 403    }
 404}
 405
 406struct CommitEntry {
 407    data: Arc<InitialGraphCommitData>,
 408    lane: usize,
 409    color_idx: usize,
 410}
 411
 412type ActiveLaneIdx = usize;
 413
 414enum AllCommitCount {
 415    NotLoaded,
 416    Loaded(usize),
 417}
 418
 419#[derive(Debug)]
 420enum CurveKind {
 421    Merge,
 422    Checkout,
 423}
 424
 425#[derive(Debug)]
 426enum CommitLineSegment {
 427    Straight {
 428        to_row: usize,
 429    },
 430    Curve {
 431        to_column: usize,
 432        on_row: usize,
 433        curve_kind: CurveKind,
 434    },
 435}
 436
 437#[derive(Debug)]
 438struct CommitLine {
 439    #[cfg(test)]
 440    child: Oid,
 441    #[cfg(test)]
 442    parent: Oid,
 443    child_column: usize,
 444    full_interval: Range<usize>,
 445    color_idx: usize,
 446    segments: SmallVec<[CommitLineSegment; 1]>,
 447}
 448
 449impl CommitLine {
 450    fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
 451        if first_visible_row > self.full_interval.end {
 452            return None;
 453        } else if first_visible_row <= self.full_interval.start {
 454            return Some((0, self.child_column));
 455        }
 456
 457        let mut current_column = self.child_column;
 458
 459        for (idx, segment) in self.segments.iter().enumerate() {
 460            match segment {
 461                CommitLineSegment::Straight { to_row } => {
 462                    if *to_row >= first_visible_row {
 463                        return Some((idx, current_column));
 464                    }
 465                }
 466                CommitLineSegment::Curve {
 467                    to_column, on_row, ..
 468                } => {
 469                    if *on_row >= first_visible_row {
 470                        return Some((idx, current_column));
 471                    }
 472                    current_column = *to_column;
 473                }
 474            }
 475        }
 476
 477        None
 478    }
 479}
 480
 481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 482struct CommitLineKey {
 483    child: Oid,
 484    parent: Oid,
 485}
 486
 487struct GraphData {
 488    lane_states: SmallVec<[LaneState; 8]>,
 489    lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
 490    parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
 491    next_color: BranchColor,
 492    accent_colors_count: usize,
 493    commits: Vec<Rc<CommitEntry>>,
 494    max_commit_count: AllCommitCount,
 495    max_lanes: usize,
 496    lines: Vec<Rc<CommitLine>>,
 497    active_commit_lines: HashMap<CommitLineKey, usize>,
 498    active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
 499}
 500
 501impl GraphData {
 502    fn new(accent_colors_count: usize) -> Self {
 503        GraphData {
 504            lane_states: SmallVec::default(),
 505            lane_colors: HashMap::default(),
 506            parent_to_lanes: HashMap::default(),
 507            next_color: BranchColor(0),
 508            accent_colors_count,
 509            commits: Vec::default(),
 510            max_commit_count: AllCommitCount::NotLoaded,
 511            max_lanes: 0,
 512            lines: Vec::default(),
 513            active_commit_lines: HashMap::default(),
 514            active_commit_lines_by_parent: HashMap::default(),
 515        }
 516    }
 517
 518    fn clear(&mut self) {
 519        self.lane_states.clear();
 520        self.lane_colors.clear();
 521        self.parent_to_lanes.clear();
 522        self.commits.clear();
 523        self.lines.clear();
 524        self.active_commit_lines.clear();
 525        self.active_commit_lines_by_parent.clear();
 526        self.next_color = BranchColor(0);
 527        self.max_commit_count = AllCommitCount::NotLoaded;
 528        self.max_lanes = 0;
 529    }
 530
 531    fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
 532        self.lane_states
 533            .iter()
 534            .position(LaneState::is_empty)
 535            .unwrap_or_else(|| {
 536                self.lane_states.push(LaneState::Empty);
 537                self.lane_states.len() - 1
 538            })
 539    }
 540
 541    fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
 542        let accent_colors_count = self.accent_colors_count;
 543        *self.lane_colors.entry(lane_idx).or_insert_with(|| {
 544            let color_idx = self.next_color;
 545            self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
 546            color_idx
 547        })
 548    }
 549
 550    fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
 551        self.commits.reserve(commits.len());
 552        self.lines.reserve(commits.len() / 2);
 553
 554        for commit in commits.iter() {
 555            let commit_row = self.commits.len();
 556
 557            let commit_lane = self
 558                .parent_to_lanes
 559                .get(&commit.sha)
 560                .and_then(|lanes| lanes.first().copied());
 561
 562            let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
 563
 564            let commit_color = self.get_lane_color(commit_lane);
 565
 566            if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
 567                for lane_column in lanes {
 568                    let state = &mut self.lane_states[lane_column];
 569
 570                    if let LaneState::Active {
 571                        starting_row,
 572                        segments,
 573                        ..
 574                    } = state
 575                    {
 576                        if let Some(CommitLineSegment::Curve {
 577                            to_column,
 578                            curve_kind: CurveKind::Merge,
 579                            ..
 580                        }) = segments.first_mut()
 581                        {
 582                            let curve_row = *starting_row + 1;
 583                            let would_overlap =
 584                                if lane_column != commit_lane && curve_row < commit_row {
 585                                    self.commits[curve_row..commit_row]
 586                                        .iter()
 587                                        .any(|c| c.lane == commit_lane)
 588                                } else {
 589                                    false
 590                                };
 591
 592                            if would_overlap {
 593                                *to_column = lane_column;
 594                            }
 595                        }
 596                    }
 597
 598                    if let Some(commit_line) =
 599                        state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color)
 600                    {
 601                        self.lines.push(Rc::new(commit_line));
 602                    }
 603                }
 604            }
 605
 606            commit
 607                .parents
 608                .iter()
 609                .enumerate()
 610                .for_each(|(parent_idx, parent)| {
 611                    if parent_idx == 0 {
 612                        self.lane_states[commit_lane] = LaneState::Active {
 613                            parent: *parent,
 614                            child: commit.sha,
 615                            color: Some(commit_color),
 616                            starting_col: commit_lane,
 617                            starting_row: commit_row,
 618                            destination_column: None,
 619                            segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
 620                        };
 621
 622                        self.parent_to_lanes
 623                            .entry(*parent)
 624                            .or_default()
 625                            .push(commit_lane);
 626                    } else {
 627                        let new_lane = self.first_empty_lane_idx();
 628
 629                        self.lane_states[new_lane] = LaneState::Active {
 630                            parent: *parent,
 631                            child: commit.sha,
 632                            color: None,
 633                            starting_col: commit_lane,
 634                            starting_row: commit_row,
 635                            destination_column: None,
 636                            segments: smallvec![CommitLineSegment::Curve {
 637                                to_column: usize::MAX,
 638                                on_row: usize::MAX,
 639                                curve_kind: CurveKind::Merge,
 640                            },],
 641                        };
 642
 643                        self.parent_to_lanes
 644                            .entry(*parent)
 645                            .or_default()
 646                            .push(new_lane);
 647                    }
 648                });
 649
 650            self.max_lanes = self.max_lanes.max(self.lane_states.len());
 651
 652            self.commits.push(Rc::new(CommitEntry {
 653                data: commit.clone(),
 654                lane: commit_lane,
 655                color_idx: commit_color.0 as usize,
 656            }));
 657        }
 658
 659        self.max_commit_count = AllCommitCount::Loaded(self.commits.len());
 660    }
 661}
 662
 663pub fn init(cx: &mut App) {
 664    workspace::register_serializable_item::<GitGraph>(cx);
 665
 666    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
 667        workspace.register_action_renderer(|div, workspace, _, cx| {
 668            div.when(
 669                workspace.project().read(cx).active_repository(cx).is_some()
 670                    && cx.has_flag::<GitGraphFeatureFlag>(),
 671                |div| {
 672                    let workspace = workspace.weak_handle();
 673
 674                    div.on_action(move |_: &git_ui::git_panel::Open, window, cx| {
 675                        workspace
 676                            .update(cx, |workspace, cx| {
 677                                let existing = workspace.items_of_type::<GitGraph>(cx).next();
 678                                if let Some(existing) = existing {
 679                                    workspace.activate_item(&existing, true, true, window, cx);
 680                                    return;
 681                                }
 682
 683                                let project = workspace.project().clone();
 684                                let workspace_handle = workspace.weak_handle();
 685                                let git_graph = cx
 686                                    .new(|cx| GitGraph::new(project, workspace_handle, window, cx));
 687                                workspace.add_item_to_active_pane(
 688                                    Box::new(git_graph),
 689                                    None,
 690                                    true,
 691                                    window,
 692                                    cx,
 693                                );
 694                            })
 695                            .ok();
 696                    })
 697                },
 698            )
 699        });
 700    })
 701    .detach();
 702}
 703
 704fn lane_center_x(bounds: Bounds<Pixels>, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels {
 705    bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset
 706}
 707
 708fn to_row_center(
 709    to_row: usize,
 710    row_height: Pixels,
 711    scroll_offset: Pixels,
 712    bounds: Bounds<Pixels>,
 713) -> Pixels {
 714    bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
 715}
 716
 717fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
 718    let radius = COMMIT_CIRCLE_RADIUS;
 719    let stroke_width = COMMIT_CIRCLE_STROKE_WIDTH;
 720
 721    let mut builder = PathBuilder::stroke(stroke_width);
 722
 723    // Start at the rightmost point of the circle
 724    builder.move_to(point(center_x + radius, center_y));
 725
 726    // Draw the circle using two arc_to calls (top half, then bottom half)
 727    builder.arc_to(
 728        point(radius, radius),
 729        px(0.),
 730        false,
 731        true,
 732        point(center_x - radius, center_y),
 733    );
 734    builder.arc_to(
 735        point(radius, radius),
 736        px(0.),
 737        false,
 738        true,
 739        point(center_x + radius, center_y),
 740    );
 741    builder.close();
 742
 743    if let Ok(path) = builder.build() {
 744        window.paint_path(path, color);
 745    }
 746}
 747
 748pub struct GitGraph {
 749    focus_handle: FocusHandle,
 750    graph_data: GraphData,
 751    project: Entity<Project>,
 752    workspace: WeakEntity<Workspace>,
 753    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 754    row_height: Pixels,
 755    table_interaction_state: Entity<TableInteractionState>,
 756    table_column_widths: Entity<TableColumnWidths>,
 757    horizontal_scroll_offset: Pixels,
 758    graph_viewport_width: Pixels,
 759    selected_entry_idx: Option<usize>,
 760    log_source: LogSource,
 761    log_order: LogOrder,
 762    selected_commit_diff: Option<CommitDiff>,
 763    _commit_diff_task: Option<Task<()>>,
 764    commit_details_split_state: Entity<SplitState>,
 765    selected_repo_id: Option<RepositoryId>,
 766}
 767
 768impl GitGraph {
 769    fn row_height(cx: &App) -> Pixels {
 770        let settings = ThemeSettings::get_global(cx);
 771        let font_size = settings.buffer_font_size(cx);
 772        font_size + px(12.0)
 773    }
 774
 775    fn graph_content_width(&self) -> Pixels {
 776        (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
 777    }
 778
 779    pub fn new(
 780        project: Entity<Project>,
 781        workspace: WeakEntity<Workspace>,
 782        window: &mut Window,
 783        cx: &mut Context<Self>,
 784    ) -> Self {
 785        let focus_handle = cx.focus_handle();
 786        cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
 787            .detach();
 788
 789        let git_store = project.read(cx).git_store().clone();
 790        let accent_colors = cx.theme().accents();
 791        let graph = GraphData::new(accent_colors_count(accent_colors));
 792        let log_source = LogSource::default();
 793        let log_order = LogOrder::default();
 794
 795        cx.subscribe(&git_store, |this, _, event, cx| match event {
 796            GitStoreEvent::RepositoryUpdated(updated_repo_id, repo_event, _) => {
 797                if this
 798                    .selected_repo_id
 799                    .as_ref()
 800                    .is_some_and(|repo_id| repo_id == updated_repo_id)
 801                {
 802                    if let Some(repository) = this.get_selected_repository(cx) {
 803                        this.on_repository_event(repository, repo_event, cx);
 804                    }
 805                }
 806            }
 807            GitStoreEvent::ActiveRepositoryChanged(changed_repo_id) => {
 808                // todo(git_graph): Make this selectable from UI so we don't have to always use active repository
 809                if this.selected_repo_id != *changed_repo_id {
 810                    this.selected_repo_id = *changed_repo_id;
 811                    this.graph_data.clear();
 812                    cx.notify();
 813                }
 814            }
 815            _ => {}
 816        })
 817        .detach();
 818
 819        let active_repository = project
 820            .read(cx)
 821            .active_repository(cx)
 822            .map(|repo| repo.read(cx).id);
 823
 824        let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
 825        let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx));
 826        let mut row_height = Self::row_height(cx);
 827
 828        cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
 829            let new_row_height = Self::row_height(cx);
 830            if new_row_height != row_height {
 831                this.row_height = new_row_height;
 832                this.table_interaction_state.update(cx, |state, _cx| {
 833                    state.scroll_handle.0.borrow_mut().last_item_size = None;
 834                });
 835                row_height = new_row_height;
 836                cx.notify();
 837            }
 838        })
 839        .detach();
 840
 841        let mut this = GitGraph {
 842            focus_handle,
 843            project,
 844            workspace,
 845            graph_data: graph,
 846            _commit_diff_task: None,
 847            context_menu: None,
 848            row_height,
 849            table_interaction_state,
 850            table_column_widths,
 851            horizontal_scroll_offset: px(0.),
 852            graph_viewport_width: px(88.),
 853            selected_entry_idx: None,
 854            selected_commit_diff: None,
 855            log_source,
 856            log_order,
 857            commit_details_split_state: cx.new(|_cx| SplitState::new()),
 858            selected_repo_id: active_repository,
 859        };
 860
 861        this.fetch_initial_graph_data(cx);
 862        this
 863    }
 864
 865    fn on_repository_event(
 866        &mut self,
 867        repository: Entity<Repository>,
 868        event: &RepositoryEvent,
 869        cx: &mut Context<Self>,
 870    ) {
 871        match event {
 872            RepositoryEvent::GitGraphCountUpdated((order, source), commit_count) => {
 873                if order != &self.log_order || source != &self.log_source {
 874                    return;
 875                }
 876
 877                let old_count = self.graph_data.commits.len();
 878
 879                repository.update(cx, |repository, cx| {
 880                    let (commits, _) =
 881                        repository.graph_data(source.clone(), *order, old_count..*commit_count, cx);
 882                    self.graph_data.add_commits(commits);
 883                });
 884                cx.notify();
 885            }
 886            RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => {
 887                // Only invalidate if we scanned atleast once,
 888                // meaning we are not inside the initial repo loading state
 889                // NOTE: this fixes an loading performance regression
 890                if repository.read(cx).scan_id > 1 {
 891                    self.graph_data.clear();
 892                    cx.notify();
 893                }
 894            }
 895            _ => {}
 896        }
 897    }
 898
 899    fn fetch_initial_graph_data(&mut self, cx: &mut App) {
 900        if let Some(repository) = self.get_selected_repository(cx) {
 901            repository.update(cx, |repository, cx| {
 902                let (commits, _) = repository.graph_data(
 903                    self.log_source.clone(),
 904                    self.log_order,
 905                    0..usize::MAX,
 906                    cx,
 907                );
 908                self.graph_data.add_commits(commits);
 909            });
 910        }
 911    }
 912
 913    fn get_selected_repository(&self, cx: &App) -> Option<Entity<Repository>> {
 914        let project = self.project.read(cx);
 915        self.selected_repo_id
 916            .as_ref()
 917            .and_then(|repo_id| project.repositories(cx).get(&repo_id).cloned())
 918    }
 919
 920    fn render_badge(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
 921        div()
 922            .px_1p5()
 923            .py_0p5()
 924            .h(self.row_height - px(4.0))
 925            .flex()
 926            .items_center()
 927            .justify_center()
 928            .rounded_md()
 929            .bg(accent_color.opacity(0.18))
 930            .border_1()
 931            .border_color(accent_color.opacity(0.55))
 932            .child(
 933                Label::new(name.clone())
 934                    .size(LabelSize::Small)
 935                    .color(Color::Default)
 936                    .single_line(),
 937            )
 938    }
 939
 940    fn render_table_rows(
 941        &mut self,
 942        range: Range<usize>,
 943        _window: &mut Window,
 944        cx: &mut Context<Self>,
 945    ) -> Vec<Vec<AnyElement>> {
 946        let repository = self.get_selected_repository(cx);
 947
 948        let row_height = self.row_height;
 949
 950        // We fetch data outside the visible viewport to avoid loading entries when
 951        // users scroll through the git graph
 952        if let Some(repository) = repository.as_ref() {
 953            const FETCH_RANGE: usize = 100;
 954            repository.update(cx, |repository, cx| {
 955                self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
 956                    ..(range.end + FETCH_RANGE)
 957                        .min(self.graph_data.commits.len().saturating_sub(1))]
 958                    .iter()
 959                    .for_each(|commit| {
 960                        repository.fetch_commit_data(commit.data.sha, cx);
 961                    });
 962            });
 963        }
 964
 965        range
 966            .map(|idx| {
 967                let Some((commit, repository)) =
 968                    self.graph_data.commits.get(idx).zip(repository.as_ref())
 969                else {
 970                    return vec![
 971                        div().h(row_height).into_any_element(),
 972                        div().h(row_height).into_any_element(),
 973                        div().h(row_height).into_any_element(),
 974                        div().h(row_height).into_any_element(),
 975                    ];
 976                };
 977
 978                let data = repository.update(cx, |repository, cx| {
 979                    repository.fetch_commit_data(commit.data.sha, cx).clone()
 980                });
 981
 982                let short_sha = commit.data.sha.display_short();
 983                let mut formatted_time = String::new();
 984                let subject;
 985                let author_name;
 986
 987                if let CommitDataState::Loaded(data) = data {
 988                    subject = data.subject.clone();
 989                    author_name = data.author_name.clone();
 990                    formatted_time = format_timestamp(data.commit_timestamp);
 991                } else {
 992                    subject = "Loading...".into();
 993                    author_name = "".into();
 994                }
 995
 996                let accent_colors = cx.theme().accents();
 997                let accent_color = accent_colors
 998                    .0
 999                    .get(commit.color_idx)
1000                    .copied()
1001                    .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1002                let is_selected = self.selected_entry_idx == Some(idx);
1003                let text_color = if is_selected {
1004                    Color::Default
1005                } else {
1006                    Color::Muted
1007                };
1008
1009                vec![
1010                    div()
1011                        .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1012                        .overflow_hidden()
1013                        .tooltip(Tooltip::text(subject.clone()))
1014                        .child(
1015                            h_flex()
1016                                .gap_1()
1017                                .items_center()
1018                                .overflow_hidden()
1019                                .children((!commit.data.ref_names.is_empty()).then(|| {
1020                                    h_flex().flex_shrink().gap_2().items_center().children(
1021                                        commit
1022                                            .data
1023                                            .ref_names
1024                                            .iter()
1025                                            .map(|name| self.render_badge(name, accent_color)),
1026                                    )
1027                                }))
1028                                .child(
1029                                    Label::new(subject)
1030                                        .color(text_color)
1031                                        .truncate()
1032                                        .single_line(),
1033                                ),
1034                        )
1035                        .into_any_element(),
1036                    Label::new(formatted_time)
1037                        .color(text_color)
1038                        .single_line()
1039                        .into_any_element(),
1040                    Label::new(author_name)
1041                        .color(text_color)
1042                        .single_line()
1043                        .into_any_element(),
1044                    Label::new(short_sha)
1045                        .color(text_color)
1046                        .single_line()
1047                        .into_any_element(),
1048                ]
1049            })
1050            .collect()
1051    }
1052
1053    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1054        self.selected_entry_idx = None;
1055        self.selected_commit_diff = None;
1056        cx.notify();
1057    }
1058
1059    fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context<Self>) {
1060        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1061            self.select_entry(selected_entry_idx.saturating_sub(1), cx);
1062        } else {
1063            self.select_entry(0, cx);
1064        }
1065    }
1066
1067    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1068        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1069            self.select_entry(selected_entry_idx.saturating_add(1), cx);
1070        } else {
1071            self.select_prev(&SelectPrevious, window, cx);
1072        }
1073    }
1074
1075    fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
1076        if self.selected_entry_idx == Some(idx) {
1077            return;
1078        }
1079
1080        self.selected_entry_idx = Some(idx);
1081        self.selected_commit_diff = None;
1082        self.table_interaction_state.update(cx, |state, cx| {
1083            state
1084                .scroll_handle
1085                .scroll_to_item(idx, ScrollStrategy::Nearest);
1086            cx.notify();
1087        });
1088
1089        let Some(commit) = self.graph_data.commits.get(idx) else {
1090            return;
1091        };
1092
1093        let sha = commit.data.sha.to_string();
1094
1095        let Some(repository) = self.get_selected_repository(cx) else {
1096            return;
1097        };
1098
1099        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1100
1101        self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1102            if let Ok(Ok(diff)) = diff_receiver.await {
1103                this.update(cx, |this, cx| {
1104                    this.selected_commit_diff = Some(diff);
1105                    cx.notify();
1106                })
1107                .ok();
1108            }
1109        }));
1110
1111        cx.notify();
1112    }
1113
1114    fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1115        let Some(selected_entry_index) = self.selected_entry_idx else {
1116            return;
1117        };
1118
1119        self.open_commit_view(selected_entry_index, window, cx);
1120    }
1121
1122    fn open_commit_view(
1123        &mut self,
1124        entry_index: usize,
1125        window: &mut Window,
1126        cx: &mut Context<Self>,
1127    ) {
1128        let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1129            return;
1130        };
1131
1132        let Some(repository) = self.get_selected_repository(cx) else {
1133            return;
1134        };
1135
1136        CommitView::open(
1137            commit_entry.data.sha.to_string(),
1138            repository.downgrade(),
1139            self.workspace.clone(),
1140            None,
1141            None,
1142            window,
1143            cx,
1144        );
1145    }
1146
1147    fn get_remote(
1148        &self,
1149        repository: &Repository,
1150        _window: &mut Window,
1151        cx: &mut App,
1152    ) -> Option<GitRemote> {
1153        let remote_url = repository.default_remote_url()?;
1154        let provider_registry = GitHostingProviderRegistry::default_global(cx);
1155        let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1156        Some(GitRemote {
1157            host: provider,
1158            owner: parsed.owner.into(),
1159            repo: parsed.repo.into(),
1160        })
1161    }
1162
1163    fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1164        let rems = TextSize::Large.rems(cx);
1165        Icon::new(IconName::LoadCircle)
1166            .size(IconSize::Custom(rems))
1167            .color(Color::Accent)
1168            .with_rotate_animation(3)
1169            .into_any_element()
1170    }
1171
1172    fn render_commit_detail_panel(
1173        &self,
1174        window: &mut Window,
1175        cx: &mut Context<Self>,
1176    ) -> impl IntoElement {
1177        let Some(selected_idx) = self.selected_entry_idx else {
1178            return Empty.into_any_element();
1179        };
1180
1181        let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1182            return Empty.into_any_element();
1183        };
1184
1185        let Some(repository) = self.get_selected_repository(cx) else {
1186            return Empty.into_any_element();
1187        };
1188
1189        let data = repository.update(cx, |repository, cx| {
1190            repository
1191                .fetch_commit_data(commit_entry.data.sha, cx)
1192                .clone()
1193        });
1194
1195        let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1196        let truncated_sha: SharedString = {
1197            let sha_str = full_sha.as_ref();
1198            if sha_str.len() > 24 {
1199                format!("{}...", &sha_str[..24]).into()
1200            } else {
1201                full_sha.clone()
1202            }
1203        };
1204        let ref_names = commit_entry.data.ref_names.clone();
1205        let accent_colors = cx.theme().accents();
1206        let accent_color = accent_colors
1207            .0
1208            .get(commit_entry.color_idx)
1209            .copied()
1210            .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1211
1212        let (author_name, author_email, commit_timestamp, subject) = match &data {
1213            CommitDataState::Loaded(data) => (
1214                data.author_name.clone(),
1215                data.author_email.clone(),
1216                Some(data.commit_timestamp),
1217                data.subject.clone(),
1218            ),
1219            CommitDataState::Loading => ("Loading...".into(), "".into(), None, "Loading...".into()),
1220        };
1221
1222        let date_string = commit_timestamp
1223            .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1224            .map(|datetime| {
1225                let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1226                let local_datetime = datetime.to_offset(local_offset);
1227                let format =
1228                    time::format_description::parse("[month repr:short] [day], [year]").ok();
1229                format
1230                    .and_then(|f| local_datetime.format(&f).ok())
1231                    .unwrap_or_default()
1232            })
1233            .unwrap_or_default();
1234
1235        let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1236
1237        let avatar = {
1238            let author_email_for_avatar = if author_email.is_empty() {
1239                None
1240            } else {
1241                Some(author_email.clone())
1242            };
1243            let avatar = CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref());
1244            v_flex()
1245                .w(px(64.))
1246                .h(px(64.))
1247                .border_1()
1248                .border_color(cx.theme().colors().border)
1249                .rounded_full()
1250                .justify_center()
1251                .items_center()
1252                .child(
1253                    avatar
1254                        .avatar(window, cx)
1255                        .map(|a| a.size(px(64.)).into_any_element())
1256                        .unwrap_or_else(|| {
1257                            Icon::new(IconName::Person)
1258                                .color(Color::Muted)
1259                                .size(IconSize::XLarge)
1260                                .into_any_element()
1261                        }),
1262                )
1263        };
1264
1265        let changed_files_count = self
1266            .selected_commit_diff
1267            .as_ref()
1268            .map(|diff| diff.files.len())
1269            .unwrap_or(0);
1270
1271        let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1272            self.selected_commit_diff
1273                .as_ref()
1274                .map(|diff| {
1275                    let mut files: Vec<_> = diff.files.iter().collect();
1276                    files.sort_by_key(|file| file.status());
1277                    files
1278                        .into_iter()
1279                        .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1280                        .collect()
1281                })
1282                .unwrap_or_default(),
1283        );
1284
1285        v_flex()
1286            .relative()
1287            .w(px(300.))
1288            .h_full()
1289            .border_l_1()
1290            .border_color(cx.theme().colors().border)
1291            .bg(cx.theme().colors().surface_background)
1292            .flex_basis(DefiniteLength::Fraction(
1293                self.commit_details_split_state.read(cx).right_ratio(),
1294            ))
1295            .child(
1296                div().absolute().top_2().right_2().child(
1297                    IconButton::new("close-detail", IconName::Close)
1298                        .icon_size(IconSize::Small)
1299                        .on_click(cx.listener(move |this, _, _, cx| {
1300                            this.selected_entry_idx = None;
1301                            this.selected_commit_diff = None;
1302                            this._commit_diff_task = None;
1303                            cx.notify();
1304                        })),
1305                ),
1306            )
1307            .child(
1308                v_flex()
1309                    .w_full()
1310                    .min_w_0()
1311                    .flex_none()
1312                    .pt_3()
1313                    .gap_3()
1314                    .child(
1315                        v_flex()
1316                            .w_full()
1317                            .px_3()
1318                            .items_center()
1319                            .gap_0p5()
1320                            .child(avatar)
1321                            .child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD))
1322                            .child(
1323                                Label::new(date_string)
1324                                    .color(Color::Muted)
1325                                    .size(LabelSize::Small),
1326                            ),
1327                    )
1328                    .children((!ref_names.is_empty()).then(|| {
1329                        h_flex()
1330                            .px_3()
1331                            .justify_center()
1332                            .gap_1()
1333                            .flex_wrap()
1334                            .children(
1335                                ref_names
1336                                    .iter()
1337                                    .map(|name| self.render_badge(name, accent_color)),
1338                            )
1339                    }))
1340                    .child(
1341                        v_flex()
1342                            .px_3()
1343                            .gap_1p5()
1344                            .child(
1345                                h_flex()
1346                                    .gap_1()
1347                                    .child(
1348                                        Icon::new(IconName::Person)
1349                                            .size(IconSize::Small)
1350                                            .color(Color::Muted),
1351                                    )
1352                                    .child(
1353                                        Label::new(author_name)
1354                                            .size(LabelSize::Small)
1355                                            .color(Color::Muted),
1356                                    )
1357                                    .when(!author_email.is_empty(), |this| {
1358                                        this.child(
1359                                            Label::new(format!("<{}>", author_email))
1360                                                .size(LabelSize::Small)
1361                                                .color(Color::Ignored),
1362                                        )
1363                                    }),
1364                            )
1365                            .child(
1366                                h_flex()
1367                                    .gap_1()
1368                                    .child(
1369                                        Icon::new(IconName::FileGit)
1370                                            .size(IconSize::Small)
1371                                            .color(Color::Muted),
1372                                    )
1373                                    .child({
1374                                        let copy_sha = full_sha.clone();
1375                                        Button::new("sha-button", truncated_sha)
1376                                            .style(ButtonStyle::Transparent)
1377                                            .label_size(LabelSize::Small)
1378                                            .color(Color::Muted)
1379                                            .tooltip(Tooltip::text(format!(
1380                                                "Copy SHA: {}",
1381                                                copy_sha
1382                                            )))
1383                                            .on_click(move |_, _, cx| {
1384                                                cx.write_to_clipboard(ClipboardItem::new_string(
1385                                                    copy_sha.to_string(),
1386                                                ));
1387                                            })
1388                                    }),
1389                            )
1390                            .when_some(remote.clone(), |this, remote| {
1391                                let provider_name = remote.host.name();
1392                                let icon = match provider_name.as_str() {
1393                                    "GitHub" => IconName::Github,
1394                                    _ => IconName::Link,
1395                                };
1396                                let parsed_remote = ParsedGitRemote {
1397                                    owner: remote.owner.as_ref().into(),
1398                                    repo: remote.repo.as_ref().into(),
1399                                };
1400                                let params = BuildCommitPermalinkParams {
1401                                    sha: full_sha.as_ref(),
1402                                };
1403                                let url = remote
1404                                    .host
1405                                    .build_commit_permalink(&parsed_remote, params)
1406                                    .to_string();
1407                                this.child(
1408                                    h_flex()
1409                                        .gap_1()
1410                                        .child(
1411                                            Icon::new(icon)
1412                                                .size(IconSize::Small)
1413                                                .color(Color::Muted),
1414                                        )
1415                                        .child(
1416                                            Button::new(
1417                                                "view-on-provider",
1418                                                format!("View on {}", provider_name),
1419                                            )
1420                                            .style(ButtonStyle::Transparent)
1421                                            .label_size(LabelSize::Small)
1422                                            .color(Color::Muted)
1423                                            .on_click(
1424                                                move |_, _, cx| {
1425                                                    cx.open_url(&url);
1426                                                },
1427                                            ),
1428                                        ),
1429                                )
1430                            }),
1431                    )
1432                    .child(
1433                        div()
1434                            .w_full()
1435                            .min_w_0()
1436                            .border_t_1()
1437                            .border_color(cx.theme().colors().border)
1438                            .p_3()
1439                            .child(Label::new(subject).weight(FontWeight::MEDIUM)),
1440                    ),
1441            )
1442            .child(
1443                v_flex()
1444                    .flex_1()
1445                    .min_h_0()
1446                    .border_t_1()
1447                    .border_color(cx.theme().colors().border)
1448                    .child(
1449                        div().px_3().pt_3().pb_1().child(
1450                            Label::new(format!("{} Changed Files", changed_files_count))
1451                                .size(LabelSize::Small)
1452                                .color(Color::Muted),
1453                        ),
1454                    )
1455                    .child({
1456                        let entries = sorted_file_entries;
1457                        let entry_count = entries.len();
1458                        let commit_sha = full_sha.clone();
1459                        let repository = repository.downgrade();
1460                        let workspace = self.workspace.clone();
1461                        uniform_list(
1462                            "changed-files-list",
1463                            entry_count,
1464                            move |range, _window, cx| {
1465                                range
1466                                    .map(|ix| {
1467                                        entries[ix].render(
1468                                            ix,
1469                                            commit_sha.clone(),
1470                                            repository.clone(),
1471                                            workspace.clone(),
1472                                            cx,
1473                                        )
1474                                    })
1475                                    .collect()
1476                            },
1477                        )
1478                        .flex_1()
1479                    }),
1480            )
1481            .into_any_element()
1482    }
1483
1484    pub fn render_graph(&self, cx: &mut Context<GitGraph>) -> impl IntoElement {
1485        let row_height = self.row_height;
1486        let table_state = self.table_interaction_state.read(cx);
1487        let viewport_height = table_state
1488            .scroll_handle
1489            .0
1490            .borrow()
1491            .last_item_size
1492            .map(|size| size.item.height)
1493            .unwrap_or(px(600.0));
1494        let loaded_commit_count = self.graph_data.commits.len();
1495
1496        let content_height = row_height * loaded_commit_count;
1497        let max_scroll = (content_height - viewport_height).max(px(0.));
1498        let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
1499
1500        let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
1501        let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
1502        let horizontal_scroll_offset = self.horizontal_scroll_offset;
1503
1504        let max_lanes = self.graph_data.max_lanes.max(6);
1505        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1506        let last_visible_row =
1507            first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
1508
1509        let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
1510            ..(last_visible_row).min(loaded_commit_count);
1511        let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
1512        let commit_lines: Vec<_> = self
1513            .graph_data
1514            .lines
1515            .iter()
1516            .filter(|line| {
1517                line.full_interval.start <= viewport_range.end
1518                    && line.full_interval.end >= viewport_range.start
1519            })
1520            .cloned()
1521            .collect();
1522
1523        let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
1524
1525        gpui::canvas(
1526            move |_bounds, _window, _cx| {},
1527            move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
1528                window.paint_layer(bounds, |window| {
1529                    let accent_colors = cx.theme().accents();
1530
1531                    for (row_idx, row) in rows.into_iter().enumerate() {
1532                        let row_color = accent_colors.color_for_index(row.color_idx as u32);
1533                        let row_y_center =
1534                            bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
1535                                - vertical_scroll_offset;
1536
1537                        let commit_x =
1538                            lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
1539
1540                        draw_commit_circle(commit_x, row_y_center, row_color, window);
1541                    }
1542
1543                    for line in commit_lines {
1544                        let Some((start_segment_idx, start_column)) =
1545                            line.get_first_visible_segment_idx(first_visible_row)
1546                        else {
1547                            continue;
1548                        };
1549
1550                        let line_x =
1551                            lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
1552
1553                        let start_row = line.full_interval.start as i32 - first_visible_row as i32;
1554
1555                        let from_y =
1556                            bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
1557                                - vertical_scroll_offset
1558                                + COMMIT_CIRCLE_RADIUS;
1559
1560                        let mut current_row = from_y;
1561                        let mut current_column = line_x;
1562
1563                        let mut builder = PathBuilder::stroke(LINE_WIDTH);
1564                        builder.move_to(point(line_x, from_y));
1565
1566                        let segments = &line.segments[start_segment_idx..];
1567
1568                        for (segment_idx, segment) in segments.iter().enumerate() {
1569                            let is_last = segment_idx + 1 == segments.len();
1570
1571                            match segment {
1572                                CommitLineSegment::Straight { to_row } => {
1573                                    let mut dest_row = to_row_center(
1574                                        to_row - first_visible_row,
1575                                        row_height,
1576                                        vertical_scroll_offset,
1577                                        bounds,
1578                                    );
1579                                    if is_last {
1580                                        dest_row -= COMMIT_CIRCLE_RADIUS;
1581                                    }
1582
1583                                    let dest_point = point(current_column, dest_row);
1584
1585                                    current_row = dest_point.y;
1586                                    builder.line_to(dest_point);
1587                                    builder.move_to(dest_point);
1588                                }
1589                                CommitLineSegment::Curve {
1590                                    to_column,
1591                                    on_row,
1592                                    curve_kind,
1593                                } => {
1594                                    let mut to_column = lane_center_x(
1595                                        bounds,
1596                                        *to_column as f32,
1597                                        horizontal_scroll_offset,
1598                                    );
1599
1600                                    let mut to_row = to_row_center(
1601                                        *on_row - first_visible_row,
1602                                        row_height,
1603                                        vertical_scroll_offset,
1604                                        bounds,
1605                                    );
1606
1607                                    // This means that this branch was a checkout
1608                                    let going_right = to_column > current_column;
1609                                    let column_shift = if going_right {
1610                                        COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
1611                                    } else {
1612                                        -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
1613                                    };
1614
1615                                    let control = match curve_kind {
1616                                        CurveKind::Checkout => {
1617                                            if is_last {
1618                                                to_column -= column_shift;
1619                                            }
1620                                            builder.move_to(point(current_column, current_row));
1621                                            point(current_column, to_row)
1622                                        }
1623                                        CurveKind::Merge => {
1624                                            if is_last {
1625                                                to_row -= COMMIT_CIRCLE_RADIUS;
1626                                            }
1627                                            builder.move_to(point(
1628                                                current_column + column_shift,
1629                                                current_row - COMMIT_CIRCLE_RADIUS,
1630                                            ));
1631                                            point(to_column, current_row)
1632                                        }
1633                                    };
1634
1635                                    match curve_kind {
1636                                        CurveKind::Checkout
1637                                            if (to_row - current_row).abs() > row_height =>
1638                                        {
1639                                            let start_curve =
1640                                                point(current_column, current_row + row_height);
1641                                            builder.line_to(start_curve);
1642                                            builder.move_to(start_curve);
1643                                        }
1644                                        CurveKind::Merge
1645                                            if (to_column - current_column).abs() > LANE_WIDTH =>
1646                                        {
1647                                            let column_shift =
1648                                                if going_right { LANE_WIDTH } else { -LANE_WIDTH };
1649
1650                                            let start_curve = point(
1651                                                current_column + column_shift,
1652                                                current_row - COMMIT_CIRCLE_RADIUS,
1653                                            );
1654
1655                                            builder.line_to(start_curve);
1656                                            builder.move_to(start_curve);
1657                                        }
1658                                        _ => {}
1659                                    };
1660
1661                                    builder.curve_to(point(to_column, to_row), control);
1662                                    current_row = to_row;
1663                                    current_column = to_column;
1664                                    builder.move_to(point(current_column, current_row));
1665                                }
1666                            }
1667                        }
1668
1669                        builder.close();
1670                        lines.entry(line.color_idx).or_default().push(builder);
1671                    }
1672
1673                    for (color_idx, builders) in lines {
1674                        let line_color = accent_colors.color_for_index(color_idx as u32);
1675
1676                        for builder in builders {
1677                            if let Ok(path) = builder.build() {
1678                                // we paint each color on it's own layer to stop overlapping lines
1679                                // of different colors changing the color of a line
1680                                window.paint_layer(bounds, |window| {
1681                                    window.paint_path(path, line_color);
1682                                });
1683                            }
1684                        }
1685                    }
1686                })
1687            },
1688        )
1689        .w(graph_width)
1690        .h_full()
1691    }
1692
1693    fn handle_graph_scroll(
1694        &mut self,
1695        event: &ScrollWheelEvent,
1696        window: &mut Window,
1697        cx: &mut Context<Self>,
1698    ) {
1699        let line_height = window.line_height();
1700        let delta = event.delta.pixel_delta(line_height);
1701
1702        let table_state = self.table_interaction_state.read(cx);
1703        let current_offset = table_state.scroll_offset();
1704
1705        let viewport_height = table_state.scroll_handle.viewport().size.height;
1706
1707        let commit_count = match self.graph_data.max_commit_count {
1708            AllCommitCount::Loaded(count) => count,
1709            AllCommitCount::NotLoaded => self.graph_data.commits.len(),
1710        };
1711        let content_height = self.row_height * commit_count;
1712        let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
1713
1714        let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
1715        let new_offset = Point::new(current_offset.x, new_y);
1716
1717        let max_lanes = self.graph_data.max_lanes.max(1);
1718        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1719        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
1720
1721        let new_horizontal_offset =
1722            (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
1723
1724        let vertical_changed = new_offset != current_offset;
1725        let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
1726
1727        if vertical_changed {
1728            table_state.set_scroll_offset(new_offset);
1729        }
1730
1731        if horizontal_changed {
1732            self.horizontal_scroll_offset = new_horizontal_offset;
1733        }
1734
1735        if vertical_changed || horizontal_changed {
1736            cx.notify();
1737        }
1738    }
1739
1740    fn render_commit_view_resize_handle(
1741        &self,
1742        _window: &mut Window,
1743        cx: &mut Context<Self>,
1744    ) -> AnyElement {
1745        div()
1746            .id("commit-view-split-resize-container")
1747            .relative()
1748            .h_full()
1749            .flex_shrink_0()
1750            .w(px(1.))
1751            .bg(cx.theme().colors().border_variant)
1752            .child(
1753                div()
1754                    .id("commit-view-split-resize-handle")
1755                    .absolute()
1756                    .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
1757                    .w(px(RESIZE_HANDLE_WIDTH))
1758                    .h_full()
1759                    .cursor_col_resize()
1760                    .block_mouse_except_scroll()
1761                    .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
1762                        if event.click_count() >= 2 {
1763                            this.commit_details_split_state.update(cx, |state, _| {
1764                                state.on_double_click();
1765                            });
1766                        }
1767                        cx.stop_propagation();
1768                    }))
1769                    .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
1770            )
1771            .into_any_element()
1772    }
1773}
1774
1775impl Render for GitGraph {
1776    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1777        let description_width_fraction = 0.72;
1778        let date_width_fraction = 0.12;
1779        let author_width_fraction = 0.10;
1780        let commit_width_fraction = 0.06;
1781
1782        let (commit_count, is_loading) = match self.graph_data.max_commit_count {
1783            AllCommitCount::Loaded(count) => (count, true),
1784            AllCommitCount::NotLoaded => {
1785                let (commit_count, is_loading) =
1786                    if let Some(repository) = self.get_selected_repository(cx) {
1787                        repository.update(cx, |repository, cx| {
1788                            // Start loading the graph data if we haven't started already
1789                            let (commits, is_loading) = repository.graph_data(
1790                                self.log_source.clone(),
1791                                self.log_order,
1792                                0..usize::MAX,
1793                                cx,
1794                            );
1795                            self.graph_data.add_commits(&commits);
1796                            (commits.len(), is_loading)
1797                        })
1798                    } else {
1799                        (0, false)
1800                    };
1801
1802                (commit_count, is_loading)
1803            }
1804        };
1805
1806        let content = if commit_count == 0 {
1807            let message = if is_loading {
1808                "Loading"
1809            } else {
1810                "No commits found"
1811            };
1812            let label = Label::new(message)
1813                .color(Color::Muted)
1814                .size(LabelSize::Large);
1815            div()
1816                .size_full()
1817                .h_flex()
1818                .gap_1()
1819                .items_center()
1820                .justify_center()
1821                .child(label)
1822                .when(is_loading, |this| {
1823                    this.child(self.render_loading_spinner(cx))
1824                })
1825        } else {
1826            div()
1827                .size_full()
1828                .flex()
1829                .flex_row()
1830                .child(
1831                    div()
1832                        .w(self.graph_content_width())
1833                        .h_full()
1834                        .flex()
1835                        .flex_col()
1836                        .child(
1837                            div()
1838                                .p_2()
1839                                .border_b_1()
1840                                .whitespace_nowrap()
1841                                .border_color(cx.theme().colors().border)
1842                                .child(Label::new("Graph").color(Color::Muted)),
1843                        )
1844                        .child(
1845                            div()
1846                                .id("graph-canvas")
1847                                .flex_1()
1848                                .overflow_hidden()
1849                                .child(self.render_graph(cx))
1850                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)),
1851                        ),
1852                )
1853                .child({
1854                    let row_height = self.row_height;
1855                    let selected_entry_idx = self.selected_entry_idx;
1856                    let weak_self = cx.weak_entity();
1857                    div().flex_1().size_full().child(
1858                        Table::new(4)
1859                            .interactable(&self.table_interaction_state)
1860                            .hide_row_borders()
1861                            .header(vec![
1862                                Label::new("Description")
1863                                    .color(Color::Muted)
1864                                    .into_any_element(),
1865                                Label::new("Date").color(Color::Muted).into_any_element(),
1866                                Label::new("Author").color(Color::Muted).into_any_element(),
1867                                Label::new("Commit").color(Color::Muted).into_any_element(),
1868                            ])
1869                            .column_widths(
1870                                [
1871                                    DefiniteLength::Fraction(description_width_fraction),
1872                                    DefiniteLength::Fraction(date_width_fraction),
1873                                    DefiniteLength::Fraction(author_width_fraction),
1874                                    DefiniteLength::Fraction(commit_width_fraction),
1875                                ]
1876                                .to_vec(),
1877                            )
1878                            .resizable_columns(
1879                                vec![
1880                                    TableResizeBehavior::Resizable,
1881                                    TableResizeBehavior::Resizable,
1882                                    TableResizeBehavior::Resizable,
1883                                    TableResizeBehavior::Resizable,
1884                                ],
1885                                &self.table_column_widths,
1886                                cx,
1887                            )
1888                            .map_row(move |(index, row), _window, cx| {
1889                                let is_selected = selected_entry_idx == Some(index);
1890                                let weak = weak_self.clone();
1891                                row.h(row_height)
1892                                    .when(is_selected, |row| {
1893                                        row.bg(cx.theme().colors().element_selected)
1894                                    })
1895                                    .on_click(move |event, window, cx| {
1896                                        let click_count = event.click_count();
1897                                        weak.update(cx, |this, cx| {
1898                                            this.select_entry(index, cx);
1899                                            if click_count >= 2 {
1900                                                this.open_commit_view(index, window, cx);
1901                                            }
1902                                        })
1903                                        .ok();
1904                                    })
1905                                    .into_any_element()
1906                            })
1907                            .uniform_list(
1908                                "git-graph-commits",
1909                                commit_count,
1910                                cx.processor(Self::render_table_rows),
1911                            ),
1912                    )
1913                })
1914                .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
1915                    this.commit_details_split_state.update(cx, |state, cx| {
1916                        state.on_drag_move(event, window, cx);
1917                    });
1918                }))
1919                .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
1920                    this.commit_details_split_state.update(cx, |state, _cx| {
1921                        state.commit_ratio();
1922                    });
1923                }))
1924                .when(self.selected_entry_idx.is_some(), |this| {
1925                    this.child(self.render_commit_view_resize_handle(window, cx))
1926                        .child(self.render_commit_detail_panel(window, cx))
1927                })
1928        };
1929
1930        div()
1931            .size_full()
1932            .bg(cx.theme().colors().editor_background)
1933            .key_context("GitGraph")
1934            .track_focus(&self.focus_handle)
1935            .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
1936                this.open_selected_commit_view(window, cx);
1937            }))
1938            .on_action(cx.listener(Self::cancel))
1939            .on_action(cx.listener(Self::select_prev))
1940            .on_action(cx.listener(Self::select_next))
1941            .child(content)
1942            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1943                deferred(
1944                    anchored()
1945                        .position(*position)
1946                        .anchor(Corner::TopLeft)
1947                        .child(menu.clone()),
1948                )
1949                .with_priority(1)
1950            }))
1951    }
1952}
1953
1954impl EventEmitter<ItemEvent> for GitGraph {}
1955
1956impl Focusable for GitGraph {
1957    fn focus_handle(&self, _cx: &App) -> FocusHandle {
1958        self.focus_handle.clone()
1959    }
1960}
1961
1962impl Item for GitGraph {
1963    type Event = ItemEvent;
1964
1965    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1966        "Git Graph".into()
1967    }
1968
1969    fn show_toolbar(&self) -> bool {
1970        false
1971    }
1972
1973    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
1974        f(*event)
1975    }
1976}
1977
1978impl SerializableItem for GitGraph {
1979    fn serialized_item_kind() -> &'static str {
1980        "GitGraph"
1981    }
1982
1983    fn cleanup(
1984        workspace_id: workspace::WorkspaceId,
1985        alive_items: Vec<workspace::ItemId>,
1986        _window: &mut Window,
1987        cx: &mut App,
1988    ) -> Task<gpui::Result<()>> {
1989        workspace::delete_unloaded_items(
1990            alive_items,
1991            workspace_id,
1992            "git_graphs",
1993            &persistence::GIT_GRAPHS,
1994            cx,
1995        )
1996    }
1997
1998    fn deserialize(
1999        project: Entity<Project>,
2000        workspace: WeakEntity<Workspace>,
2001        workspace_id: workspace::WorkspaceId,
2002        item_id: workspace::ItemId,
2003        window: &mut Window,
2004        cx: &mut App,
2005    ) -> Task<gpui::Result<Entity<Self>>> {
2006        if persistence::GIT_GRAPHS
2007            .get_git_graph(item_id, workspace_id)
2008            .ok()
2009            .is_some_and(|is_open| is_open)
2010        {
2011            let git_graph = cx.new(|cx| GitGraph::new(project, workspace, window, cx));
2012            Task::ready(Ok(git_graph))
2013        } else {
2014            Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
2015        }
2016    }
2017
2018    fn serialize(
2019        &mut self,
2020        workspace: &mut Workspace,
2021        item_id: workspace::ItemId,
2022        _closing: bool,
2023        _window: &mut Window,
2024        cx: &mut Context<Self>,
2025    ) -> Option<Task<gpui::Result<()>>> {
2026        let workspace_id = workspace.database_id()?;
2027        Some(cx.background_spawn(async move {
2028            persistence::GIT_GRAPHS
2029                .save_git_graph(item_id, workspace_id, true)
2030                .await
2031        }))
2032    }
2033
2034    fn should_serialize(&self, event: &Self::Event) -> bool {
2035        event == &ItemEvent::UpdateTab
2036    }
2037}
2038
2039mod persistence {
2040    use db::{
2041        query,
2042        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
2043        sqlez_macros::sql,
2044    };
2045    use workspace::WorkspaceDb;
2046
2047    pub struct GitGraphsDb(ThreadSafeConnection);
2048
2049    impl Domain for GitGraphsDb {
2050        const NAME: &str = stringify!(GitGraphsDb);
2051
2052        const MIGRATIONS: &[&str] = (&[sql!(
2053            CREATE TABLE git_graphs (
2054                workspace_id INTEGER,
2055                item_id INTEGER UNIQUE,
2056                is_open INTEGER DEFAULT FALSE,
2057
2058                PRIMARY KEY(workspace_id, item_id),
2059                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2060                ON DELETE CASCADE
2061            ) STRICT;
2062        )]);
2063    }
2064
2065    db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
2066
2067    impl GitGraphsDb {
2068        query! {
2069            pub async fn save_git_graph(
2070                item_id: workspace::ItemId,
2071                workspace_id: workspace::WorkspaceId,
2072                is_open: bool
2073            ) -> Result<()> {
2074                INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
2075                VALUES (?, ?, ?)
2076            }
2077        }
2078
2079        query! {
2080            pub fn get_git_graph(
2081                item_id: workspace::ItemId,
2082                workspace_id: workspace::WorkspaceId
2083            ) -> Result<bool> {
2084                SELECT is_open
2085                FROM git_graphs
2086                WHERE item_id = ? AND workspace_id = ?
2087            }
2088        }
2089    }
2090}
2091
2092#[cfg(test)]
2093mod tests {
2094    use super::*;
2095    use anyhow::{Context, Result, bail};
2096    use collections::{HashMap, HashSet};
2097    use fs::FakeFs;
2098    use git::Oid;
2099    use git::repository::InitialGraphCommitData;
2100    use gpui::TestAppContext;
2101    use project::Project;
2102    use project::git_store::{GitStoreEvent, RepositoryEvent};
2103    use rand::prelude::*;
2104    use serde_json::json;
2105    use settings::SettingsStore;
2106    use smallvec::{SmallVec, smallvec};
2107    use std::path::Path;
2108    use std::sync::{Arc, Mutex};
2109    use workspace::MultiWorkspace;
2110
2111    fn init_test(cx: &mut TestAppContext) {
2112        cx.update(|cx| {
2113            let settings_store = SettingsStore::test(cx);
2114            cx.set_global(settings_store);
2115        });
2116    }
2117
2118    fn init_test_with_theme(cx: &mut TestAppContext) {
2119        cx.update(|cx| {
2120            let settings_store = SettingsStore::test(cx);
2121            cx.set_global(settings_store);
2122            theme::init(theme::LoadThemes::JustBase, cx);
2123        });
2124    }
2125
2126    /// Generates a random commit DAG suitable for testing git graph rendering.
2127    ///
2128    /// The commits are ordered newest-first (like git log output), so:
2129    /// - Index 0 = most recent commit (HEAD)
2130    /// - Last index = oldest commit (root, has no parents)
2131    /// - Parents of commit at index I must have index > I
2132    ///
2133    /// When `adversarial` is true, generates complex topologies with many branches
2134    /// and octopus merges. Otherwise generates more realistic linear histories
2135    /// with occasional branches.
2136    fn generate_random_commit_dag(
2137        rng: &mut StdRng,
2138        num_commits: usize,
2139        adversarial: bool,
2140    ) -> Vec<Arc<InitialGraphCommitData>> {
2141        if num_commits == 0 {
2142            return Vec::new();
2143        }
2144
2145        let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
2146        let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
2147
2148        for i in 0..num_commits {
2149            let sha = oids[i];
2150
2151            let parents = if i == num_commits - 1 {
2152                smallvec![]
2153            } else {
2154                generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
2155            };
2156
2157            let ref_names = if i == 0 {
2158                vec!["HEAD".into(), "main".into()]
2159            } else if adversarial && rng.random_bool(0.1) {
2160                vec![format!("branch-{}", i).into()]
2161            } else {
2162                Vec::new()
2163            };
2164
2165            commits.push(Arc::new(InitialGraphCommitData {
2166                sha,
2167                parents,
2168                ref_names,
2169            }));
2170        }
2171
2172        commits
2173    }
2174
2175    fn generate_parents_from_oids(
2176        rng: &mut StdRng,
2177        oids: &[Oid],
2178        current_idx: usize,
2179        num_commits: usize,
2180        adversarial: bool,
2181    ) -> SmallVec<[Oid; 1]> {
2182        let remaining = num_commits - current_idx - 1;
2183        if remaining == 0 {
2184            return smallvec![];
2185        }
2186
2187        if adversarial {
2188            let merge_chance = 0.4;
2189            let octopus_chance = 0.15;
2190
2191            if remaining >= 3 && rng.random_bool(octopus_chance) {
2192                let num_parents = rng.random_range(3..=remaining.min(5));
2193                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2194                parent_indices.shuffle(rng);
2195                parent_indices
2196                    .into_iter()
2197                    .take(num_parents)
2198                    .map(|idx| oids[idx])
2199                    .collect()
2200            } else if remaining >= 2 && rng.random_bool(merge_chance) {
2201                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2202                parent_indices.shuffle(rng);
2203                parent_indices
2204                    .into_iter()
2205                    .take(2)
2206                    .map(|idx| oids[idx])
2207                    .collect()
2208            } else {
2209                let parent_idx = rng.random_range(current_idx + 1..num_commits);
2210                smallvec![oids[parent_idx]]
2211            }
2212        } else {
2213            let merge_chance = 0.15;
2214            let skip_chance = 0.1;
2215
2216            if remaining >= 2 && rng.random_bool(merge_chance) {
2217                let first_parent = current_idx + 1;
2218                let second_parent = rng.random_range(current_idx + 2..num_commits);
2219                smallvec![oids[first_parent], oids[second_parent]]
2220            } else if rng.random_bool(skip_chance) && remaining >= 2 {
2221                let skip = rng.random_range(1..remaining.min(3));
2222                smallvec![oids[current_idx + 1 + skip]]
2223            } else {
2224                smallvec![oids[current_idx + 1]]
2225            }
2226        }
2227    }
2228
2229    fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
2230        graph
2231            .commits
2232            .iter()
2233            .enumerate()
2234            .map(|(idx, entry)| (entry.data.sha, idx))
2235            .collect()
2236    }
2237
2238    fn verify_commit_order(
2239        graph: &GraphData,
2240        commits: &[Arc<InitialGraphCommitData>],
2241    ) -> Result<()> {
2242        if graph.commits.len() != commits.len() {
2243            bail!(
2244                "Commit count mismatch: graph has {} commits, expected {}",
2245                graph.commits.len(),
2246                commits.len()
2247            );
2248        }
2249
2250        for (idx, (graph_commit, expected_commit)) in
2251            graph.commits.iter().zip(commits.iter()).enumerate()
2252        {
2253            if graph_commit.data.sha != expected_commit.sha {
2254                bail!(
2255                    "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
2256                    idx,
2257                    graph_commit.data.sha,
2258                    expected_commit.sha
2259                );
2260            }
2261        }
2262
2263        Ok(())
2264    }
2265
2266    fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
2267        for line in &graph.lines {
2268            let child_row = *oid_to_row
2269                .get(&line.child)
2270                .context("Line references non-existent child commit")?;
2271
2272            let parent_row = *oid_to_row
2273                .get(&line.parent)
2274                .context("Line references non-existent parent commit")?;
2275
2276            if child_row >= parent_row {
2277                bail!(
2278                    "child_row ({}) must be < parent_row ({})",
2279                    child_row,
2280                    parent_row
2281                );
2282            }
2283
2284            if line.full_interval.start != child_row {
2285                bail!(
2286                    "full_interval.start ({}) != child_row ({})",
2287                    line.full_interval.start,
2288                    child_row
2289                );
2290            }
2291
2292            if line.full_interval.end != parent_row {
2293                bail!(
2294                    "full_interval.end ({}) != parent_row ({})",
2295                    line.full_interval.end,
2296                    parent_row
2297                );
2298            }
2299
2300            if let Some(last_segment) = line.segments.last() {
2301                let segment_end_row = match last_segment {
2302                    CommitLineSegment::Straight { to_row } => *to_row,
2303                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2304                };
2305
2306                if segment_end_row != line.full_interval.end {
2307                    bail!(
2308                        "last segment ends at row {} but full_interval.end is {}",
2309                        segment_end_row,
2310                        line.full_interval.end
2311                    );
2312                }
2313            }
2314        }
2315
2316        Ok(())
2317    }
2318
2319    fn verify_column_correctness(
2320        graph: &GraphData,
2321        oid_to_row: &HashMap<Oid, usize>,
2322    ) -> Result<()> {
2323        for line in &graph.lines {
2324            let child_row = *oid_to_row
2325                .get(&line.child)
2326                .context("Line references non-existent child commit")?;
2327
2328            let parent_row = *oid_to_row
2329                .get(&line.parent)
2330                .context("Line references non-existent parent commit")?;
2331
2332            let child_lane = graph.commits[child_row].lane;
2333            if line.child_column != child_lane {
2334                bail!(
2335                    "child_column ({}) != child's lane ({})",
2336                    line.child_column,
2337                    child_lane
2338                );
2339            }
2340
2341            let mut current_column = line.child_column;
2342            for segment in &line.segments {
2343                if let CommitLineSegment::Curve { to_column, .. } = segment {
2344                    current_column = *to_column;
2345                }
2346            }
2347
2348            let parent_lane = graph.commits[parent_row].lane;
2349            if current_column != parent_lane {
2350                bail!(
2351                    "ending column ({}) != parent's lane ({})",
2352                    current_column,
2353                    parent_lane
2354                );
2355            }
2356        }
2357
2358        Ok(())
2359    }
2360
2361    fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
2362        for line in &graph.lines {
2363            if line.segments.is_empty() {
2364                bail!("Line has no segments");
2365            }
2366
2367            let mut current_row = line.full_interval.start;
2368
2369            for (idx, segment) in line.segments.iter().enumerate() {
2370                let segment_end_row = match segment {
2371                    CommitLineSegment::Straight { to_row } => *to_row,
2372                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2373                };
2374
2375                if segment_end_row < current_row {
2376                    bail!(
2377                        "segment {} ends at row {} which is before current row {}",
2378                        idx,
2379                        segment_end_row,
2380                        current_row
2381                    );
2382                }
2383
2384                current_row = segment_end_row;
2385            }
2386        }
2387
2388        Ok(())
2389    }
2390
2391    fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
2392        for line in &graph.lines {
2393            let child_row = line.full_interval.start;
2394
2395            let mut current_column = line.child_column;
2396            let mut current_row = child_row;
2397
2398            for segment in &line.segments {
2399                match segment {
2400                    CommitLineSegment::Straight { to_row } => {
2401                        for row in (current_row + 1)..*to_row {
2402                            if row < graph.commits.len() {
2403                                let commit_at_row = &graph.commits[row];
2404                                if commit_at_row.lane == current_column {
2405                                    bail!(
2406                                        "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
2407                                        current_row,
2408                                        to_row,
2409                                        current_column,
2410                                        commit_at_row.data.sha,
2411                                        row
2412                                    );
2413                                }
2414                            }
2415                        }
2416                        current_row = *to_row;
2417                    }
2418                    CommitLineSegment::Curve {
2419                        to_column, on_row, ..
2420                    } => {
2421                        current_column = *to_column;
2422                        current_row = *on_row;
2423                    }
2424                }
2425            }
2426        }
2427
2428        Ok(())
2429    }
2430
2431    fn verify_coverage(graph: &GraphData) -> Result<()> {
2432        let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
2433        for entry in &graph.commits {
2434            for parent in &entry.data.parents {
2435                expected_edges.insert((entry.data.sha, *parent));
2436            }
2437        }
2438
2439        let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
2440        for line in &graph.lines {
2441            let edge = (line.child, line.parent);
2442
2443            if !found_edges.insert(edge) {
2444                bail!(
2445                    "Duplicate line found for edge {:?} -> {:?}",
2446                    line.child,
2447                    line.parent
2448                );
2449            }
2450
2451            if !expected_edges.contains(&edge) {
2452                bail!(
2453                    "Orphan line found: {:?} -> {:?} is not in the commit graph",
2454                    line.child,
2455                    line.parent
2456                );
2457            }
2458        }
2459
2460        for (child, parent) in &expected_edges {
2461            if !found_edges.contains(&(*child, *parent)) {
2462                bail!("Missing line for edge {:?} -> {:?}", child, parent);
2463            }
2464        }
2465
2466        assert_eq!(
2467            expected_edges.symmetric_difference(&found_edges).count(),
2468            0,
2469            "The symmetric difference should be zero"
2470        );
2471
2472        Ok(())
2473    }
2474
2475    fn verify_merge_line_optimality(
2476        graph: &GraphData,
2477        oid_to_row: &HashMap<Oid, usize>,
2478    ) -> Result<()> {
2479        for line in &graph.lines {
2480            let first_segment = line.segments.first();
2481            let is_merge_line = matches!(
2482                first_segment,
2483                Some(CommitLineSegment::Curve {
2484                    curve_kind: CurveKind::Merge,
2485                    ..
2486                })
2487            );
2488
2489            if !is_merge_line {
2490                continue;
2491            }
2492
2493            let child_row = *oid_to_row
2494                .get(&line.child)
2495                .context("Line references non-existent child commit")?;
2496
2497            let parent_row = *oid_to_row
2498                .get(&line.parent)
2499                .context("Line references non-existent parent commit")?;
2500
2501            let parent_lane = graph.commits[parent_row].lane;
2502
2503            let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
2504                continue;
2505            };
2506
2507            let curves_directly_to_parent = *to_column == parent_lane;
2508
2509            if !curves_directly_to_parent {
2510                continue;
2511            }
2512
2513            let curve_row = child_row + 1;
2514            let has_commits_in_path = graph.commits[curve_row..parent_row]
2515                .iter()
2516                .any(|c| c.lane == parent_lane);
2517
2518            if has_commits_in_path {
2519                bail!(
2520                    "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
2521                    line.child,
2522                    line.parent,
2523                    parent_lane,
2524                    curve_row,
2525                    parent_row
2526                );
2527            }
2528
2529            let curve_ends_at_parent = curve_row == parent_row;
2530
2531            if curve_ends_at_parent {
2532                if line.segments.len() != 1 {
2533                    bail!(
2534                        "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
2535                        line.child,
2536                        line.parent,
2537                        line.segments.len()
2538                    );
2539                }
2540            } else {
2541                if line.segments.len() != 2 {
2542                    bail!(
2543                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
2544                        line.child,
2545                        line.parent,
2546                        line.segments.len()
2547                    );
2548                }
2549
2550                let is_straight_segment = matches!(
2551                    line.segments.get(1),
2552                    Some(CommitLineSegment::Straight { .. })
2553                );
2554
2555                if !is_straight_segment {
2556                    bail!(
2557                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
2558                        line.child,
2559                        line.parent
2560                    );
2561                }
2562            }
2563        }
2564
2565        Ok(())
2566    }
2567
2568    fn verify_all_invariants(
2569        graph: &GraphData,
2570        commits: &[Arc<InitialGraphCommitData>],
2571    ) -> Result<()> {
2572        let oid_to_row = build_oid_to_row_map(graph);
2573
2574        verify_commit_order(graph, commits).context("commit order")?;
2575        verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
2576        verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
2577        verify_segment_continuity(graph).context("segment continuity")?;
2578        verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
2579        verify_coverage(graph).context("coverage")?;
2580        verify_line_overlaps(graph).context("line overlaps")?;
2581        Ok(())
2582    }
2583
2584    #[test]
2585    fn test_git_graph_merge_commits() {
2586        let mut rng = StdRng::seed_from_u64(42);
2587
2588        let oid1 = Oid::random(&mut rng);
2589        let oid2 = Oid::random(&mut rng);
2590        let oid3 = Oid::random(&mut rng);
2591        let oid4 = Oid::random(&mut rng);
2592
2593        let commits = vec![
2594            Arc::new(InitialGraphCommitData {
2595                sha: oid1,
2596                parents: smallvec![oid2, oid3],
2597                ref_names: vec!["HEAD".into()],
2598            }),
2599            Arc::new(InitialGraphCommitData {
2600                sha: oid2,
2601                parents: smallvec![oid4],
2602                ref_names: vec![],
2603            }),
2604            Arc::new(InitialGraphCommitData {
2605                sha: oid3,
2606                parents: smallvec![oid4],
2607                ref_names: vec![],
2608            }),
2609            Arc::new(InitialGraphCommitData {
2610                sha: oid4,
2611                parents: smallvec![],
2612                ref_names: vec![],
2613            }),
2614        ];
2615
2616        let mut graph_data = GraphData::new(8);
2617        graph_data.add_commits(&commits);
2618
2619        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2620            panic!("Graph invariant violation for merge commits:\n{}", error);
2621        }
2622    }
2623
2624    #[test]
2625    fn test_git_graph_linear_commits() {
2626        let mut rng = StdRng::seed_from_u64(42);
2627
2628        let oid1 = Oid::random(&mut rng);
2629        let oid2 = Oid::random(&mut rng);
2630        let oid3 = Oid::random(&mut rng);
2631
2632        let commits = vec![
2633            Arc::new(InitialGraphCommitData {
2634                sha: oid1,
2635                parents: smallvec![oid2],
2636                ref_names: vec!["HEAD".into()],
2637            }),
2638            Arc::new(InitialGraphCommitData {
2639                sha: oid2,
2640                parents: smallvec![oid3],
2641                ref_names: vec![],
2642            }),
2643            Arc::new(InitialGraphCommitData {
2644                sha: oid3,
2645                parents: smallvec![],
2646                ref_names: vec![],
2647            }),
2648        ];
2649
2650        let mut graph_data = GraphData::new(8);
2651        graph_data.add_commits(&commits);
2652
2653        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2654            panic!("Graph invariant violation for linear commits:\n{}", error);
2655        }
2656    }
2657
2658    #[test]
2659    fn test_git_graph_random_commits() {
2660        for seed in 0..100 {
2661            let mut rng = StdRng::seed_from_u64(seed);
2662
2663            let adversarial = rng.random_bool(0.2);
2664            let num_commits = if adversarial {
2665                rng.random_range(10..100)
2666            } else {
2667                rng.random_range(5..50)
2668            };
2669
2670            let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2671
2672            assert_eq!(
2673                num_commits,
2674                commits.len(),
2675                "seed={}: Generate random commit dag didn't generate the correct amount of commits",
2676                seed
2677            );
2678
2679            let mut graph_data = GraphData::new(8);
2680            graph_data.add_commits(&commits);
2681
2682            if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2683                panic!(
2684                    "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
2685                    seed, adversarial, num_commits, error
2686                );
2687            }
2688        }
2689    }
2690
2691    // The full integration test has less iterations because it's significantly slower
2692    // than the random commit test
2693    #[gpui::test(iterations = 10)]
2694    async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
2695        init_test(cx);
2696
2697        let adversarial = rng.random_bool(0.2);
2698        let num_commits = if adversarial {
2699            rng.random_range(10..100)
2700        } else {
2701            rng.random_range(5..50)
2702        };
2703
2704        let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2705
2706        let fs = FakeFs::new(cx.executor());
2707        fs.insert_tree(
2708            Path::new("/project"),
2709            json!({
2710                ".git": {},
2711                "file.txt": "content",
2712            }),
2713        )
2714        .await;
2715
2716        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
2717
2718        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
2719        cx.run_until_parked();
2720
2721        let repository = project.read_with(cx, |project, cx| {
2722            project
2723                .active_repository(cx)
2724                .expect("should have a repository")
2725        });
2726
2727        repository.update(cx, |repo, cx| {
2728            repo.graph_data(
2729                crate::LogSource::default(),
2730                crate::LogOrder::default(),
2731                0..usize::MAX,
2732                cx,
2733            );
2734        });
2735        cx.run_until_parked();
2736
2737        let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
2738            repo.graph_data(
2739                crate::LogSource::default(),
2740                crate::LogOrder::default(),
2741                0..usize::MAX,
2742                cx,
2743            )
2744            .0
2745            .to_vec()
2746        });
2747
2748        let mut graph_data = GraphData::new(8);
2749        graph_data.add_commits(&graph_commits);
2750
2751        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2752            panic!(
2753                "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
2754                adversarial, num_commits, error
2755            );
2756        }
2757    }
2758
2759    #[gpui::test]
2760    async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
2761        init_test(cx);
2762
2763        let fs = FakeFs::new(cx.executor());
2764        fs.insert_tree(
2765            Path::new("/project"),
2766            json!({
2767                ".git": {},
2768                "file.txt": "content",
2769            }),
2770        )
2771        .await;
2772
2773        let mut rng = StdRng::seed_from_u64(42);
2774        let commits = generate_random_commit_dag(&mut rng, 10, false);
2775        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
2776
2777        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
2778        let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
2779        project.update(cx, |project, cx| {
2780            let observed_repository_events = observed_repository_events.clone();
2781            cx.subscribe(project.git_store(), move |_, _, event, _| {
2782                if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
2783                    observed_repository_events
2784                        .lock()
2785                        .expect("repository event mutex should be available")
2786                        .push(repository_event.clone());
2787                }
2788            })
2789            .detach();
2790        });
2791
2792        let repository = project.read_with(cx, |project, cx| {
2793            project
2794                .active_repository(cx)
2795                .expect("should have a repository")
2796        });
2797
2798        repository.update(cx, |repo, cx| {
2799            repo.graph_data(
2800                crate::LogSource::default(),
2801                crate::LogOrder::default(),
2802                0..usize::MAX,
2803                cx,
2804            );
2805        });
2806
2807        project
2808            .update(cx, |project, cx| project.git_scans_complete(cx))
2809            .await;
2810        cx.run_until_parked();
2811
2812        let observed_repository_events = observed_repository_events
2813            .lock()
2814            .expect("repository event mutex should be available");
2815        assert!(
2816            observed_repository_events
2817                .iter()
2818                .any(|event| matches!(event, RepositoryEvent::BranchChanged)),
2819            "initial repository scan should emit BranchChanged"
2820        );
2821        assert!(
2822            observed_repository_events
2823                .iter()
2824                .any(|event| matches!(event, RepositoryEvent::MergeHeadsChanged)),
2825            "initial repository scan should emit MergeHeadsChanged"
2826        );
2827
2828        let graph_data_key = (crate::LogOrder::default(), crate::LogSource::default());
2829        let commit_count_after = repository.read_with(cx, |repo, _| {
2830            repo.initial_graph_data
2831                .get(&graph_data_key)
2832                .map(|(_, data)| data.len())
2833                .unwrap_or(0)
2834        });
2835        assert_eq!(
2836            commits.len(),
2837            commit_count_after,
2838            "initial_graph_data should remain populated after events emitted by initial repository scan"
2839        );
2840    }
2841
2842    #[gpui::test]
2843    async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
2844        init_test_with_theme(cx);
2845
2846        let fs = FakeFs::new(cx.executor());
2847        fs.insert_tree(
2848            Path::new("/project_a"),
2849            json!({
2850                ".git": {},
2851                "file.txt": "content",
2852            }),
2853        )
2854        .await;
2855        fs.insert_tree(
2856            Path::new("/project_b"),
2857            json!({
2858                ".git": {},
2859                "other.txt": "content",
2860            }),
2861        )
2862        .await;
2863
2864        let mut rng = StdRng::seed_from_u64(42);
2865        let commits = generate_random_commit_dag(&mut rng, 10, false);
2866        fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
2867
2868        let project = Project::test(
2869            fs.clone(),
2870            [Path::new("/project_a"), Path::new("/project_b")],
2871            cx,
2872        )
2873        .await;
2874        cx.run_until_parked();
2875
2876        let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
2877            let mut first_repository = None;
2878            let mut second_repository = None;
2879
2880            for repository in project.repositories(cx).values() {
2881                let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
2882                if work_directory_abs_path.as_ref() == Path::new("/project_a") {
2883                    first_repository = Some(repository.clone());
2884                } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
2885                    second_repository = Some(repository.clone());
2886                }
2887            }
2888
2889            (
2890                first_repository.expect("should have repository for /project_a"),
2891                second_repository.expect("should have repository for /project_b"),
2892            )
2893        });
2894        first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
2895        cx.run_until_parked();
2896
2897        let (multi_workspace, cx) =
2898            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2899
2900        let workspace_weak =
2901            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
2902        let git_graph = cx.new_window_entity(|window, cx| {
2903            GitGraph::new(project.clone(), workspace_weak, window, cx)
2904        });
2905        cx.run_until_parked();
2906
2907        // Verify initial graph data is loaded
2908        let initial_commit_count =
2909            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
2910        assert!(
2911            initial_commit_count > 0,
2912            "graph data should have been loaded, got 0 commits"
2913        );
2914
2915        second_repository.update(&mut *cx, |repository, cx| {
2916            repository.set_as_active_repository(cx)
2917        });
2918        cx.run_until_parked();
2919
2920        let commit_count_after_clear =
2921            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
2922        assert_eq!(
2923            commit_count_after_clear, 0,
2924            "graph_data should be cleared after switching away"
2925        );
2926
2927        first_repository.update(&mut *cx, |repository, cx| {
2928            repository.set_as_active_repository(cx)
2929        });
2930
2931        git_graph.update_in(&mut *cx, |this, window, cx| {
2932            this.render(window, cx);
2933        });
2934        cx.run_until_parked();
2935
2936        let commit_count_after_switch_back =
2937            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
2938        assert_eq!(
2939            initial_commit_count, commit_count_after_switch_back,
2940            "graph_data should be repopulated from cache after switching back to the same repo"
2941        );
2942    }
2943}