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