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