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 avatar = CommitAvatar::new(&full_sha, remote.as_ref());
 979            v_flex()
 980                .w(px(64.))
 981                .h(px(64.))
 982                .border_1()
 983                .border_color(cx.theme().colors().border)
 984                .rounded_full()
 985                .justify_center()
 986                .items_center()
 987                .child(
 988                    avatar
 989                        .avatar(window, cx)
 990                        .map(|a| a.size(px(64.)).into_any_element())
 991                        .unwrap_or_else(|| {
 992                            Icon::new(IconName::Person)
 993                                .color(Color::Muted)
 994                                .size(IconSize::XLarge)
 995                                .into_any_element()
 996                        }),
 997                )
 998        };
 999
1000        let changed_files_count = self
1001            .selected_commit_diff
1002            .as_ref()
1003            .map(|diff| diff.files.len())
1004            .unwrap_or(0);
1005
1006        v_flex()
1007            .w(px(300.))
1008            .h_full()
1009            .border_l_1()
1010            .border_color(cx.theme().colors().border)
1011            .bg(cx.theme().colors().surface_background)
1012            .child(
1013                v_flex()
1014                    .p_3()
1015                    .gap_3()
1016                    .child(
1017                        h_flex().justify_between().child(avatar).child(
1018                            IconButton::new("close-detail", IconName::Close)
1019                                .icon_size(IconSize::Small)
1020                                .on_click(cx.listener(move |this, _, _, cx| {
1021                                    this.selected_entry_idx = None;
1022                                    this.selected_commit_diff = None;
1023                                    this._commit_diff_task = None;
1024                                    cx.notify();
1025                                })),
1026                        ),
1027                    )
1028                    .child(
1029                        v_flex()
1030                            .gap_0p5()
1031                            .child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD))
1032                            .child(
1033                                Label::new(date_string)
1034                                    .color(Color::Muted)
1035                                    .size(LabelSize::Small),
1036                            ),
1037                    )
1038                    .children((!ref_names.is_empty()).then(|| {
1039                        h_flex().gap_1().flex_wrap().children(
1040                            ref_names
1041                                .iter()
1042                                .map(|name| self.render_badge(name, accent_color)),
1043                        )
1044                    }))
1045                    .child(
1046                        v_flex()
1047                            .gap_1p5()
1048                            .child(
1049                                h_flex()
1050                                    .gap_1()
1051                                    .child(
1052                                        Icon::new(IconName::Person)
1053                                            .size(IconSize::Small)
1054                                            .color(Color::Muted),
1055                                    )
1056                                    .child(
1057                                        Label::new(author_name)
1058                                            .size(LabelSize::Small)
1059                                            .color(Color::Muted),
1060                                    )
1061                                    .when(!author_email.is_empty(), |this| {
1062                                        this.child(
1063                                            Label::new(format!("<{}>", author_email))
1064                                                .size(LabelSize::Small)
1065                                                .color(Color::Ignored),
1066                                        )
1067                                    }),
1068                            )
1069                            .child(
1070                                h_flex()
1071                                    .gap_1()
1072                                    .child(
1073                                        Icon::new(IconName::Hash)
1074                                            .size(IconSize::Small)
1075                                            .color(Color::Muted),
1076                                    )
1077                                    .child({
1078                                        let copy_sha = full_sha.clone();
1079                                        Button::new("sha-button", truncated_sha)
1080                                            .style(ButtonStyle::Transparent)
1081                                            .label_size(LabelSize::Small)
1082                                            .color(Color::Muted)
1083                                            .tooltip(Tooltip::text(format!(
1084                                                "Copy SHA: {}",
1085                                                copy_sha
1086                                            )))
1087                                            .on_click(move |_, _, cx| {
1088                                                cx.write_to_clipboard(ClipboardItem::new_string(
1089                                                    copy_sha.to_string(),
1090                                                ));
1091                                            })
1092                                    }),
1093                            )
1094                            .when_some(remote.clone(), |this, remote| {
1095                                let provider_name = remote.host.name();
1096                                let icon = match provider_name.as_str() {
1097                                    "GitHub" => IconName::Github,
1098                                    _ => IconName::Link,
1099                                };
1100                                let parsed_remote = ParsedGitRemote {
1101                                    owner: remote.owner.as_ref().into(),
1102                                    repo: remote.repo.as_ref().into(),
1103                                };
1104                                let params = BuildCommitPermalinkParams {
1105                                    sha: full_sha.as_ref(),
1106                                };
1107                                let url = remote
1108                                    .host
1109                                    .build_commit_permalink(&parsed_remote, params)
1110                                    .to_string();
1111                                this.child(
1112                                    h_flex()
1113                                        .gap_1()
1114                                        .child(
1115                                            Icon::new(icon)
1116                                                .size(IconSize::Small)
1117                                                .color(Color::Muted),
1118                                        )
1119                                        .child(
1120                                            Button::new(
1121                                                "view-on-provider",
1122                                                format!("View on {}", provider_name),
1123                                            )
1124                                            .style(ButtonStyle::Transparent)
1125                                            .label_size(LabelSize::Small)
1126                                            .color(Color::Muted)
1127                                            .on_click(
1128                                                move |_, _, cx| {
1129                                                    cx.open_url(&url);
1130                                                },
1131                                            ),
1132                                        ),
1133                                )
1134                            }),
1135                    ),
1136            )
1137            .child(
1138                div()
1139                    .border_t_1()
1140                    .border_color(cx.theme().colors().border)
1141                    .p_3()
1142                    .min_w_0()
1143                    .child(
1144                        v_flex()
1145                            .gap_2()
1146                            .child(Label::new(subject).weight(FontWeight::MEDIUM)),
1147                    ),
1148            )
1149            .child(
1150                div()
1151                    .flex_1()
1152                    .overflow_hidden()
1153                    .border_t_1()
1154                    .border_color(cx.theme().colors().border)
1155                    .p_3()
1156                    .child(
1157                        v_flex()
1158                            .gap_2()
1159                            .child(
1160                                Label::new(format!("{} Changed Files", changed_files_count))
1161                                    .size(LabelSize::Small)
1162                                    .color(Color::Muted),
1163                            )
1164                            .children(self.selected_commit_diff.as_ref().map(|diff| {
1165                                v_flex().gap_1().children(diff.files.iter().map(|file| {
1166                                    let file_name: String = file
1167                                        .path
1168                                        .file_name()
1169                                        .map(|n| n.to_string())
1170                                        .unwrap_or_default();
1171                                    let dir_path: String = file
1172                                        .path
1173                                        .parent()
1174                                        .map(|p| p.as_unix_str().to_string())
1175                                        .unwrap_or_default();
1176
1177                                    h_flex()
1178                                        .gap_1()
1179                                        .overflow_hidden()
1180                                        .child(
1181                                            Icon::new(IconName::File)
1182                                                .size(IconSize::Small)
1183                                                .color(Color::Accent),
1184                                        )
1185                                        .child(
1186                                            Label::new(file_name)
1187                                                .size(LabelSize::Small)
1188                                                .single_line(),
1189                                        )
1190                                        .when(!dir_path.is_empty(), |this| {
1191                                            this.child(
1192                                                Label::new(dir_path)
1193                                                    .size(LabelSize::Small)
1194                                                    .color(Color::Muted)
1195                                                    .single_line(),
1196                                            )
1197                                        })
1198                                }))
1199                            })),
1200                    ),
1201            )
1202            .into_any_element()
1203    }
1204
1205    pub fn render_graph(&self, cx: &mut Context<GitGraph>) -> impl IntoElement {
1206        let row_height = self.row_height;
1207        let table_state = self.table_interaction_state.read(cx);
1208        let viewport_height = table_state
1209            .scroll_handle
1210            .0
1211            .borrow()
1212            .last_item_size
1213            .map(|size| size.item.height)
1214            .unwrap_or(px(600.0));
1215        let loaded_commit_count = self.graph_data.commits.len();
1216
1217        let content_height = row_height * loaded_commit_count;
1218        let max_scroll = (content_height - viewport_height).max(px(0.));
1219        let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
1220
1221        let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
1222        let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
1223        let horizontal_scroll_offset = self.horizontal_scroll_offset;
1224
1225        let max_lanes = self.graph_data.max_lanes.max(6);
1226        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1227        let last_visible_row =
1228            first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
1229
1230        let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
1231            ..(last_visible_row).min(loaded_commit_count);
1232        let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
1233        let commit_lines: Vec<_> = self
1234            .graph_data
1235            .lines
1236            .iter()
1237            .filter(|line| {
1238                line.full_interval.start <= viewport_range.end
1239                    && line.full_interval.end >= viewport_range.start
1240            })
1241            .cloned()
1242            .collect();
1243
1244        let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
1245
1246        gpui::canvas(
1247            move |_bounds, _window, _cx| {},
1248            move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
1249                window.paint_layer(bounds, |window| {
1250                    let accent_colors = cx.theme().accents();
1251
1252                    for (row_idx, row) in rows.into_iter().enumerate() {
1253                        let row_color = accent_colors.color_for_index(row.color_idx as u32);
1254                        let row_y_center =
1255                            bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
1256                                - vertical_scroll_offset;
1257
1258                        let commit_x =
1259                            lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
1260
1261                        draw_commit_circle(commit_x, row_y_center, row_color, window);
1262                    }
1263
1264                    for line in commit_lines {
1265                        let Some((start_segment_idx, start_column)) =
1266                            line.get_first_visible_segment_idx(first_visible_row)
1267                        else {
1268                            continue;
1269                        };
1270
1271                        let line_x =
1272                            lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
1273
1274                        let start_row = line.full_interval.start as i32 - first_visible_row as i32;
1275
1276                        let from_y =
1277                            bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
1278                                - vertical_scroll_offset
1279                                + COMMIT_CIRCLE_RADIUS;
1280
1281                        let mut current_row = from_y;
1282                        let mut current_column = line_x;
1283
1284                        let mut builder = PathBuilder::stroke(LINE_WIDTH);
1285                        builder.move_to(point(line_x, from_y));
1286
1287                        let segments = &line.segments[start_segment_idx..];
1288
1289                        for (segment_idx, segment) in segments.iter().enumerate() {
1290                            let is_last = segment_idx + 1 == segments.len();
1291
1292                            match segment {
1293                                CommitLineSegment::Straight { to_row } => {
1294                                    let mut dest_row = to_row_center(
1295                                        to_row - first_visible_row,
1296                                        row_height,
1297                                        vertical_scroll_offset,
1298                                        bounds,
1299                                    );
1300                                    if is_last {
1301                                        dest_row -= COMMIT_CIRCLE_RADIUS;
1302                                    }
1303
1304                                    let dest_point = point(current_column, dest_row);
1305
1306                                    current_row = dest_point.y;
1307                                    builder.line_to(dest_point);
1308                                    builder.move_to(dest_point);
1309                                }
1310                                CommitLineSegment::Curve {
1311                                    to_column,
1312                                    on_row,
1313                                    curve_kind,
1314                                } => {
1315                                    let mut to_column = lane_center_x(
1316                                        bounds,
1317                                        *to_column as f32,
1318                                        horizontal_scroll_offset,
1319                                    );
1320
1321                                    let mut to_row = to_row_center(
1322                                        *on_row - first_visible_row,
1323                                        row_height,
1324                                        vertical_scroll_offset,
1325                                        bounds,
1326                                    );
1327
1328                                    // This means that this branch was a checkout
1329                                    let going_right = to_column > current_column;
1330                                    let column_shift = if going_right {
1331                                        COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
1332                                    } else {
1333                                        -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
1334                                    };
1335
1336                                    let control = match curve_kind {
1337                                        CurveKind::Checkout => {
1338                                            if is_last {
1339                                                to_column -= column_shift;
1340                                            }
1341                                            builder.move_to(point(current_column, current_row));
1342                                            point(current_column, to_row)
1343                                        }
1344                                        CurveKind::Merge => {
1345                                            if is_last {
1346                                                to_row -= COMMIT_CIRCLE_RADIUS;
1347                                            }
1348                                            builder.move_to(point(
1349                                                current_column + column_shift,
1350                                                current_row - COMMIT_CIRCLE_RADIUS,
1351                                            ));
1352                                            point(to_column, current_row)
1353                                        }
1354                                    };
1355
1356                                    match curve_kind {
1357                                        CurveKind::Checkout
1358                                            if (to_row - current_row).abs() > row_height =>
1359                                        {
1360                                            let start_curve =
1361                                                point(current_column, current_row + row_height);
1362                                            builder.line_to(start_curve);
1363                                            builder.move_to(start_curve);
1364                                        }
1365                                        CurveKind::Merge
1366                                            if (to_column - current_column).abs() > LANE_WIDTH =>
1367                                        {
1368                                            let column_shift =
1369                                                if going_right { LANE_WIDTH } else { -LANE_WIDTH };
1370
1371                                            let start_curve = point(
1372                                                current_column + column_shift,
1373                                                current_row - COMMIT_CIRCLE_RADIUS,
1374                                            );
1375
1376                                            builder.line_to(start_curve);
1377                                            builder.move_to(start_curve);
1378                                        }
1379                                        _ => {}
1380                                    };
1381
1382                                    builder.curve_to(point(to_column, to_row), control);
1383                                    current_row = to_row;
1384                                    current_column = to_column;
1385                                    builder.move_to(point(current_column, current_row));
1386                                }
1387                            }
1388                        }
1389
1390                        builder.close();
1391                        lines.entry(line.color_idx).or_default().push(builder);
1392                    }
1393
1394                    for (color_idx, builders) in lines {
1395                        let line_color = accent_colors.color_for_index(color_idx as u32);
1396
1397                        for builder in builders {
1398                            if let Ok(path) = builder.build() {
1399                                // we paint each color on it's own layer to stop overlapping lines
1400                                // of different colors changing the color of a line
1401                                window.paint_layer(bounds, |window| {
1402                                    window.paint_path(path, line_color);
1403                                });
1404                            }
1405                        }
1406                    }
1407                })
1408            },
1409        )
1410        .w(graph_width)
1411        .h_full()
1412    }
1413
1414    fn handle_graph_scroll(
1415        &mut self,
1416        event: &ScrollWheelEvent,
1417        window: &mut Window,
1418        cx: &mut Context<Self>,
1419    ) {
1420        let line_height = window.line_height();
1421        let delta = event.delta.pixel_delta(line_height);
1422
1423        let table_state = self.table_interaction_state.read(cx);
1424        let current_offset = table_state.scroll_offset();
1425
1426        let viewport_height = table_state.scroll_handle.viewport().size.height;
1427
1428        let commit_count = match self.graph_data.max_commit_count {
1429            AllCommitCount::Loaded(count) => count,
1430            AllCommitCount::NotLoaded => self.graph_data.commits.len(),
1431        };
1432        let content_height = self.row_height * commit_count;
1433        let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
1434
1435        let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
1436        let new_offset = Point::new(current_offset.x, new_y);
1437
1438        let max_lanes = self.graph_data.max_lanes.max(1);
1439        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1440        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
1441
1442        let new_horizontal_offset =
1443            (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
1444
1445        let vertical_changed = new_offset != current_offset;
1446        let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
1447
1448        if vertical_changed {
1449            table_state.set_scroll_offset(new_offset);
1450        }
1451
1452        if horizontal_changed {
1453            self.horizontal_scroll_offset = new_horizontal_offset;
1454        }
1455
1456        if vertical_changed || horizontal_changed {
1457            cx.notify();
1458        }
1459    }
1460}
1461
1462impl Render for GitGraph {
1463    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1464        let description_width_fraction = 0.72;
1465        let date_width_fraction = 0.12;
1466        let author_width_fraction = 0.10;
1467        let commit_width_fraction = 0.06;
1468
1469        let (commit_count, is_loading) = match self.graph_data.max_commit_count {
1470            AllCommitCount::Loaded(count) => (count, true),
1471            AllCommitCount::NotLoaded => {
1472                let is_loading = self.project.update(cx, |project, cx| {
1473                    if let Some(repository) = project.active_repository(cx) {
1474                        repository.update(cx, |repository, cx| {
1475                            // Start loading the graph data if we haven't started already
1476                            repository
1477                                .graph_data(self.log_source.clone(), self.log_order, 0..0, cx)
1478                                .1
1479                        })
1480                    } else {
1481                        false
1482                    }
1483                }) && self.graph_data.commits.is_empty();
1484
1485                (self.graph_data.commits.len(), is_loading)
1486            }
1487        };
1488
1489        let content = if self.graph_data.commits.is_empty() {
1490            let message = if is_loading {
1491                "Loading"
1492            } else {
1493                "No commits found"
1494            };
1495            let label = Label::new(message)
1496                .color(Color::Muted)
1497                .size(LabelSize::Large);
1498            div()
1499                .size_full()
1500                .h_flex()
1501                .gap_1()
1502                .items_center()
1503                .justify_center()
1504                .child(label)
1505                .when(is_loading, |this| {
1506                    this.child(self.render_loading_spinner(cx))
1507                })
1508        } else {
1509            div()
1510                .size_full()
1511                .flex()
1512                .flex_row()
1513                .child(
1514                    div()
1515                        .w(self.graph_content_width())
1516                        .h_full()
1517                        .flex()
1518                        .flex_col()
1519                        .child(
1520                            div()
1521                                .p_2()
1522                                .border_b_1()
1523                                .border_color(cx.theme().colors().border)
1524                                .child(Label::new("Graph").color(Color::Muted)),
1525                        )
1526                        .child(
1527                            div()
1528                                .id("graph-canvas")
1529                                .flex_1()
1530                                .overflow_hidden()
1531                                .child(self.render_graph(cx))
1532                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)),
1533                        ),
1534                )
1535                .child({
1536                    let row_height = self.row_height;
1537                    let selected_entry_idx = self.selected_entry_idx;
1538                    let weak_self = cx.weak_entity();
1539                    div().flex_1().size_full().child(
1540                        Table::new(4)
1541                            .interactable(&self.table_interaction_state)
1542                            .hide_row_borders()
1543                            .header(vec![
1544                                Label::new("Description")
1545                                    .color(Color::Muted)
1546                                    .into_any_element(),
1547                                Label::new("Date").color(Color::Muted).into_any_element(),
1548                                Label::new("Author").color(Color::Muted).into_any_element(),
1549                                Label::new("Commit").color(Color::Muted).into_any_element(),
1550                            ])
1551                            .column_widths(
1552                                [
1553                                    DefiniteLength::Fraction(description_width_fraction),
1554                                    DefiniteLength::Fraction(date_width_fraction),
1555                                    DefiniteLength::Fraction(author_width_fraction),
1556                                    DefiniteLength::Fraction(commit_width_fraction),
1557                                ]
1558                                .to_vec(),
1559                            )
1560                            .resizable_columns(
1561                                vec![
1562                                    TableResizeBehavior::Resizable,
1563                                    TableResizeBehavior::Resizable,
1564                                    TableResizeBehavior::Resizable,
1565                                    TableResizeBehavior::Resizable,
1566                                ],
1567                                &self.table_column_widths,
1568                                cx,
1569                            )
1570                            .map_row(move |(index, row), _window, cx| {
1571                                let is_selected = selected_entry_idx == Some(index);
1572                                let weak = weak_self.clone();
1573                                row.h(row_height)
1574                                    .when(is_selected, |row| {
1575                                        row.bg(cx.theme().colors().element_selected)
1576                                    })
1577                                    .on_click(move |_, _, cx| {
1578                                        weak.update(cx, |this, cx| {
1579                                            this.select_entry(index, cx);
1580                                        })
1581                                        .ok();
1582                                    })
1583                                    .into_any_element()
1584                            })
1585                            .uniform_list(
1586                                "git-graph-commits",
1587                                commit_count,
1588                                cx.processor(Self::render_table_rows),
1589                            ),
1590                    )
1591                })
1592                .when(self.selected_entry_idx.is_some(), |this| {
1593                    this.child(self.render_commit_detail_panel(window, cx))
1594                })
1595        };
1596
1597        div()
1598            .size_full()
1599            .bg(cx.theme().colors().editor_background)
1600            .key_context("GitGraph")
1601            .track_focus(&self.focus_handle)
1602            .child(content)
1603            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1604                deferred(
1605                    anchored()
1606                        .position(*position)
1607                        .anchor(Corner::TopLeft)
1608                        .child(menu.clone()),
1609                )
1610                .with_priority(1)
1611            }))
1612    }
1613}
1614
1615impl EventEmitter<ItemEvent> for GitGraph {}
1616
1617impl Focusable for GitGraph {
1618    fn focus_handle(&self, _cx: &App) -> FocusHandle {
1619        self.focus_handle.clone()
1620    }
1621}
1622
1623impl Item for GitGraph {
1624    type Event = ItemEvent;
1625
1626    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1627        "Git Graph".into()
1628    }
1629
1630    fn show_toolbar(&self) -> bool {
1631        false
1632    }
1633
1634    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1635        f(*event)
1636    }
1637}
1638
1639impl SerializableItem for GitGraph {
1640    fn serialized_item_kind() -> &'static str {
1641        "GitGraph"
1642    }
1643
1644    fn cleanup(
1645        workspace_id: workspace::WorkspaceId,
1646        alive_items: Vec<workspace::ItemId>,
1647        _window: &mut Window,
1648        cx: &mut App,
1649    ) -> Task<gpui::Result<()>> {
1650        workspace::delete_unloaded_items(
1651            alive_items,
1652            workspace_id,
1653            "git_graphs",
1654            &persistence::GIT_GRAPHS,
1655            cx,
1656        )
1657    }
1658
1659    fn deserialize(
1660        project: Entity<Project>,
1661        _: WeakEntity<Workspace>,
1662        workspace_id: workspace::WorkspaceId,
1663        item_id: workspace::ItemId,
1664        window: &mut Window,
1665        cx: &mut App,
1666    ) -> Task<gpui::Result<Entity<Self>>> {
1667        if persistence::GIT_GRAPHS
1668            .get_git_graph(item_id, workspace_id)
1669            .ok()
1670            .is_some_and(|is_open| is_open)
1671        {
1672            let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
1673            Task::ready(Ok(git_graph))
1674        } else {
1675            Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
1676        }
1677    }
1678
1679    fn serialize(
1680        &mut self,
1681        workspace: &mut Workspace,
1682        item_id: workspace::ItemId,
1683        _closing: bool,
1684        _window: &mut Window,
1685        cx: &mut Context<Self>,
1686    ) -> Option<Task<gpui::Result<()>>> {
1687        let workspace_id = workspace.database_id()?;
1688        Some(cx.background_spawn(async move {
1689            persistence::GIT_GRAPHS
1690                .save_git_graph(item_id, workspace_id, true)
1691                .await
1692        }))
1693    }
1694
1695    fn should_serialize(&self, event: &Self::Event) -> bool {
1696        event == &ItemEvent::UpdateTab
1697    }
1698}
1699
1700mod persistence {
1701    use db::{
1702        query,
1703        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1704        sqlez_macros::sql,
1705    };
1706    use workspace::WorkspaceDb;
1707
1708    pub struct GitGraphsDb(ThreadSafeConnection);
1709
1710    impl Domain for GitGraphsDb {
1711        const NAME: &str = stringify!(GitGraphsDb);
1712
1713        const MIGRATIONS: &[&str] = (&[sql!(
1714            CREATE TABLE git_graphs (
1715                workspace_id INTEGER,
1716                item_id INTEGER UNIQUE,
1717                is_open INTEGER DEFAULT FALSE,
1718
1719                PRIMARY KEY(workspace_id, item_id),
1720                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1721                ON DELETE CASCADE
1722            ) STRICT;
1723        )]);
1724    }
1725
1726    db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
1727
1728    impl GitGraphsDb {
1729        query! {
1730            pub async fn save_git_graph(
1731                item_id: workspace::ItemId,
1732                workspace_id: workspace::WorkspaceId,
1733                is_open: bool
1734            ) -> Result<()> {
1735                INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
1736                VALUES (?, ?, ?)
1737            }
1738        }
1739
1740        query! {
1741            pub fn get_git_graph(
1742                item_id: workspace::ItemId,
1743                workspace_id: workspace::WorkspaceId
1744            ) -> Result<bool> {
1745                SELECT is_open
1746                FROM git_graphs
1747                WHERE item_id = ? AND workspace_id = ?
1748            }
1749        }
1750    }
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755    use super::*;
1756    use anyhow::{Context, Result, bail};
1757    use collections::{HashMap, HashSet};
1758    use fs::FakeFs;
1759    use git::Oid;
1760    use git::repository::InitialGraphCommitData;
1761    use gpui::TestAppContext;
1762    use project::Project;
1763    use rand::prelude::*;
1764    use serde_json::json;
1765    use settings::SettingsStore;
1766    use smallvec::{SmallVec, smallvec};
1767    use std::path::Path;
1768    use std::sync::Arc;
1769
1770    fn init_test(cx: &mut TestAppContext) {
1771        cx.update(|cx| {
1772            let settings_store = SettingsStore::test(cx);
1773            cx.set_global(settings_store);
1774        });
1775    }
1776
1777    /// Generates a random commit DAG suitable for testing git graph rendering.
1778    ///
1779    /// The commits are ordered newest-first (like git log output), so:
1780    /// - Index 0 = most recent commit (HEAD)
1781    /// - Last index = oldest commit (root, has no parents)
1782    /// - Parents of commit at index I must have index > I
1783    ///
1784    /// When `adversarial` is true, generates complex topologies with many branches
1785    /// and octopus merges. Otherwise generates more realistic linear histories
1786    /// with occasional branches.
1787    fn generate_random_commit_dag(
1788        rng: &mut StdRng,
1789        num_commits: usize,
1790        adversarial: bool,
1791    ) -> Vec<Arc<InitialGraphCommitData>> {
1792        if num_commits == 0 {
1793            return Vec::new();
1794        }
1795
1796        let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
1797        let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
1798
1799        for i in 0..num_commits {
1800            let sha = oids[i];
1801
1802            let parents = if i == num_commits - 1 {
1803                smallvec![]
1804            } else {
1805                generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
1806            };
1807
1808            let ref_names = if i == 0 {
1809                vec!["HEAD".into(), "main".into()]
1810            } else if adversarial && rng.random_bool(0.1) {
1811                vec![format!("branch-{}", i).into()]
1812            } else {
1813                Vec::new()
1814            };
1815
1816            commits.push(Arc::new(InitialGraphCommitData {
1817                sha,
1818                parents,
1819                ref_names,
1820            }));
1821        }
1822
1823        commits
1824    }
1825
1826    fn generate_parents_from_oids(
1827        rng: &mut StdRng,
1828        oids: &[Oid],
1829        current_idx: usize,
1830        num_commits: usize,
1831        adversarial: bool,
1832    ) -> SmallVec<[Oid; 1]> {
1833        let remaining = num_commits - current_idx - 1;
1834        if remaining == 0 {
1835            return smallvec![];
1836        }
1837
1838        if adversarial {
1839            let merge_chance = 0.4;
1840            let octopus_chance = 0.15;
1841
1842            if remaining >= 3 && rng.random_bool(octopus_chance) {
1843                let num_parents = rng.random_range(3..=remaining.min(5));
1844                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
1845                parent_indices.shuffle(rng);
1846                parent_indices
1847                    .into_iter()
1848                    .take(num_parents)
1849                    .map(|idx| oids[idx])
1850                    .collect()
1851            } else if remaining >= 2 && rng.random_bool(merge_chance) {
1852                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
1853                parent_indices.shuffle(rng);
1854                parent_indices
1855                    .into_iter()
1856                    .take(2)
1857                    .map(|idx| oids[idx])
1858                    .collect()
1859            } else {
1860                let parent_idx = rng.random_range(current_idx + 1..num_commits);
1861                smallvec![oids[parent_idx]]
1862            }
1863        } else {
1864            let merge_chance = 0.15;
1865            let skip_chance = 0.1;
1866
1867            if remaining >= 2 && rng.random_bool(merge_chance) {
1868                let first_parent = current_idx + 1;
1869                let second_parent = rng.random_range(current_idx + 2..num_commits);
1870                smallvec![oids[first_parent], oids[second_parent]]
1871            } else if rng.random_bool(skip_chance) && remaining >= 2 {
1872                let skip = rng.random_range(1..remaining.min(3));
1873                smallvec![oids[current_idx + 1 + skip]]
1874            } else {
1875                smallvec![oids[current_idx + 1]]
1876            }
1877        }
1878    }
1879
1880    fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
1881        graph
1882            .commits
1883            .iter()
1884            .enumerate()
1885            .map(|(idx, entry)| (entry.data.sha, idx))
1886            .collect()
1887    }
1888
1889    fn verify_commit_order(
1890        graph: &GraphData,
1891        commits: &[Arc<InitialGraphCommitData>],
1892    ) -> Result<()> {
1893        if graph.commits.len() != commits.len() {
1894            bail!(
1895                "Commit count mismatch: graph has {} commits, expected {}",
1896                graph.commits.len(),
1897                commits.len()
1898            );
1899        }
1900
1901        for (idx, (graph_commit, expected_commit)) in
1902            graph.commits.iter().zip(commits.iter()).enumerate()
1903        {
1904            if graph_commit.data.sha != expected_commit.sha {
1905                bail!(
1906                    "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
1907                    idx,
1908                    graph_commit.data.sha,
1909                    expected_commit.sha
1910                );
1911            }
1912        }
1913
1914        Ok(())
1915    }
1916
1917    fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
1918        for line in &graph.lines {
1919            let child_row = *oid_to_row
1920                .get(&line.child)
1921                .context("Line references non-existent child commit")?;
1922
1923            let parent_row = *oid_to_row
1924                .get(&line.parent)
1925                .context("Line references non-existent parent commit")?;
1926
1927            if child_row >= parent_row {
1928                bail!(
1929                    "child_row ({}) must be < parent_row ({})",
1930                    child_row,
1931                    parent_row
1932                );
1933            }
1934
1935            if line.full_interval.start != child_row {
1936                bail!(
1937                    "full_interval.start ({}) != child_row ({})",
1938                    line.full_interval.start,
1939                    child_row
1940                );
1941            }
1942
1943            if line.full_interval.end != parent_row {
1944                bail!(
1945                    "full_interval.end ({}) != parent_row ({})",
1946                    line.full_interval.end,
1947                    parent_row
1948                );
1949            }
1950
1951            if let Some(last_segment) = line.segments.last() {
1952                let segment_end_row = match last_segment {
1953                    CommitLineSegment::Straight { to_row } => *to_row,
1954                    CommitLineSegment::Curve { on_row, .. } => *on_row,
1955                };
1956
1957                if segment_end_row != line.full_interval.end {
1958                    bail!(
1959                        "last segment ends at row {} but full_interval.end is {}",
1960                        segment_end_row,
1961                        line.full_interval.end
1962                    );
1963                }
1964            }
1965        }
1966
1967        Ok(())
1968    }
1969
1970    fn verify_column_correctness(
1971        graph: &GraphData,
1972        oid_to_row: &HashMap<Oid, usize>,
1973    ) -> Result<()> {
1974        for line in &graph.lines {
1975            let child_row = *oid_to_row
1976                .get(&line.child)
1977                .context("Line references non-existent child commit")?;
1978
1979            let parent_row = *oid_to_row
1980                .get(&line.parent)
1981                .context("Line references non-existent parent commit")?;
1982
1983            let child_lane = graph.commits[child_row].lane;
1984            if line.child_column != child_lane {
1985                bail!(
1986                    "child_column ({}) != child's lane ({})",
1987                    line.child_column,
1988                    child_lane
1989                );
1990            }
1991
1992            let mut current_column = line.child_column;
1993            for segment in &line.segments {
1994                if let CommitLineSegment::Curve { to_column, .. } = segment {
1995                    current_column = *to_column;
1996                }
1997            }
1998
1999            let parent_lane = graph.commits[parent_row].lane;
2000            if current_column != parent_lane {
2001                bail!(
2002                    "ending column ({}) != parent's lane ({})",
2003                    current_column,
2004                    parent_lane
2005                );
2006            }
2007        }
2008
2009        Ok(())
2010    }
2011
2012    fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
2013        for line in &graph.lines {
2014            if line.segments.is_empty() {
2015                bail!("Line has no segments");
2016            }
2017
2018            let mut current_row = line.full_interval.start;
2019
2020            for (idx, segment) in line.segments.iter().enumerate() {
2021                let segment_end_row = match segment {
2022                    CommitLineSegment::Straight { to_row } => *to_row,
2023                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2024                };
2025
2026                if segment_end_row < current_row {
2027                    bail!(
2028                        "segment {} ends at row {} which is before current row {}",
2029                        idx,
2030                        segment_end_row,
2031                        current_row
2032                    );
2033                }
2034
2035                current_row = segment_end_row;
2036            }
2037        }
2038
2039        Ok(())
2040    }
2041
2042    fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
2043        for line in &graph.lines {
2044            let child_row = line.full_interval.start;
2045
2046            let mut current_column = line.child_column;
2047            let mut current_row = child_row;
2048
2049            for segment in &line.segments {
2050                match segment {
2051                    CommitLineSegment::Straight { to_row } => {
2052                        for row in (current_row + 1)..*to_row {
2053                            if row < graph.commits.len() {
2054                                let commit_at_row = &graph.commits[row];
2055                                if commit_at_row.lane == current_column {
2056                                    bail!(
2057                                        "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
2058                                        current_row,
2059                                        to_row,
2060                                        current_column,
2061                                        commit_at_row.data.sha,
2062                                        row
2063                                    );
2064                                }
2065                            }
2066                        }
2067                        current_row = *to_row;
2068                    }
2069                    CommitLineSegment::Curve {
2070                        to_column, on_row, ..
2071                    } => {
2072                        current_column = *to_column;
2073                        current_row = *on_row;
2074                    }
2075                }
2076            }
2077        }
2078
2079        Ok(())
2080    }
2081
2082    fn verify_coverage(graph: &GraphData) -> Result<()> {
2083        let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
2084        for entry in &graph.commits {
2085            for parent in &entry.data.parents {
2086                expected_edges.insert((entry.data.sha, *parent));
2087            }
2088        }
2089
2090        let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
2091        for line in &graph.lines {
2092            let edge = (line.child, line.parent);
2093
2094            if !found_edges.insert(edge) {
2095                bail!(
2096                    "Duplicate line found for edge {:?} -> {:?}",
2097                    line.child,
2098                    line.parent
2099                );
2100            }
2101
2102            if !expected_edges.contains(&edge) {
2103                bail!(
2104                    "Orphan line found: {:?} -> {:?} is not in the commit graph",
2105                    line.child,
2106                    line.parent
2107                );
2108            }
2109        }
2110
2111        for (child, parent) in &expected_edges {
2112            if !found_edges.contains(&(*child, *parent)) {
2113                bail!("Missing line for edge {:?} -> {:?}", child, parent);
2114            }
2115        }
2116
2117        assert_eq!(
2118            expected_edges.symmetric_difference(&found_edges).count(),
2119            0,
2120            "The symmetric difference should be zero"
2121        );
2122
2123        Ok(())
2124    }
2125
2126    fn verify_merge_line_optimality(
2127        graph: &GraphData,
2128        oid_to_row: &HashMap<Oid, usize>,
2129    ) -> Result<()> {
2130        for line in &graph.lines {
2131            let first_segment = line.segments.first();
2132            let is_merge_line = matches!(
2133                first_segment,
2134                Some(CommitLineSegment::Curve {
2135                    curve_kind: CurveKind::Merge,
2136                    ..
2137                })
2138            );
2139
2140            if !is_merge_line {
2141                continue;
2142            }
2143
2144            let child_row = *oid_to_row
2145                .get(&line.child)
2146                .context("Line references non-existent child commit")?;
2147
2148            let parent_row = *oid_to_row
2149                .get(&line.parent)
2150                .context("Line references non-existent parent commit")?;
2151
2152            let parent_lane = graph.commits[parent_row].lane;
2153
2154            let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
2155                continue;
2156            };
2157
2158            let curves_directly_to_parent = *to_column == parent_lane;
2159
2160            if !curves_directly_to_parent {
2161                continue;
2162            }
2163
2164            let curve_row = child_row + 1;
2165            let has_commits_in_path = graph.commits[curve_row..parent_row]
2166                .iter()
2167                .any(|c| c.lane == parent_lane);
2168
2169            if has_commits_in_path {
2170                bail!(
2171                    "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
2172                    line.child,
2173                    line.parent,
2174                    parent_lane,
2175                    curve_row,
2176                    parent_row
2177                );
2178            }
2179
2180            let curve_ends_at_parent = curve_row == parent_row;
2181
2182            if curve_ends_at_parent {
2183                if line.segments.len() != 1 {
2184                    bail!(
2185                        "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
2186                        line.child,
2187                        line.parent,
2188                        line.segments.len()
2189                    );
2190                }
2191            } else {
2192                if line.segments.len() != 2 {
2193                    bail!(
2194                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
2195                        line.child,
2196                        line.parent,
2197                        line.segments.len()
2198                    );
2199                }
2200
2201                let is_straight_segment = matches!(
2202                    line.segments.get(1),
2203                    Some(CommitLineSegment::Straight { .. })
2204                );
2205
2206                if !is_straight_segment {
2207                    bail!(
2208                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
2209                        line.child,
2210                        line.parent
2211                    );
2212                }
2213            }
2214        }
2215
2216        Ok(())
2217    }
2218
2219    fn verify_all_invariants(
2220        graph: &GraphData,
2221        commits: &[Arc<InitialGraphCommitData>],
2222    ) -> Result<()> {
2223        let oid_to_row = build_oid_to_row_map(graph);
2224
2225        verify_commit_order(graph, commits).context("commit order")?;
2226        verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
2227        verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
2228        verify_segment_continuity(graph).context("segment continuity")?;
2229        verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
2230        verify_coverage(graph).context("coverage")?;
2231        verify_line_overlaps(graph).context("line overlaps")?;
2232        Ok(())
2233    }
2234
2235    #[test]
2236    fn test_git_graph_merge_commits() {
2237        let mut rng = StdRng::seed_from_u64(42);
2238
2239        let oid1 = Oid::random(&mut rng);
2240        let oid2 = Oid::random(&mut rng);
2241        let oid3 = Oid::random(&mut rng);
2242        let oid4 = Oid::random(&mut rng);
2243
2244        let commits = vec![
2245            Arc::new(InitialGraphCommitData {
2246                sha: oid1,
2247                parents: smallvec![oid2, oid3],
2248                ref_names: vec!["HEAD".into()],
2249            }),
2250            Arc::new(InitialGraphCommitData {
2251                sha: oid2,
2252                parents: smallvec![oid4],
2253                ref_names: vec![],
2254            }),
2255            Arc::new(InitialGraphCommitData {
2256                sha: oid3,
2257                parents: smallvec![oid4],
2258                ref_names: vec![],
2259            }),
2260            Arc::new(InitialGraphCommitData {
2261                sha: oid4,
2262                parents: smallvec![],
2263                ref_names: vec![],
2264            }),
2265        ];
2266
2267        let mut graph_data = GraphData::new(8);
2268        graph_data.add_commits(&commits);
2269
2270        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2271            panic!("Graph invariant violation for merge commits:\n{}", error);
2272        }
2273    }
2274
2275    #[test]
2276    fn test_git_graph_linear_commits() {
2277        let mut rng = StdRng::seed_from_u64(42);
2278
2279        let oid1 = Oid::random(&mut rng);
2280        let oid2 = Oid::random(&mut rng);
2281        let oid3 = Oid::random(&mut rng);
2282
2283        let commits = vec![
2284            Arc::new(InitialGraphCommitData {
2285                sha: oid1,
2286                parents: smallvec![oid2],
2287                ref_names: vec!["HEAD".into()],
2288            }),
2289            Arc::new(InitialGraphCommitData {
2290                sha: oid2,
2291                parents: smallvec![oid3],
2292                ref_names: vec![],
2293            }),
2294            Arc::new(InitialGraphCommitData {
2295                sha: oid3,
2296                parents: smallvec![],
2297                ref_names: vec![],
2298            }),
2299        ];
2300
2301        let mut graph_data = GraphData::new(8);
2302        graph_data.add_commits(&commits);
2303
2304        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2305            panic!("Graph invariant violation for linear commits:\n{}", error);
2306        }
2307    }
2308
2309    #[test]
2310    fn test_git_graph_random_commits() {
2311        for seed in 0..100 {
2312            let mut rng = StdRng::seed_from_u64(seed);
2313
2314            let adversarial = rng.random_bool(0.2);
2315            let num_commits = if adversarial {
2316                rng.random_range(10..100)
2317            } else {
2318                rng.random_range(5..50)
2319            };
2320
2321            let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2322
2323            assert_eq!(
2324                num_commits,
2325                commits.len(),
2326                "seed={}: Generate random commit dag didn't generate the correct amount of commits",
2327                seed
2328            );
2329
2330            let mut graph_data = GraphData::new(8);
2331            graph_data.add_commits(&commits);
2332
2333            if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2334                panic!(
2335                    "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
2336                    seed, adversarial, num_commits, error
2337                );
2338            }
2339        }
2340    }
2341
2342    // The full integration test has less iterations because it's significantly slower
2343    // than the random commit test
2344    #[gpui::test(iterations = 5)]
2345    async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
2346        init_test(cx);
2347
2348        let adversarial = rng.random_bool(0.2);
2349        let num_commits = if adversarial {
2350            rng.random_range(10..100)
2351        } else {
2352            rng.random_range(5..50)
2353        };
2354
2355        let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2356
2357        let fs = FakeFs::new(cx.executor());
2358        fs.insert_tree(
2359            Path::new("/project"),
2360            json!({
2361                ".git": {},
2362                "file.txt": "content",
2363            }),
2364        )
2365        .await;
2366
2367        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
2368
2369        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
2370        cx.run_until_parked();
2371
2372        let repository = project.read_with(cx, |project, cx| {
2373            project
2374                .active_repository(cx)
2375                .expect("should have a repository")
2376        });
2377
2378        repository.update(cx, |repo, cx| {
2379            repo.graph_data(
2380                crate::LogSource::default(),
2381                crate::LogOrder::default(),
2382                0..usize::MAX,
2383                cx,
2384            );
2385        });
2386        cx.run_until_parked();
2387
2388        let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
2389            repo.graph_data(
2390                crate::LogSource::default(),
2391                crate::LogOrder::default(),
2392                0..usize::MAX,
2393                cx,
2394            )
2395            .0
2396            .to_vec()
2397        });
2398
2399        let mut graph_data = GraphData::new(8);
2400        graph_data.add_commits(&graph_commits);
2401
2402        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2403            panic!(
2404                "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
2405                adversarial, num_commits, error
2406            );
2407        }
2408    }
2409}