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