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