git_graph.rs

   1use collections::{BTreeMap, HashMap};
   2use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
   3use git::{
   4    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
   5    parse_git_remote_url,
   6    repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath},
   7    status::{FileStatus, StatusCode, TrackedStatus},
   8};
   9use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon};
  10use gpui::{
  11    AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent,
  12    ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels,
  13    Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task,
  14    UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
  15    px, uniform_list,
  16};
  17use language::line_diff;
  18use menu::{Cancel, SelectNext, SelectPrevious};
  19use project::{
  20    Project,
  21    git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent, RepositoryId},
  22};
  23use settings::Settings;
  24use smallvec::{SmallVec, smallvec};
  25use std::{
  26    cell::Cell,
  27    ops::Range,
  28    rc::Rc,
  29    sync::Arc,
  30    sync::OnceLock,
  31    time::{Duration, Instant},
  32};
  33use theme::{AccentColors, ThemeSettings};
  34use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
  35use ui::{
  36    ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle,
  37    Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar,
  38    prelude::*,
  39};
  40use workspace::{
  41    Workspace,
  42    item::{Item, ItemEvent, SerializableItem, TabTooltipContent},
  43};
  44
  45const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);
  46const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
  47const LANE_WIDTH: Pixels = px(16.0);
  48const LEFT_PADDING: Pixels = px(12.0);
  49const LINE_WIDTH: Pixels = px(1.5);
  50const RESIZE_HANDLE_WIDTH: f32 = 8.0;
  51const PENDING_SELECT_MAX_RETRIES: usize = 5;
  52const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
  53
  54struct CopiedState {
  55    copied_at: Option<Instant>,
  56}
  57
  58impl CopiedState {
  59    fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
  60        Self { copied_at: None }
  61    }
  62
  63    fn is_copied(&self) -> bool {
  64        self.copied_at
  65            .map(|t| t.elapsed() < COPIED_STATE_DURATION)
  66            .unwrap_or(false)
  67    }
  68
  69    fn mark_copied(&mut self) {
  70        self.copied_at = Some(Instant::now());
  71    }
  72}
  73
  74struct DraggedSplitHandle;
  75
  76#[derive(Clone)]
  77struct ChangedFileEntry {
  78    status: FileStatus,
  79    file_name: SharedString,
  80    dir_path: SharedString,
  81    repo_path: RepoPath,
  82}
  83
  84impl ChangedFileEntry {
  85    fn from_commit_file(file: &CommitFile, _cx: &App) -> Self {
  86        let file_name: SharedString = file
  87            .path
  88            .file_name()
  89            .map(|n| n.to_string())
  90            .unwrap_or_default()
  91            .into();
  92        let dir_path: SharedString = file
  93            .path
  94            .parent()
  95            .map(|p| p.as_unix_str().to_string())
  96            .unwrap_or_default()
  97            .into();
  98
  99        let status_code = match (&file.old_text, &file.new_text) {
 100            (None, Some(_)) => StatusCode::Added,
 101            (Some(_), None) => StatusCode::Deleted,
 102            _ => StatusCode::Modified,
 103        };
 104
 105        let status = FileStatus::Tracked(TrackedStatus {
 106            index_status: status_code,
 107            worktree_status: StatusCode::Unmodified,
 108        });
 109
 110        Self {
 111            status,
 112            file_name,
 113            dir_path,
 114            repo_path: file.path.clone(),
 115        }
 116    }
 117
 118    fn open_in_commit_view(
 119        &self,
 120        commit_sha: &SharedString,
 121        repository: &WeakEntity<Repository>,
 122        workspace: &WeakEntity<Workspace>,
 123        window: &mut Window,
 124        cx: &mut App,
 125    ) {
 126        CommitView::open(
 127            commit_sha.to_string(),
 128            repository.clone(),
 129            workspace.clone(),
 130            None,
 131            Some(self.repo_path.clone()),
 132            window,
 133            cx,
 134        );
 135    }
 136
 137    fn render(
 138        &self,
 139        ix: usize,
 140        commit_sha: SharedString,
 141        repository: WeakEntity<Repository>,
 142        workspace: WeakEntity<Workspace>,
 143        _cx: &App,
 144    ) -> AnyElement {
 145        let file_name = self.file_name.clone();
 146        let dir_path = self.dir_path.clone();
 147
 148        div()
 149            .w_full()
 150            .child(
 151                ButtonLike::new(("changed-file", ix))
 152                    .child(
 153                        h_flex()
 154                            .min_w_0()
 155                            .w_full()
 156                            .gap_1()
 157                            .overflow_hidden()
 158                            .child(git_status_icon(self.status))
 159                            .child(
 160                                Label::new(file_name.clone())
 161                                    .size(LabelSize::Small)
 162                                    .truncate(),
 163                            )
 164                            .when(!dir_path.is_empty(), |this| {
 165                                this.child(
 166                                    Label::new(dir_path.clone())
 167                                        .size(LabelSize::Small)
 168                                        .color(Color::Muted)
 169                                        .truncate_start(),
 170                                )
 171                            }),
 172                    )
 173                    .tooltip({
 174                        let meta = if dir_path.is_empty() {
 175                            file_name
 176                        } else {
 177                            format!("{}/{}", dir_path, file_name).into()
 178                        };
 179                        move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx)
 180                    })
 181                    .on_click({
 182                        let entry = self.clone();
 183                        move |_, window, cx| {
 184                            entry.open_in_commit_view(
 185                                &commit_sha,
 186                                &repository,
 187                                &workspace,
 188                                window,
 189                                cx,
 190                            );
 191                        }
 192                    }),
 193            )
 194            .into_any_element()
 195    }
 196}
 197
 198pub struct SplitState {
 199    left_ratio: f32,
 200    visible_left_ratio: f32,
 201}
 202
 203impl SplitState {
 204    pub fn new() -> Self {
 205        Self {
 206            left_ratio: 1.0,
 207            visible_left_ratio: 1.0,
 208        }
 209    }
 210
 211    pub fn right_ratio(&self) -> f32 {
 212        1.0 - self.visible_left_ratio
 213    }
 214
 215    fn on_drag_move(
 216        &mut self,
 217        drag_event: &DragMoveEvent<DraggedSplitHandle>,
 218        _window: &mut Window,
 219        _cx: &mut Context<Self>,
 220    ) {
 221        let drag_position = drag_event.event.position;
 222        let bounds = drag_event.bounds;
 223        let bounds_width = bounds.right() - bounds.left();
 224
 225        let min_ratio = 0.1;
 226        let max_ratio = 0.9;
 227
 228        let new_ratio = (drag_position.x - bounds.left()) / bounds_width;
 229        self.visible_left_ratio = new_ratio.clamp(min_ratio, max_ratio);
 230    }
 231
 232    fn commit_ratio(&mut self) {
 233        self.left_ratio = self.visible_left_ratio;
 234    }
 235
 236    fn on_double_click(&mut self) {
 237        self.left_ratio = 1.0;
 238        self.visible_left_ratio = 1.0;
 239    }
 240}
 241
 242actions!(
 243    git_graph,
 244    [
 245        /// Opens the commit view for the selected commit.
 246        OpenCommitView,
 247    ]
 248);
 249
 250fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
 251    static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
 252    FORMAT.get_or_init(|| {
 253        time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
 254            .unwrap_or_default()
 255    })
 256}
 257
 258fn format_timestamp(timestamp: i64) -> String {
 259    let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
 260        return "Unknown".to_string();
 261    };
 262
 263    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
 264    let local_datetime = datetime.to_offset(local_offset);
 265
 266    local_datetime
 267        .format(timestamp_format())
 268        .unwrap_or_default()
 269}
 270
 271fn accent_colors_count(accents: &AccentColors) -> usize {
 272    accents.0.len()
 273}
 274
 275#[derive(Copy, Clone, Debug)]
 276struct BranchColor(u8);
 277
 278#[derive(Debug)]
 279enum LaneState {
 280    Empty,
 281    Active {
 282        child: Oid,
 283        parent: Oid,
 284        color: Option<BranchColor>,
 285        starting_row: usize,
 286        starting_col: usize,
 287        destination_column: Option<usize>,
 288        segments: SmallVec<[CommitLineSegment; 1]>,
 289    },
 290}
 291
 292impl LaneState {
 293    fn to_commit_lines(
 294        &mut self,
 295        ending_row: usize,
 296        lane_column: usize,
 297        parent_column: usize,
 298        parent_color: BranchColor,
 299    ) -> Option<CommitLine> {
 300        let state = std::mem::replace(self, LaneState::Empty);
 301
 302        match state {
 303            LaneState::Active {
 304                #[cfg_attr(not(test), allow(unused_variables))]
 305                parent,
 306                #[cfg_attr(not(test), allow(unused_variables))]
 307                child,
 308                color,
 309                starting_row,
 310                starting_col,
 311                destination_column,
 312                mut segments,
 313            } => {
 314                let final_destination = destination_column.unwrap_or(parent_column);
 315                let final_color = color.unwrap_or(parent_color);
 316
 317                Some(CommitLine {
 318                    #[cfg(test)]
 319                    child,
 320                    #[cfg(test)]
 321                    parent,
 322                    child_column: starting_col,
 323                    full_interval: starting_row..ending_row,
 324                    color_idx: final_color.0 as usize,
 325                    segments: {
 326                        match segments.last_mut() {
 327                            Some(CommitLineSegment::Straight { to_row })
 328                                if *to_row == usize::MAX =>
 329                            {
 330                                if final_destination != lane_column {
 331                                    *to_row = ending_row - 1;
 332
 333                                    let curved_line = CommitLineSegment::Curve {
 334                                        to_column: final_destination,
 335                                        on_row: ending_row,
 336                                        curve_kind: CurveKind::Checkout,
 337                                    };
 338
 339                                    if *to_row == starting_row {
 340                                        let last_index = segments.len() - 1;
 341                                        segments[last_index] = curved_line;
 342                                    } else {
 343                                        segments.push(curved_line);
 344                                    }
 345                                } else {
 346                                    *to_row = ending_row;
 347                                }
 348                            }
 349                            Some(CommitLineSegment::Curve {
 350                                on_row,
 351                                to_column,
 352                                curve_kind,
 353                            }) if *on_row == usize::MAX => {
 354                                if *to_column == usize::MAX {
 355                                    *to_column = final_destination;
 356                                }
 357                                if matches!(curve_kind, CurveKind::Merge) {
 358                                    *on_row = starting_row + 1;
 359                                    if *on_row < ending_row {
 360                                        if *to_column != final_destination {
 361                                            segments.push(CommitLineSegment::Straight {
 362                                                to_row: ending_row - 1,
 363                                            });
 364                                            segments.push(CommitLineSegment::Curve {
 365                                                to_column: final_destination,
 366                                                on_row: ending_row,
 367                                                curve_kind: CurveKind::Checkout,
 368                                            });
 369                                        } else {
 370                                            segments.push(CommitLineSegment::Straight {
 371                                                to_row: ending_row,
 372                                            });
 373                                        }
 374                                    } else if *to_column != final_destination {
 375                                        segments.push(CommitLineSegment::Curve {
 376                                            to_column: final_destination,
 377                                            on_row: ending_row,
 378                                            curve_kind: CurveKind::Checkout,
 379                                        });
 380                                    }
 381                                } else {
 382                                    *on_row = ending_row;
 383                                    if *to_column != final_destination {
 384                                        segments.push(CommitLineSegment::Straight {
 385                                            to_row: ending_row,
 386                                        });
 387                                        segments.push(CommitLineSegment::Curve {
 388                                            to_column: final_destination,
 389                                            on_row: ending_row,
 390                                            curve_kind: CurveKind::Checkout,
 391                                        });
 392                                    }
 393                                }
 394                            }
 395                            Some(CommitLineSegment::Curve {
 396                                on_row, to_column, ..
 397                            }) => {
 398                                if *on_row < ending_row {
 399                                    if *to_column != final_destination {
 400                                        segments.push(CommitLineSegment::Straight {
 401                                            to_row: ending_row - 1,
 402                                        });
 403                                        segments.push(CommitLineSegment::Curve {
 404                                            to_column: final_destination,
 405                                            on_row: ending_row,
 406                                            curve_kind: CurveKind::Checkout,
 407                                        });
 408                                    } else {
 409                                        segments.push(CommitLineSegment::Straight {
 410                                            to_row: ending_row,
 411                                        });
 412                                    }
 413                                } else if *to_column != final_destination {
 414                                    segments.push(CommitLineSegment::Curve {
 415                                        to_column: final_destination,
 416                                        on_row: ending_row,
 417                                        curve_kind: CurveKind::Checkout,
 418                                    });
 419                                }
 420                            }
 421                            _ => {}
 422                        }
 423
 424                        segments
 425                    },
 426                })
 427            }
 428            LaneState::Empty => None,
 429        }
 430    }
 431
 432    fn is_empty(&self) -> bool {
 433        match self {
 434            LaneState::Empty => true,
 435            LaneState::Active { .. } => false,
 436        }
 437    }
 438}
 439
 440struct CommitEntry {
 441    data: Arc<InitialGraphCommitData>,
 442    lane: usize,
 443    color_idx: usize,
 444}
 445
 446type ActiveLaneIdx = usize;
 447
 448enum AllCommitCount {
 449    NotLoaded,
 450    Loaded(usize),
 451}
 452
 453#[derive(Debug)]
 454enum CurveKind {
 455    Merge,
 456    Checkout,
 457}
 458
 459#[derive(Debug)]
 460enum CommitLineSegment {
 461    Straight {
 462        to_row: usize,
 463    },
 464    Curve {
 465        to_column: usize,
 466        on_row: usize,
 467        curve_kind: CurveKind,
 468    },
 469}
 470
 471#[derive(Debug)]
 472struct CommitLine {
 473    #[cfg(test)]
 474    child: Oid,
 475    #[cfg(test)]
 476    parent: Oid,
 477    child_column: usize,
 478    full_interval: Range<usize>,
 479    color_idx: usize,
 480    segments: SmallVec<[CommitLineSegment; 1]>,
 481}
 482
 483impl CommitLine {
 484    fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
 485        if first_visible_row > self.full_interval.end {
 486            return None;
 487        } else if first_visible_row <= self.full_interval.start {
 488            return Some((0, self.child_column));
 489        }
 490
 491        let mut current_column = self.child_column;
 492
 493        for (idx, segment) in self.segments.iter().enumerate() {
 494            match segment {
 495                CommitLineSegment::Straight { to_row } => {
 496                    if *to_row >= first_visible_row {
 497                        return Some((idx, current_column));
 498                    }
 499                }
 500                CommitLineSegment::Curve {
 501                    to_column, on_row, ..
 502                } => {
 503                    if *on_row >= first_visible_row {
 504                        return Some((idx, current_column));
 505                    }
 506                    current_column = *to_column;
 507                }
 508            }
 509        }
 510
 511        None
 512    }
 513}
 514
 515#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 516struct CommitLineKey {
 517    child: Oid,
 518    parent: Oid,
 519}
 520
 521struct GraphData {
 522    lane_states: SmallVec<[LaneState; 8]>,
 523    lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
 524    parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
 525    next_color: BranchColor,
 526    accent_colors_count: usize,
 527    commits: Vec<Rc<CommitEntry>>,
 528    max_commit_count: AllCommitCount,
 529    max_lanes: usize,
 530    lines: Vec<Rc<CommitLine>>,
 531    active_commit_lines: HashMap<CommitLineKey, usize>,
 532    active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
 533}
 534
 535impl GraphData {
 536    fn new(accent_colors_count: usize) -> Self {
 537        GraphData {
 538            lane_states: SmallVec::default(),
 539            lane_colors: HashMap::default(),
 540            parent_to_lanes: HashMap::default(),
 541            next_color: BranchColor(0),
 542            accent_colors_count,
 543            commits: Vec::default(),
 544            max_commit_count: AllCommitCount::NotLoaded,
 545            max_lanes: 0,
 546            lines: Vec::default(),
 547            active_commit_lines: HashMap::default(),
 548            active_commit_lines_by_parent: HashMap::default(),
 549        }
 550    }
 551
 552    fn clear(&mut self) {
 553        self.lane_states.clear();
 554        self.lane_colors.clear();
 555        self.parent_to_lanes.clear();
 556        self.commits.clear();
 557        self.lines.clear();
 558        self.active_commit_lines.clear();
 559        self.active_commit_lines_by_parent.clear();
 560        self.next_color = BranchColor(0);
 561        self.max_commit_count = AllCommitCount::NotLoaded;
 562        self.max_lanes = 0;
 563    }
 564
 565    fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
 566        self.lane_states
 567            .iter()
 568            .position(LaneState::is_empty)
 569            .unwrap_or_else(|| {
 570                self.lane_states.push(LaneState::Empty);
 571                self.lane_states.len() - 1
 572            })
 573    }
 574
 575    fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
 576        let accent_colors_count = self.accent_colors_count;
 577        *self.lane_colors.entry(lane_idx).or_insert_with(|| {
 578            let color_idx = self.next_color;
 579            self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
 580            color_idx
 581        })
 582    }
 583
 584    fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
 585        self.commits.reserve(commits.len());
 586        self.lines.reserve(commits.len() / 2);
 587
 588        for commit in commits.iter() {
 589            let commit_row = self.commits.len();
 590
 591            let commit_lane = self
 592                .parent_to_lanes
 593                .get(&commit.sha)
 594                .and_then(|lanes| lanes.first().copied());
 595
 596            let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
 597
 598            let commit_color = self.get_lane_color(commit_lane);
 599
 600            if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
 601                for lane_column in lanes {
 602                    let state = &mut self.lane_states[lane_column];
 603
 604                    if let LaneState::Active {
 605                        starting_row,
 606                        segments,
 607                        ..
 608                    } = state
 609                    {
 610                        if let Some(CommitLineSegment::Curve {
 611                            to_column,
 612                            curve_kind: CurveKind::Merge,
 613                            ..
 614                        }) = segments.first_mut()
 615                        {
 616                            let curve_row = *starting_row + 1;
 617                            let would_overlap =
 618                                if lane_column != commit_lane && curve_row < commit_row {
 619                                    self.commits[curve_row..commit_row]
 620                                        .iter()
 621                                        .any(|c| c.lane == commit_lane)
 622                                } else {
 623                                    false
 624                                };
 625
 626                            if would_overlap {
 627                                *to_column = lane_column;
 628                            }
 629                        }
 630                    }
 631
 632                    if let Some(commit_line) =
 633                        state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color)
 634                    {
 635                        self.lines.push(Rc::new(commit_line));
 636                    }
 637                }
 638            }
 639
 640            commit
 641                .parents
 642                .iter()
 643                .enumerate()
 644                .for_each(|(parent_idx, parent)| {
 645                    if parent_idx == 0 {
 646                        self.lane_states[commit_lane] = LaneState::Active {
 647                            parent: *parent,
 648                            child: commit.sha,
 649                            color: Some(commit_color),
 650                            starting_col: commit_lane,
 651                            starting_row: commit_row,
 652                            destination_column: None,
 653                            segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
 654                        };
 655
 656                        self.parent_to_lanes
 657                            .entry(*parent)
 658                            .or_default()
 659                            .push(commit_lane);
 660                    } else {
 661                        let new_lane = self.first_empty_lane_idx();
 662
 663                        self.lane_states[new_lane] = LaneState::Active {
 664                            parent: *parent,
 665                            child: commit.sha,
 666                            color: None,
 667                            starting_col: commit_lane,
 668                            starting_row: commit_row,
 669                            destination_column: None,
 670                            segments: smallvec![CommitLineSegment::Curve {
 671                                to_column: usize::MAX,
 672                                on_row: usize::MAX,
 673                                curve_kind: CurveKind::Merge,
 674                            },],
 675                        };
 676
 677                        self.parent_to_lanes
 678                            .entry(*parent)
 679                            .or_default()
 680                            .push(new_lane);
 681                    }
 682                });
 683
 684            self.max_lanes = self.max_lanes.max(self.lane_states.len());
 685
 686            self.commits.push(Rc::new(CommitEntry {
 687                data: commit.clone(),
 688                lane: commit_lane,
 689                color_idx: commit_color.0 as usize,
 690            }));
 691        }
 692
 693        self.max_commit_count = AllCommitCount::Loaded(self.commits.len());
 694    }
 695}
 696
 697pub fn init(cx: &mut App) {
 698    workspace::register_serializable_item::<GitGraph>(cx);
 699
 700    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
 701        workspace.register_action_renderer(|div, workspace, _, cx| {
 702            div.when(
 703                workspace.project().read(cx).active_repository(cx).is_some()
 704                    && cx.has_flag::<GitGraphFeatureFlag>(),
 705                |div| {
 706                    let workspace = workspace.weak_handle();
 707
 708                    div.on_action({
 709                        let workspace = workspace.clone();
 710                        move |_: &git_ui::git_panel::Open, window, cx| {
 711                            workspace
 712                                .update(cx, |workspace, cx| {
 713                                    let existing = workspace.items_of_type::<GitGraph>(cx).next();
 714                                    if let Some(existing) = existing {
 715                                        workspace.activate_item(&existing, true, true, window, cx);
 716                                        return;
 717                                    }
 718
 719                                    let project = workspace.project().clone();
 720                                    let workspace_handle = workspace.weak_handle();
 721                                    let git_graph = cx.new(|cx| {
 722                                        GitGraph::new(project, workspace_handle, window, cx)
 723                                    });
 724                                    workspace.add_item_to_active_pane(
 725                                        Box::new(git_graph),
 726                                        None,
 727                                        true,
 728                                        window,
 729                                        cx,
 730                                    );
 731                                })
 732                                .ok();
 733                        }
 734                    })
 735                    .on_action(
 736                        move |action: &git_ui::git_panel::OpenAtCommit, window, cx| {
 737                            let sha = action.sha.clone();
 738                            workspace
 739                                .update(cx, |workspace, cx| {
 740                                    let existing = workspace.items_of_type::<GitGraph>(cx).next();
 741                                    if let Some(existing) = existing {
 742                                        existing.update(cx, |graph, cx| {
 743                                            graph.select_commit_by_sha(&sha, cx);
 744                                        });
 745                                        workspace.activate_item(&existing, true, true, window, cx);
 746                                        return;
 747                                    }
 748
 749                                    let project = workspace.project().clone();
 750                                    let workspace_handle = workspace.weak_handle();
 751                                    let git_graph = cx.new(|cx| {
 752                                        let mut graph =
 753                                            GitGraph::new(project, workspace_handle, window, cx);
 754                                        graph.select_commit_by_sha(&sha, cx);
 755                                        graph
 756                                    });
 757                                    workspace.add_item_to_active_pane(
 758                                        Box::new(git_graph),
 759                                        None,
 760                                        true,
 761                                        window,
 762                                        cx,
 763                                    );
 764                                })
 765                                .ok();
 766                        },
 767                    )
 768                },
 769            )
 770        });
 771    })
 772    .detach();
 773}
 774
 775fn lane_center_x(bounds: Bounds<Pixels>, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels {
 776    bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset
 777}
 778
 779fn to_row_center(
 780    to_row: usize,
 781    row_height: Pixels,
 782    scroll_offset: Pixels,
 783    bounds: Bounds<Pixels>,
 784) -> Pixels {
 785    bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
 786}
 787
 788fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
 789    let radius = COMMIT_CIRCLE_RADIUS;
 790
 791    let mut builder = PathBuilder::fill();
 792
 793    // Start at the rightmost point of the circle
 794    builder.move_to(point(center_x + radius, center_y));
 795
 796    // Draw the circle using two arc_to calls (top half, then bottom half)
 797    builder.arc_to(
 798        point(radius, radius),
 799        px(0.),
 800        false,
 801        true,
 802        point(center_x - radius, center_y),
 803    );
 804    builder.arc_to(
 805        point(radius, radius),
 806        px(0.),
 807        false,
 808        true,
 809        point(center_x + radius, center_y),
 810    );
 811    builder.close();
 812
 813    if let Ok(path) = builder.build() {
 814        window.paint_path(path, color);
 815    }
 816}
 817
 818fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
 819    diff.files.iter().fold((0, 0), |(added, removed), file| {
 820        let old_text = file.old_text.as_deref().unwrap_or("");
 821        let new_text = file.new_text.as_deref().unwrap_or("");
 822        let hunks = line_diff(old_text, new_text);
 823        hunks
 824            .iter()
 825            .fold((added, removed), |(a, r), (old_range, new_range)| {
 826                (
 827                    a + (new_range.end - new_range.start) as usize,
 828                    r + (old_range.end - old_range.start) as usize,
 829                )
 830            })
 831    })
 832}
 833
 834pub struct GitGraph {
 835    focus_handle: FocusHandle,
 836    graph_data: GraphData,
 837    project: Entity<Project>,
 838    workspace: WeakEntity<Workspace>,
 839    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 840    row_height: Pixels,
 841    table_interaction_state: Entity<TableInteractionState>,
 842    table_column_widths: Entity<TableColumnWidths>,
 843    horizontal_scroll_offset: Pixels,
 844    graph_viewport_width: Pixels,
 845    selected_entry_idx: Option<usize>,
 846    hovered_entry_idx: Option<usize>,
 847    graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
 848    log_source: LogSource,
 849    log_order: LogOrder,
 850    selected_commit_diff: Option<CommitDiff>,
 851    selected_commit_diff_stats: Option<(usize, usize)>,
 852    _commit_diff_task: Option<Task<()>>,
 853    commit_details_split_state: Entity<SplitState>,
 854    selected_repo_id: Option<RepositoryId>,
 855    changed_files_scroll_handle: UniformListScrollHandle,
 856    pending_select_sha: Option<(String, usize)>,
 857}
 858
 859impl GitGraph {
 860    fn row_height(cx: &App) -> Pixels {
 861        let settings = ThemeSettings::get_global(cx);
 862        let font_size = settings.buffer_font_size(cx);
 863        font_size + px(12.0)
 864    }
 865
 866    fn graph_content_width(&self) -> Pixels {
 867        (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
 868    }
 869
 870    pub fn new(
 871        project: Entity<Project>,
 872        workspace: WeakEntity<Workspace>,
 873        window: &mut Window,
 874        cx: &mut Context<Self>,
 875    ) -> Self {
 876        let focus_handle = cx.focus_handle();
 877        cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
 878            .detach();
 879
 880        let git_store = project.read(cx).git_store().clone();
 881        let accent_colors = cx.theme().accents();
 882        let graph = GraphData::new(accent_colors_count(accent_colors));
 883        let log_source = LogSource::default();
 884        let log_order = LogOrder::default();
 885
 886        cx.subscribe(&git_store, |this, _, event, cx| match event {
 887            GitStoreEvent::RepositoryUpdated(updated_repo_id, repo_event, _) => {
 888                if this
 889                    .selected_repo_id
 890                    .as_ref()
 891                    .is_some_and(|repo_id| repo_id == updated_repo_id)
 892                {
 893                    if let Some(repository) = this.get_selected_repository(cx) {
 894                        this.on_repository_event(repository, repo_event, cx);
 895                    }
 896                }
 897            }
 898            GitStoreEvent::ActiveRepositoryChanged(changed_repo_id) => {
 899                // todo(git_graph): Make this selectable from UI so we don't have to always use active repository
 900                if this.selected_repo_id != *changed_repo_id {
 901                    this.selected_repo_id = *changed_repo_id;
 902                    this.graph_data.clear();
 903                    cx.notify();
 904                }
 905            }
 906            _ => {}
 907        })
 908        .detach();
 909
 910        let active_repository = project
 911            .read(cx)
 912            .active_repository(cx)
 913            .map(|repo| repo.read(cx).id);
 914
 915        let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
 916        let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx));
 917        let mut row_height = Self::row_height(cx);
 918
 919        cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
 920            let new_row_height = Self::row_height(cx);
 921            if new_row_height != row_height {
 922                this.row_height = new_row_height;
 923                this.table_interaction_state.update(cx, |state, _cx| {
 924                    state.scroll_handle.0.borrow_mut().last_item_size = None;
 925                });
 926                row_height = new_row_height;
 927                cx.notify();
 928            }
 929        })
 930        .detach();
 931
 932        let mut this = GitGraph {
 933            focus_handle,
 934            project,
 935            workspace,
 936            graph_data: graph,
 937            _commit_diff_task: None,
 938            context_menu: None,
 939            row_height,
 940            table_interaction_state,
 941            table_column_widths,
 942            horizontal_scroll_offset: px(0.),
 943            graph_viewport_width: px(88.),
 944            selected_entry_idx: None,
 945            hovered_entry_idx: None,
 946            graph_canvas_bounds: Rc::new(Cell::new(None)),
 947            selected_commit_diff: None,
 948            selected_commit_diff_stats: None,
 949            log_source,
 950            log_order,
 951            commit_details_split_state: cx.new(|_cx| SplitState::new()),
 952            selected_repo_id: active_repository,
 953            changed_files_scroll_handle: UniformListScrollHandle::new(),
 954            pending_select_sha: None,
 955        };
 956
 957        this.fetch_initial_graph_data(cx);
 958        this
 959    }
 960
 961    fn on_repository_event(
 962        &mut self,
 963        repository: Entity<Repository>,
 964        event: &RepositoryEvent,
 965        cx: &mut Context<Self>,
 966    ) {
 967        match event {
 968            RepositoryEvent::GitGraphCountUpdated((order, source), commit_count) => {
 969                if order != &self.log_order || source != &self.log_source {
 970                    return;
 971                }
 972
 973                let old_count = self.graph_data.commits.len();
 974
 975                repository.update(cx, |repository, cx| {
 976                    let (commits, _) =
 977                        repository.graph_data(source.clone(), *order, old_count..*commit_count, cx);
 978                    self.graph_data.add_commits(commits);
 979                });
 980                cx.notify();
 981                self.retry_pending_select(cx);
 982            }
 983            RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => {
 984                self.pending_select_sha = None;
 985                // Only invalidate if we scanned atleast once,
 986                // meaning we are not inside the initial repo loading state
 987                // NOTE: this fixes an loading performance regression
 988                if repository.read(cx).scan_id > 1 {
 989                    self.graph_data.clear();
 990                    cx.notify();
 991                }
 992            }
 993            _ => {}
 994        }
 995    }
 996
 997    fn fetch_initial_graph_data(&mut self, cx: &mut App) {
 998        if let Some(repository) = self.get_selected_repository(cx) {
 999            repository.update(cx, |repository, cx| {
1000                let (commits, _) = repository.graph_data(
1001                    self.log_source.clone(),
1002                    self.log_order,
1003                    0..usize::MAX,
1004                    cx,
1005                );
1006                self.graph_data.add_commits(commits);
1007            });
1008        }
1009    }
1010
1011    fn get_selected_repository(&self, cx: &App) -> Option<Entity<Repository>> {
1012        let project = self.project.read(cx);
1013        self.selected_repo_id
1014            .as_ref()
1015            .and_then(|repo_id| project.repositories(cx).get(&repo_id).cloned())
1016    }
1017
1018    fn render_chip(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
1019        Chip::new(name.clone())
1020            .label_size(LabelSize::Small)
1021            .bg_color(accent_color.opacity(0.1))
1022            .border_color(accent_color.opacity(0.5))
1023    }
1024
1025    fn render_table_rows(
1026        &mut self,
1027        range: Range<usize>,
1028        _window: &mut Window,
1029        cx: &mut Context<Self>,
1030    ) -> Vec<Vec<AnyElement>> {
1031        let repository = self.get_selected_repository(cx);
1032
1033        let row_height = self.row_height;
1034
1035        // We fetch data outside the visible viewport to avoid loading entries when
1036        // users scroll through the git graph
1037        if let Some(repository) = repository.as_ref() {
1038            const FETCH_RANGE: usize = 100;
1039            repository.update(cx, |repository, cx| {
1040                self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
1041                    ..(range.end + FETCH_RANGE)
1042                        .min(self.graph_data.commits.len().saturating_sub(1))]
1043                    .iter()
1044                    .for_each(|commit| {
1045                        repository.fetch_commit_data(commit.data.sha, cx);
1046                    });
1047            });
1048        }
1049
1050        range
1051            .map(|idx| {
1052                let Some((commit, repository)) =
1053                    self.graph_data.commits.get(idx).zip(repository.as_ref())
1054                else {
1055                    return vec![
1056                        div().h(row_height).into_any_element(),
1057                        div().h(row_height).into_any_element(),
1058                        div().h(row_height).into_any_element(),
1059                        div().h(row_height).into_any_element(),
1060                    ];
1061                };
1062
1063                let data = repository.update(cx, |repository, cx| {
1064                    repository.fetch_commit_data(commit.data.sha, cx).clone()
1065                });
1066
1067                let short_sha = commit.data.sha.display_short();
1068                let mut formatted_time = String::new();
1069                let subject: SharedString;
1070                let author_name: SharedString;
1071
1072                if let CommitDataState::Loaded(data) = data {
1073                    subject = data.subject.clone();
1074                    author_name = data.author_name.clone();
1075                    formatted_time = format_timestamp(data.commit_timestamp);
1076                } else {
1077                    subject = "Loading…".into();
1078                    author_name = "".into();
1079                }
1080
1081                let accent_colors = cx.theme().accents();
1082                let accent_color = accent_colors
1083                    .0
1084                    .get(commit.color_idx)
1085                    .copied()
1086                    .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1087
1088                let is_selected = self.selected_entry_idx == Some(idx);
1089                let column_label = |label: SharedString| {
1090                    Label::new(label)
1091                        .when(!is_selected, |c| c.color(Color::Muted))
1092                        .truncate()
1093                        .into_any_element()
1094                };
1095
1096                vec![
1097                    div()
1098                        .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1099                        .overflow_hidden()
1100                        .tooltip(Tooltip::text(subject.clone()))
1101                        .child(
1102                            h_flex()
1103                                .gap_2()
1104                                .overflow_hidden()
1105                                .children((!commit.data.ref_names.is_empty()).then(|| {
1106                                    h_flex().gap_1().children(
1107                                        commit
1108                                            .data
1109                                            .ref_names
1110                                            .iter()
1111                                            .map(|name| self.render_chip(name, accent_color)),
1112                                    )
1113                                }))
1114                                .child(column_label(subject)),
1115                        )
1116                        .into_any_element(),
1117                    column_label(formatted_time.into()),
1118                    column_label(author_name),
1119                    column_label(short_sha.into()),
1120                ]
1121            })
1122            .collect()
1123    }
1124
1125    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1126        self.selected_entry_idx = None;
1127        self.selected_commit_diff = None;
1128        self.selected_commit_diff_stats = None;
1129        cx.notify();
1130    }
1131
1132    fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context<Self>) {
1133        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1134            self.select_entry(selected_entry_idx.saturating_sub(1), cx);
1135        } else {
1136            self.select_entry(0, cx);
1137        }
1138    }
1139
1140    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1141        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1142            self.select_entry(selected_entry_idx.saturating_add(1), cx);
1143        } else {
1144            self.select_prev(&SelectPrevious, window, cx);
1145        }
1146    }
1147
1148    fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
1149        if self.selected_entry_idx == Some(idx) {
1150            return;
1151        }
1152
1153        self.selected_entry_idx = Some(idx);
1154        self.selected_commit_diff = None;
1155        self.selected_commit_diff_stats = None;
1156        self.changed_files_scroll_handle
1157            .scroll_to_item(0, ScrollStrategy::Top);
1158        self.table_interaction_state.update(cx, |state, cx| {
1159            state
1160                .scroll_handle
1161                .scroll_to_item(idx, ScrollStrategy::Nearest);
1162            cx.notify();
1163        });
1164
1165        let Some(commit) = self.graph_data.commits.get(idx) else {
1166            return;
1167        };
1168
1169        let sha = commit.data.sha.to_string();
1170
1171        let Some(repository) = self.get_selected_repository(cx) else {
1172            return;
1173        };
1174
1175        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1176
1177        self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1178            if let Ok(Ok(diff)) = diff_receiver.await {
1179                this.update(cx, |this, cx| {
1180                    let stats = compute_diff_stats(&diff);
1181                    this.selected_commit_diff = Some(diff);
1182                    this.selected_commit_diff_stats = Some(stats);
1183                    cx.notify();
1184                })
1185                .ok();
1186            }
1187        }));
1188
1189        cx.notify();
1190    }
1191
1192    pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context<Self>) {
1193        let Ok(oid) = sha.parse::<Oid>() else {
1194            return;
1195        };
1196        for (idx, commit) in self.graph_data.commits.iter().enumerate() {
1197            if commit.data.sha == oid {
1198                self.pending_select_sha = None;
1199                self.select_entry(idx, cx);
1200                return;
1201            }
1202        }
1203        self.pending_select_sha = Some((sha.to_string(), PENDING_SELECT_MAX_RETRIES));
1204    }
1205
1206    fn retry_pending_select(&mut self, cx: &mut Context<Self>) {
1207        let Some((sha, retries_remaining)) = self.pending_select_sha.take() else {
1208            return;
1209        };
1210        if let Ok(oid) = sha.parse::<Oid>() {
1211            for (idx, commit) in self.graph_data.commits.iter().enumerate() {
1212                if commit.data.sha == oid {
1213                    self.select_entry(idx, cx);
1214                    return;
1215                }
1216            }
1217        }
1218        if retries_remaining > 0 {
1219            self.pending_select_sha = Some((sha, retries_remaining - 1));
1220        }
1221    }
1222
1223    fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1224        let Some(selected_entry_index) = self.selected_entry_idx else {
1225            return;
1226        };
1227
1228        self.open_commit_view(selected_entry_index, window, cx);
1229    }
1230
1231    fn open_commit_view(
1232        &mut self,
1233        entry_index: usize,
1234        window: &mut Window,
1235        cx: &mut Context<Self>,
1236    ) {
1237        let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1238            return;
1239        };
1240
1241        let Some(repository) = self.get_selected_repository(cx) else {
1242            return;
1243        };
1244
1245        CommitView::open(
1246            commit_entry.data.sha.to_string(),
1247            repository.downgrade(),
1248            self.workspace.clone(),
1249            None,
1250            None,
1251            window,
1252            cx,
1253        );
1254    }
1255
1256    fn get_remote(
1257        &self,
1258        repository: &Repository,
1259        _window: &mut Window,
1260        cx: &mut App,
1261    ) -> Option<GitRemote> {
1262        let remote_url = repository.default_remote_url()?;
1263        let provider_registry = GitHostingProviderRegistry::default_global(cx);
1264        let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1265        Some(GitRemote {
1266            host: provider,
1267            owner: parsed.owner.into(),
1268            repo: parsed.repo.into(),
1269        })
1270    }
1271
1272    fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1273        let rems = TextSize::Large.rems(cx);
1274        Icon::new(IconName::LoadCircle)
1275            .size(IconSize::Custom(rems))
1276            .color(Color::Accent)
1277            .with_rotate_animation(3)
1278            .into_any_element()
1279    }
1280
1281    fn render_commit_detail_panel(
1282        &self,
1283        window: &mut Window,
1284        cx: &mut Context<Self>,
1285    ) -> impl IntoElement {
1286        let Some(selected_idx) = self.selected_entry_idx else {
1287            return Empty.into_any_element();
1288        };
1289
1290        let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1291            return Empty.into_any_element();
1292        };
1293
1294        let Some(repository) = self.get_selected_repository(cx) else {
1295            return Empty.into_any_element();
1296        };
1297
1298        let data = repository.update(cx, |repository, cx| {
1299            repository
1300                .fetch_commit_data(commit_entry.data.sha, cx)
1301                .clone()
1302        });
1303
1304        let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1305        let ref_names = commit_entry.data.ref_names.clone();
1306
1307        let accent_colors = cx.theme().accents();
1308        let accent_color = accent_colors
1309            .0
1310            .get(commit_entry.color_idx)
1311            .copied()
1312            .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1313
1314        let (author_name, author_email, commit_timestamp, subject) = match &data {
1315            CommitDataState::Loaded(data) => (
1316                data.author_name.clone(),
1317                data.author_email.clone(),
1318                Some(data.commit_timestamp),
1319                data.subject.clone(),
1320            ),
1321            CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
1322        };
1323
1324        let date_string = commit_timestamp
1325            .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1326            .map(|datetime| {
1327                let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1328                let local_datetime = datetime.to_offset(local_offset);
1329                let format =
1330                    time::format_description::parse("[month repr:short] [day], [year]").ok();
1331                format
1332                    .and_then(|f| local_datetime.format(&f).ok())
1333                    .unwrap_or_default()
1334            })
1335            .unwrap_or_default();
1336
1337        let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1338
1339        let avatar = {
1340            let author_email_for_avatar = if author_email.is_empty() {
1341                None
1342            } else {
1343                Some(author_email.clone())
1344            };
1345
1346            CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
1347                .size(px(40.))
1348                .render(window, cx)
1349        };
1350
1351        let changed_files_count = self
1352            .selected_commit_diff
1353            .as_ref()
1354            .map(|diff| diff.files.len())
1355            .unwrap_or(0);
1356
1357        let (total_lines_added, total_lines_removed) =
1358            self.selected_commit_diff_stats.unwrap_or((0, 0));
1359
1360        let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1361            self.selected_commit_diff
1362                .as_ref()
1363                .map(|diff| {
1364                    let mut files: Vec<_> = diff.files.iter().collect();
1365                    files.sort_by_key(|file| file.status());
1366                    files
1367                        .into_iter()
1368                        .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1369                        .collect()
1370                })
1371                .unwrap_or_default(),
1372        );
1373
1374        v_flex()
1375            .min_w(px(300.))
1376            .h_full()
1377            .bg(cx.theme().colors().surface_background)
1378            .flex_basis(DefiniteLength::Fraction(
1379                self.commit_details_split_state.read(cx).right_ratio(),
1380            ))
1381            .child(
1382                v_flex()
1383                    .relative()
1384                    .w_full()
1385                    .p_2()
1386                    .gap_2()
1387                    .child(
1388                        div().absolute().top_2().right_2().child(
1389                            IconButton::new("close-detail", IconName::Close)
1390                                .icon_size(IconSize::Small)
1391                                .on_click(cx.listener(move |this, _, _, cx| {
1392                                    this.selected_entry_idx = None;
1393                                    this.selected_commit_diff = None;
1394                                    this.selected_commit_diff_stats = None;
1395                                    this._commit_diff_task = None;
1396                                    cx.notify();
1397                                })),
1398                        ),
1399                    )
1400                    .child(
1401                        v_flex()
1402                            .py_1()
1403                            .w_full()
1404                            .items_center()
1405                            .gap_1()
1406                            .child(avatar)
1407                            .child(
1408                                v_flex()
1409                                    .items_center()
1410                                    .child(Label::new(author_name))
1411                                    .child(
1412                                        Label::new(date_string)
1413                                            .color(Color::Muted)
1414                                            .size(LabelSize::Small),
1415                                    ),
1416                            ),
1417                    )
1418                    .children((!ref_names.is_empty()).then(|| {
1419                        h_flex().gap_1().flex_wrap().justify_center().children(
1420                            ref_names
1421                                .iter()
1422                                .map(|name| self.render_chip(name, accent_color)),
1423                        )
1424                    }))
1425                    .child(
1426                        v_flex()
1427                            .ml_neg_1()
1428                            .gap_1p5()
1429                            .when(!author_email.is_empty(), |this| {
1430                                let copied_state: Entity<CopiedState> = window.use_keyed_state(
1431                                    "author-email-copy",
1432                                    cx,
1433                                    CopiedState::new,
1434                                );
1435                                let is_copied = copied_state.read(cx).is_copied();
1436
1437                                let (icon, icon_color, tooltip_label) = if is_copied {
1438                                    (IconName::Check, Color::Success, "Email Copied!")
1439                                } else {
1440                                    (IconName::Envelope, Color::Muted, "Copy Email")
1441                                };
1442
1443                                let copy_email = author_email.clone();
1444                                let author_email_for_tooltip = author_email.clone();
1445
1446                                this.child(
1447                                    Button::new("author-email-copy", author_email.clone())
1448                                        .icon(icon)
1449                                        .icon_size(IconSize::Small)
1450                                        .icon_color(icon_color)
1451                                        .icon_position(IconPosition::Start)
1452                                        .label_size(LabelSize::Small)
1453                                        .truncate(true)
1454                                        .color(Color::Muted)
1455                                        .tooltip(move |_, cx| {
1456                                            Tooltip::with_meta(
1457                                                tooltip_label,
1458                                                None,
1459                                                author_email_for_tooltip.clone(),
1460                                                cx,
1461                                            )
1462                                        })
1463                                        .on_click(move |_, _, cx| {
1464                                            copied_state.update(cx, |state, _cx| {
1465                                                state.mark_copied();
1466                                            });
1467                                            cx.write_to_clipboard(ClipboardItem::new_string(
1468                                                copy_email.to_string(),
1469                                            ));
1470                                            let state_id = copied_state.entity_id();
1471                                            cx.spawn(async move |cx| {
1472                                                cx.background_executor()
1473                                                    .timer(COPIED_STATE_DURATION)
1474                                                    .await;
1475                                                cx.update(|cx| {
1476                                                    cx.notify(state_id);
1477                                                })
1478                                            })
1479                                            .detach();
1480                                        }),
1481                                )
1482                            })
1483                            .child({
1484                                let copy_sha = full_sha.clone();
1485                                let copied_state: Entity<CopiedState> =
1486                                    window.use_keyed_state("sha-copy", cx, CopiedState::new);
1487                                let is_copied = copied_state.read(cx).is_copied();
1488
1489                                let (icon, icon_color, tooltip_label) = if is_copied {
1490                                    (IconName::Check, Color::Success, "Commit SHA Copied!")
1491                                } else {
1492                                    (IconName::Hash, Color::Muted, "Copy Commit SHA")
1493                                };
1494
1495                                Button::new("sha-button", &full_sha)
1496                                    .icon(icon)
1497                                    .icon_size(IconSize::Small)
1498                                    .icon_color(icon_color)
1499                                    .icon_position(IconPosition::Start)
1500                                    .label_size(LabelSize::Small)
1501                                    .truncate(true)
1502                                    .color(Color::Muted)
1503                                    .tooltip({
1504                                        let full_sha = full_sha.clone();
1505                                        move |_, cx| {
1506                                            Tooltip::with_meta(
1507                                                tooltip_label,
1508                                                None,
1509                                                full_sha.clone(),
1510                                                cx,
1511                                            )
1512                                        }
1513                                    })
1514                                    .on_click(move |_, _, cx| {
1515                                        copied_state.update(cx, |state, _cx| {
1516                                            state.mark_copied();
1517                                        });
1518                                        cx.write_to_clipboard(ClipboardItem::new_string(
1519                                            copy_sha.to_string(),
1520                                        ));
1521                                        let state_id = copied_state.entity_id();
1522                                        cx.spawn(async move |cx| {
1523                                            cx.background_executor()
1524                                                .timer(COPIED_STATE_DURATION)
1525                                                .await;
1526                                            cx.update(|cx| {
1527                                                cx.notify(state_id);
1528                                            })
1529                                        })
1530                                        .detach();
1531                                    })
1532                            })
1533                            .when_some(remote.clone(), |this, remote| {
1534                                let provider_name = remote.host.name();
1535                                let icon = match provider_name.as_str() {
1536                                    "GitHub" => IconName::Github,
1537                                    _ => IconName::Link,
1538                                };
1539                                let parsed_remote = ParsedGitRemote {
1540                                    owner: remote.owner.as_ref().into(),
1541                                    repo: remote.repo.as_ref().into(),
1542                                };
1543                                let params = BuildCommitPermalinkParams {
1544                                    sha: full_sha.as_ref(),
1545                                };
1546                                let url = remote
1547                                    .host
1548                                    .build_commit_permalink(&parsed_remote, params)
1549                                    .to_string();
1550
1551                                this.child(
1552                                    Button::new(
1553                                        "view-on-provider",
1554                                        format!("View on {}", provider_name),
1555                                    )
1556                                    .icon(icon)
1557                                    .icon_size(IconSize::Small)
1558                                    .icon_color(Color::Muted)
1559                                    .icon_position(IconPosition::Start)
1560                                    .label_size(LabelSize::Small)
1561                                    .truncate(true)
1562                                    .color(Color::Muted)
1563                                    .on_click(
1564                                        move |_, _, cx| {
1565                                            cx.open_url(&url);
1566                                        },
1567                                    ),
1568                                )
1569                            }),
1570                    ),
1571            )
1572            .child(Divider::horizontal())
1573            .child(div().min_w_0().p_2().child(Label::new(subject)))
1574            .child(Divider::horizontal())
1575            .child(
1576                v_flex()
1577                    .min_w_0()
1578                    .p_2()
1579                    .flex_1()
1580                    .gap_1()
1581                    .child(
1582                        h_flex()
1583                            .gap_1()
1584                            .child(
1585                                Label::new(format!("{} Changed Files", changed_files_count))
1586                                    .size(LabelSize::Small)
1587                                    .color(Color::Muted),
1588                            )
1589                            .child(DiffStat::new(
1590                                "commit-diff-stat",
1591                                total_lines_added,
1592                                total_lines_removed,
1593                            )),
1594                    )
1595                    .child(
1596                        div()
1597                            .id("changed-files-container")
1598                            .flex_1()
1599                            .min_h_0()
1600                            .child({
1601                                let entries = sorted_file_entries;
1602                                let entry_count = entries.len();
1603                                let commit_sha = full_sha.clone();
1604                                let repository = repository.downgrade();
1605                                let workspace = self.workspace.clone();
1606                                uniform_list(
1607                                    "changed-files-list",
1608                                    entry_count,
1609                                    move |range, _window, cx| {
1610                                        range
1611                                            .map(|ix| {
1612                                                entries[ix].render(
1613                                                    ix,
1614                                                    commit_sha.clone(),
1615                                                    repository.clone(),
1616                                                    workspace.clone(),
1617                                                    cx,
1618                                                )
1619                                            })
1620                                            .collect()
1621                                    },
1622                                )
1623                                .size_full()
1624                                .ml_neg_1()
1625                                .track_scroll(&self.changed_files_scroll_handle)
1626                            })
1627                            .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
1628                    ),
1629            )
1630            .child(Divider::horizontal())
1631            .child(
1632                h_flex().p_1p5().w_full().child(
1633                    Button::new("view-commit", "View Commit")
1634                        .full_width()
1635                        .style(ButtonStyle::Outlined)
1636                        .on_click(cx.listener(|this, _, window, cx| {
1637                            this.open_selected_commit_view(window, cx);
1638                        })),
1639                ),
1640            )
1641            .into_any_element()
1642    }
1643
1644    pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
1645        let row_height = self.row_height;
1646        let table_state = self.table_interaction_state.read(cx);
1647        let viewport_height = table_state
1648            .scroll_handle
1649            .0
1650            .borrow()
1651            .last_item_size
1652            .map(|size| size.item.height)
1653            .unwrap_or(px(600.0));
1654        let loaded_commit_count = self.graph_data.commits.len();
1655
1656        let content_height = row_height * loaded_commit_count;
1657        let max_scroll = (content_height - viewport_height).max(px(0.));
1658        let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
1659
1660        let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
1661        let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
1662        let horizontal_scroll_offset = self.horizontal_scroll_offset;
1663
1664        let max_lanes = self.graph_data.max_lanes.max(6);
1665        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1666        let last_visible_row =
1667            first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
1668
1669        let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
1670            ..(last_visible_row).min(loaded_commit_count);
1671        let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
1672        let commit_lines: Vec<_> = self
1673            .graph_data
1674            .lines
1675            .iter()
1676            .filter(|line| {
1677                line.full_interval.start <= viewport_range.end
1678                    && line.full_interval.end >= viewport_range.start
1679            })
1680            .cloned()
1681            .collect();
1682
1683        let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
1684
1685        let hovered_entry_idx = self.hovered_entry_idx;
1686        let selected_entry_idx = self.selected_entry_idx;
1687        let is_focused = self.focus_handle.is_focused(window);
1688        let graph_canvas_bounds = self.graph_canvas_bounds.clone();
1689
1690        gpui::canvas(
1691            move |_bounds, _window, _cx| {},
1692            move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
1693                graph_canvas_bounds.set(Some(bounds));
1694
1695                window.paint_layer(bounds, |window| {
1696                    let accent_colors = cx.theme().accents();
1697
1698                    let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
1699                    let selected_bg = if is_focused {
1700                        cx.theme().colors().element_selected
1701                    } else {
1702                        cx.theme().colors().element_hover
1703                    };
1704
1705                    for visible_row_idx in 0..rows.len() {
1706                        let absolute_row_idx = first_visible_row + visible_row_idx;
1707                        let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
1708                        let is_selected = selected_entry_idx == Some(absolute_row_idx);
1709
1710                        if is_hovered || is_selected {
1711                            let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
1712                                - vertical_scroll_offset;
1713
1714                            let row_bounds = Bounds::new(
1715                                point(bounds.origin.x, row_y),
1716                                gpui::Size {
1717                                    width: bounds.size.width,
1718                                    height: row_height,
1719                                },
1720                            );
1721
1722                            let bg_color = if is_selected { selected_bg } else { hover_bg };
1723                            window.paint_quad(gpui::fill(row_bounds, bg_color));
1724                        }
1725                    }
1726
1727                    for (row_idx, row) in rows.into_iter().enumerate() {
1728                        let row_color = accent_colors.color_for_index(row.color_idx as u32);
1729                        let row_y_center =
1730                            bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
1731                                - vertical_scroll_offset;
1732
1733                        let commit_x =
1734                            lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
1735
1736                        draw_commit_circle(commit_x, row_y_center, row_color, window);
1737                    }
1738
1739                    for line in commit_lines {
1740                        let Some((start_segment_idx, start_column)) =
1741                            line.get_first_visible_segment_idx(first_visible_row)
1742                        else {
1743                            continue;
1744                        };
1745
1746                        let line_x =
1747                            lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
1748
1749                        let start_row = line.full_interval.start as i32 - first_visible_row as i32;
1750
1751                        let from_y =
1752                            bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
1753                                - vertical_scroll_offset
1754                                + COMMIT_CIRCLE_RADIUS;
1755
1756                        let mut current_row = from_y;
1757                        let mut current_column = line_x;
1758
1759                        let mut builder = PathBuilder::stroke(LINE_WIDTH);
1760                        builder.move_to(point(line_x, from_y));
1761
1762                        let segments = &line.segments[start_segment_idx..];
1763
1764                        for (segment_idx, segment) in segments.iter().enumerate() {
1765                            let is_last = segment_idx + 1 == segments.len();
1766
1767                            match segment {
1768                                CommitLineSegment::Straight { to_row } => {
1769                                    let mut dest_row = to_row_center(
1770                                        to_row - first_visible_row,
1771                                        row_height,
1772                                        vertical_scroll_offset,
1773                                        bounds,
1774                                    );
1775                                    if is_last {
1776                                        dest_row -= COMMIT_CIRCLE_RADIUS;
1777                                    }
1778
1779                                    let dest_point = point(current_column, dest_row);
1780
1781                                    current_row = dest_point.y;
1782                                    builder.line_to(dest_point);
1783                                    builder.move_to(dest_point);
1784                                }
1785                                CommitLineSegment::Curve {
1786                                    to_column,
1787                                    on_row,
1788                                    curve_kind,
1789                                } => {
1790                                    let mut to_column = lane_center_x(
1791                                        bounds,
1792                                        *to_column as f32,
1793                                        horizontal_scroll_offset,
1794                                    );
1795
1796                                    let mut to_row = to_row_center(
1797                                        *on_row - first_visible_row,
1798                                        row_height,
1799                                        vertical_scroll_offset,
1800                                        bounds,
1801                                    );
1802
1803                                    // This means that this branch was a checkout
1804                                    let going_right = to_column > current_column;
1805                                    let column_shift = if going_right {
1806                                        COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
1807                                    } else {
1808                                        -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
1809                                    };
1810
1811                                    let control = match curve_kind {
1812                                        CurveKind::Checkout => {
1813                                            if is_last {
1814                                                to_column -= column_shift;
1815                                            }
1816                                            builder.move_to(point(current_column, current_row));
1817                                            point(current_column, to_row)
1818                                        }
1819                                        CurveKind::Merge => {
1820                                            if is_last {
1821                                                to_row -= COMMIT_CIRCLE_RADIUS;
1822                                            }
1823                                            builder.move_to(point(
1824                                                current_column + column_shift,
1825                                                current_row - COMMIT_CIRCLE_RADIUS,
1826                                            ));
1827                                            point(to_column, current_row)
1828                                        }
1829                                    };
1830
1831                                    match curve_kind {
1832                                        CurveKind::Checkout
1833                                            if (to_row - current_row).abs() > row_height =>
1834                                        {
1835                                            let start_curve =
1836                                                point(current_column, current_row + row_height);
1837                                            builder.line_to(start_curve);
1838                                            builder.move_to(start_curve);
1839                                        }
1840                                        CurveKind::Merge
1841                                            if (to_column - current_column).abs() > LANE_WIDTH =>
1842                                        {
1843                                            let column_shift =
1844                                                if going_right { LANE_WIDTH } else { -LANE_WIDTH };
1845
1846                                            let start_curve = point(
1847                                                current_column + column_shift,
1848                                                current_row - COMMIT_CIRCLE_RADIUS,
1849                                            );
1850
1851                                            builder.line_to(start_curve);
1852                                            builder.move_to(start_curve);
1853                                        }
1854                                        _ => {}
1855                                    };
1856
1857                                    builder.curve_to(point(to_column, to_row), control);
1858                                    current_row = to_row;
1859                                    current_column = to_column;
1860                                    builder.move_to(point(current_column, current_row));
1861                                }
1862                            }
1863                        }
1864
1865                        builder.close();
1866                        lines.entry(line.color_idx).or_default().push(builder);
1867                    }
1868
1869                    for (color_idx, builders) in lines {
1870                        let line_color = accent_colors.color_for_index(color_idx as u32);
1871
1872                        for builder in builders {
1873                            if let Ok(path) = builder.build() {
1874                                // we paint each color on it's own layer to stop overlapping lines
1875                                // of different colors changing the color of a line
1876                                window.paint_layer(bounds, |window| {
1877                                    window.paint_path(path, line_color);
1878                                });
1879                            }
1880                        }
1881                    }
1882                })
1883            },
1884        )
1885        .w(graph_width)
1886        .h_full()
1887    }
1888
1889    fn row_at_position(&self, position_y: Pixels, cx: &Context<Self>) -> Option<usize> {
1890        let canvas_bounds = self.graph_canvas_bounds.get()?;
1891        let table_state = self.table_interaction_state.read(cx);
1892        let scroll_offset_y = -table_state.scroll_offset().y;
1893
1894        let local_y = position_y - canvas_bounds.origin.y;
1895
1896        if local_y >= px(0.) && local_y < canvas_bounds.size.height {
1897            let row_in_viewport = (local_y / self.row_height).floor() as usize;
1898            let scroll_rows = (scroll_offset_y / self.row_height).floor() as usize;
1899            let absolute_row = scroll_rows + row_in_viewport;
1900
1901            if absolute_row < self.graph_data.commits.len() {
1902                return Some(absolute_row);
1903            }
1904        }
1905
1906        None
1907    }
1908
1909    fn handle_graph_mouse_move(
1910        &mut self,
1911        event: &gpui::MouseMoveEvent,
1912        _window: &mut Window,
1913        cx: &mut Context<Self>,
1914    ) {
1915        if let Some(row) = self.row_at_position(event.position.y, cx) {
1916            if self.hovered_entry_idx != Some(row) {
1917                self.hovered_entry_idx = Some(row);
1918                cx.notify();
1919            }
1920        } else if self.hovered_entry_idx.is_some() {
1921            self.hovered_entry_idx = None;
1922            cx.notify();
1923        }
1924    }
1925
1926    fn handle_graph_click(
1927        &mut self,
1928        event: &ClickEvent,
1929        window: &mut Window,
1930        cx: &mut Context<Self>,
1931    ) {
1932        if let Some(row) = self.row_at_position(event.position().y, cx) {
1933            self.select_entry(row, cx);
1934            if event.click_count() >= 2 {
1935                self.open_commit_view(row, window, cx);
1936            }
1937        }
1938    }
1939
1940    fn handle_graph_scroll(
1941        &mut self,
1942        event: &ScrollWheelEvent,
1943        window: &mut Window,
1944        cx: &mut Context<Self>,
1945    ) {
1946        let line_height = window.line_height();
1947        let delta = event.delta.pixel_delta(line_height);
1948
1949        let table_state = self.table_interaction_state.read(cx);
1950        let current_offset = table_state.scroll_offset();
1951
1952        let viewport_height = table_state.scroll_handle.viewport().size.height;
1953
1954        let commit_count = match self.graph_data.max_commit_count {
1955            AllCommitCount::Loaded(count) => count,
1956            AllCommitCount::NotLoaded => self.graph_data.commits.len(),
1957        };
1958        let content_height = self.row_height * commit_count;
1959        let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
1960
1961        let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
1962        let new_offset = Point::new(current_offset.x, new_y);
1963
1964        let max_lanes = self.graph_data.max_lanes.max(1);
1965        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1966        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
1967
1968        let new_horizontal_offset =
1969            (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
1970
1971        let vertical_changed = new_offset != current_offset;
1972        let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
1973
1974        if vertical_changed {
1975            table_state.set_scroll_offset(new_offset);
1976        }
1977
1978        if horizontal_changed {
1979            self.horizontal_scroll_offset = new_horizontal_offset;
1980        }
1981
1982        if vertical_changed || horizontal_changed {
1983            cx.notify();
1984        }
1985    }
1986
1987    fn render_commit_view_resize_handle(
1988        &self,
1989        _window: &mut Window,
1990        cx: &mut Context<Self>,
1991    ) -> AnyElement {
1992        div()
1993            .id("commit-view-split-resize-container")
1994            .relative()
1995            .h_full()
1996            .flex_shrink_0()
1997            .w(px(1.))
1998            .bg(cx.theme().colors().border_variant)
1999            .child(
2000                div()
2001                    .id("commit-view-split-resize-handle")
2002                    .absolute()
2003                    .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
2004                    .w(px(RESIZE_HANDLE_WIDTH))
2005                    .h_full()
2006                    .cursor_col_resize()
2007                    .block_mouse_except_scroll()
2008                    .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
2009                        if event.click_count() >= 2 {
2010                            this.commit_details_split_state.update(cx, |state, _| {
2011                                state.on_double_click();
2012                            });
2013                        }
2014                        cx.stop_propagation();
2015                    }))
2016                    .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
2017            )
2018            .into_any_element()
2019    }
2020}
2021
2022impl Render for GitGraph {
2023    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2024        let description_width_fraction = 0.72;
2025        let date_width_fraction = 0.12;
2026        let author_width_fraction = 0.10;
2027        let commit_width_fraction = 0.06;
2028
2029        let (commit_count, is_loading) = match self.graph_data.max_commit_count {
2030            AllCommitCount::Loaded(count) => (count, true),
2031            AllCommitCount::NotLoaded => {
2032                let (commit_count, is_loading) =
2033                    if let Some(repository) = self.get_selected_repository(cx) {
2034                        repository.update(cx, |repository, cx| {
2035                            // Start loading the graph data if we haven't started already
2036                            let (commits, is_loading) = repository.graph_data(
2037                                self.log_source.clone(),
2038                                self.log_order,
2039                                0..usize::MAX,
2040                                cx,
2041                            );
2042                            self.graph_data.add_commits(&commits);
2043                            (commits.len(), is_loading)
2044                        })
2045                    } else {
2046                        (0, false)
2047                    };
2048
2049                (commit_count, is_loading)
2050            }
2051        };
2052
2053        let content = if commit_count == 0 {
2054            let message = if is_loading {
2055                "Loading"
2056            } else {
2057                "No commits found"
2058            };
2059            let label = Label::new(message)
2060                .color(Color::Muted)
2061                .size(LabelSize::Large);
2062            div()
2063                .size_full()
2064                .h_flex()
2065                .gap_1()
2066                .items_center()
2067                .justify_center()
2068                .child(label)
2069                .when(is_loading, |this| {
2070                    this.child(self.render_loading_spinner(cx))
2071                })
2072        } else {
2073            div()
2074                .size_full()
2075                .flex()
2076                .flex_row()
2077                .child(
2078                    div()
2079                        .w(self.graph_content_width())
2080                        .h_full()
2081                        .flex()
2082                        .flex_col()
2083                        .child(
2084                            div()
2085                                .p_2()
2086                                .border_b_1()
2087                                .whitespace_nowrap()
2088                                .border_color(cx.theme().colors().border)
2089                                .child(Label::new("Graph").color(Color::Muted)),
2090                        )
2091                        .child(
2092                            div()
2093                                .id("graph-canvas")
2094                                .flex_1()
2095                                .overflow_hidden()
2096                                .child(self.render_graph(window, cx))
2097                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
2098                                .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
2099                                .on_click(cx.listener(Self::handle_graph_click))
2100                                .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
2101                                    if !is_hovered && this.hovered_entry_idx.is_some() {
2102                                        this.hovered_entry_idx = None;
2103                                        cx.notify();
2104                                    }
2105                                })),
2106                        ),
2107                )
2108                .child({
2109                    let row_height = self.row_height;
2110                    let selected_entry_idx = self.selected_entry_idx;
2111                    let hovered_entry_idx = self.hovered_entry_idx;
2112                    let weak_self = cx.weak_entity();
2113                    let focus_handle = self.focus_handle.clone();
2114                    div().flex_1().size_full().child(
2115                        Table::new(4)
2116                            .interactable(&self.table_interaction_state)
2117                            .hide_row_borders()
2118                            .hide_row_hover()
2119                            .header(vec![
2120                                Label::new("Description")
2121                                    .color(Color::Muted)
2122                                    .into_any_element(),
2123                                Label::new("Date").color(Color::Muted).into_any_element(),
2124                                Label::new("Author").color(Color::Muted).into_any_element(),
2125                                Label::new("Commit").color(Color::Muted).into_any_element(),
2126                            ])
2127                            .column_widths(
2128                                [
2129                                    DefiniteLength::Fraction(description_width_fraction),
2130                                    DefiniteLength::Fraction(date_width_fraction),
2131                                    DefiniteLength::Fraction(author_width_fraction),
2132                                    DefiniteLength::Fraction(commit_width_fraction),
2133                                ]
2134                                .to_vec(),
2135                            )
2136                            .resizable_columns(
2137                                vec![
2138                                    TableResizeBehavior::Resizable,
2139                                    TableResizeBehavior::Resizable,
2140                                    TableResizeBehavior::Resizable,
2141                                    TableResizeBehavior::Resizable,
2142                                ],
2143                                &self.table_column_widths,
2144                                cx,
2145                            )
2146                            .map_row(move |(index, row), window, cx| {
2147                                let is_selected = selected_entry_idx == Some(index);
2148                                let is_hovered = hovered_entry_idx == Some(index);
2149                                let is_focused = focus_handle.is_focused(window);
2150                                let weak = weak_self.clone();
2151                                let weak_for_hover = weak.clone();
2152
2153                                let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
2154                                let selected_bg = if is_focused {
2155                                    cx.theme().colors().element_selected
2156                                } else {
2157                                    cx.theme().colors().element_hover
2158                                };
2159
2160                                row.h(row_height)
2161                                    .when(is_selected, |row| row.bg(selected_bg))
2162                                    .when(is_hovered && !is_selected, |row| row.bg(hover_bg))
2163                                    .on_hover(move |&is_hovered, _, cx| {
2164                                        weak_for_hover
2165                                            .update(cx, |this, cx| {
2166                                                if is_hovered {
2167                                                    if this.hovered_entry_idx != Some(index) {
2168                                                        this.hovered_entry_idx = Some(index);
2169                                                        cx.notify();
2170                                                    }
2171                                                } else if this.hovered_entry_idx == Some(index) {
2172                                                    // Only clear if this row was the hovered one
2173                                                    this.hovered_entry_idx = None;
2174                                                    cx.notify();
2175                                                }
2176                                            })
2177                                            .ok();
2178                                    })
2179                                    .on_click(move |event, window, cx| {
2180                                        let click_count = event.click_count();
2181                                        weak.update(cx, |this, cx| {
2182                                            this.select_entry(index, cx);
2183                                            if click_count >= 2 {
2184                                                this.open_commit_view(index, window, cx);
2185                                            }
2186                                        })
2187                                        .ok();
2188                                    })
2189                                    .into_any_element()
2190                            })
2191                            .uniform_list(
2192                                "git-graph-commits",
2193                                commit_count,
2194                                cx.processor(Self::render_table_rows),
2195                            ),
2196                    )
2197                })
2198                .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
2199                    this.commit_details_split_state.update(cx, |state, cx| {
2200                        state.on_drag_move(event, window, cx);
2201                    });
2202                }))
2203                .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
2204                    this.commit_details_split_state.update(cx, |state, _cx| {
2205                        state.commit_ratio();
2206                    });
2207                }))
2208                .when(self.selected_entry_idx.is_some(), |this| {
2209                    this.child(self.render_commit_view_resize_handle(window, cx))
2210                        .child(self.render_commit_detail_panel(window, cx))
2211                })
2212        };
2213
2214        div()
2215            .size_full()
2216            .bg(cx.theme().colors().editor_background)
2217            .key_context("GitGraph")
2218            .track_focus(&self.focus_handle)
2219            .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
2220                this.open_selected_commit_view(window, cx);
2221            }))
2222            .on_action(cx.listener(Self::cancel))
2223            .on_action(cx.listener(Self::select_prev))
2224            .on_action(cx.listener(Self::select_next))
2225            .child(content)
2226            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2227                deferred(
2228                    anchored()
2229                        .position(*position)
2230                        .anchor(Corner::TopLeft)
2231                        .child(menu.clone()),
2232                )
2233                .with_priority(1)
2234            }))
2235    }
2236}
2237
2238impl EventEmitter<ItemEvent> for GitGraph {}
2239
2240impl Focusable for GitGraph {
2241    fn focus_handle(&self, _cx: &App) -> FocusHandle {
2242        self.focus_handle.clone()
2243    }
2244}
2245
2246impl Item for GitGraph {
2247    type Event = ItemEvent;
2248
2249    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
2250        Some(Icon::new(IconName::GitGraph))
2251    }
2252
2253    fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
2254        let repo_name = self.get_selected_repository(cx).and_then(|repo| {
2255            repo.read(cx)
2256                .work_directory_abs_path
2257                .file_name()
2258                .map(|name| name.to_string_lossy().to_string())
2259        });
2260
2261        Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
2262            move |_, _| {
2263                v_flex()
2264                    .child(Label::new("Git Graph"))
2265                    .when_some(repo_name.clone(), |this, name| {
2266                        this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
2267                    })
2268                    .into_any_element()
2269            }
2270        }))))
2271    }
2272
2273    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
2274        "Git Graph".into()
2275    }
2276
2277    fn show_toolbar(&self) -> bool {
2278        false
2279    }
2280
2281    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
2282        f(*event)
2283    }
2284}
2285
2286impl SerializableItem for GitGraph {
2287    fn serialized_item_kind() -> &'static str {
2288        "GitGraph"
2289    }
2290
2291    fn cleanup(
2292        workspace_id: workspace::WorkspaceId,
2293        alive_items: Vec<workspace::ItemId>,
2294        _window: &mut Window,
2295        cx: &mut App,
2296    ) -> Task<gpui::Result<()>> {
2297        workspace::delete_unloaded_items(
2298            alive_items,
2299            workspace_id,
2300            "git_graphs",
2301            &persistence::GIT_GRAPHS,
2302            cx,
2303        )
2304    }
2305
2306    fn deserialize(
2307        project: Entity<Project>,
2308        workspace: WeakEntity<Workspace>,
2309        workspace_id: workspace::WorkspaceId,
2310        item_id: workspace::ItemId,
2311        window: &mut Window,
2312        cx: &mut App,
2313    ) -> Task<gpui::Result<Entity<Self>>> {
2314        if persistence::GIT_GRAPHS
2315            .get_git_graph(item_id, workspace_id)
2316            .ok()
2317            .is_some_and(|is_open| is_open)
2318        {
2319            let git_graph = cx.new(|cx| GitGraph::new(project, workspace, window, cx));
2320            Task::ready(Ok(git_graph))
2321        } else {
2322            Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
2323        }
2324    }
2325
2326    fn serialize(
2327        &mut self,
2328        workspace: &mut Workspace,
2329        item_id: workspace::ItemId,
2330        _closing: bool,
2331        _window: &mut Window,
2332        cx: &mut Context<Self>,
2333    ) -> Option<Task<gpui::Result<()>>> {
2334        let workspace_id = workspace.database_id()?;
2335        Some(cx.background_spawn(async move {
2336            persistence::GIT_GRAPHS
2337                .save_git_graph(item_id, workspace_id, true)
2338                .await
2339        }))
2340    }
2341
2342    fn should_serialize(&self, event: &Self::Event) -> bool {
2343        event == &ItemEvent::UpdateTab
2344    }
2345}
2346
2347mod persistence {
2348    use db::{
2349        query,
2350        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
2351        sqlez_macros::sql,
2352    };
2353    use workspace::WorkspaceDb;
2354
2355    pub struct GitGraphsDb(ThreadSafeConnection);
2356
2357    impl Domain for GitGraphsDb {
2358        const NAME: &str = stringify!(GitGraphsDb);
2359
2360        const MIGRATIONS: &[&str] = (&[sql!(
2361            CREATE TABLE git_graphs (
2362                workspace_id INTEGER,
2363                item_id INTEGER UNIQUE,
2364                is_open INTEGER DEFAULT FALSE,
2365
2366                PRIMARY KEY(workspace_id, item_id),
2367                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2368                ON DELETE CASCADE
2369            ) STRICT;
2370        )]);
2371    }
2372
2373    db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
2374
2375    impl GitGraphsDb {
2376        query! {
2377            pub async fn save_git_graph(
2378                item_id: workspace::ItemId,
2379                workspace_id: workspace::WorkspaceId,
2380                is_open: bool
2381            ) -> Result<()> {
2382                INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
2383                VALUES (?, ?, ?)
2384            }
2385        }
2386
2387        query! {
2388            pub fn get_git_graph(
2389                item_id: workspace::ItemId,
2390                workspace_id: workspace::WorkspaceId
2391            ) -> Result<bool> {
2392                SELECT is_open
2393                FROM git_graphs
2394                WHERE item_id = ? AND workspace_id = ?
2395            }
2396        }
2397    }
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402    use super::*;
2403    use anyhow::{Context, Result, bail};
2404    use collections::{HashMap, HashSet};
2405    use fs::FakeFs;
2406    use git::Oid;
2407    use git::repository::InitialGraphCommitData;
2408    use gpui::TestAppContext;
2409    use project::Project;
2410    use project::git_store::{GitStoreEvent, RepositoryEvent};
2411    use rand::prelude::*;
2412    use serde_json::json;
2413    use settings::SettingsStore;
2414    use smallvec::{SmallVec, smallvec};
2415    use std::path::Path;
2416    use std::sync::{Arc, Mutex};
2417    use workspace::MultiWorkspace;
2418
2419    fn init_test(cx: &mut TestAppContext) {
2420        cx.update(|cx| {
2421            let settings_store = SettingsStore::test(cx);
2422            cx.set_global(settings_store);
2423        });
2424    }
2425
2426    fn init_test_with_theme(cx: &mut TestAppContext) {
2427        cx.update(|cx| {
2428            let settings_store = SettingsStore::test(cx);
2429            cx.set_global(settings_store);
2430            theme::init(theme::LoadThemes::JustBase, cx);
2431        });
2432    }
2433
2434    /// Generates a random commit DAG suitable for testing git graph rendering.
2435    ///
2436    /// The commits are ordered newest-first (like git log output), so:
2437    /// - Index 0 = most recent commit (HEAD)
2438    /// - Last index = oldest commit (root, has no parents)
2439    /// - Parents of commit at index I must have index > I
2440    ///
2441    /// When `adversarial` is true, generates complex topologies with many branches
2442    /// and octopus merges. Otherwise generates more realistic linear histories
2443    /// with occasional branches.
2444    fn generate_random_commit_dag(
2445        rng: &mut StdRng,
2446        num_commits: usize,
2447        adversarial: bool,
2448    ) -> Vec<Arc<InitialGraphCommitData>> {
2449        if num_commits == 0 {
2450            return Vec::new();
2451        }
2452
2453        let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
2454        let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
2455
2456        for i in 0..num_commits {
2457            let sha = oids[i];
2458
2459            let parents = if i == num_commits - 1 {
2460                smallvec![]
2461            } else {
2462                generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
2463            };
2464
2465            let ref_names = if i == 0 {
2466                vec!["HEAD".into(), "main".into()]
2467            } else if adversarial && rng.random_bool(0.1) {
2468                vec![format!("branch-{}", i).into()]
2469            } else {
2470                Vec::new()
2471            };
2472
2473            commits.push(Arc::new(InitialGraphCommitData {
2474                sha,
2475                parents,
2476                ref_names,
2477            }));
2478        }
2479
2480        commits
2481    }
2482
2483    fn generate_parents_from_oids(
2484        rng: &mut StdRng,
2485        oids: &[Oid],
2486        current_idx: usize,
2487        num_commits: usize,
2488        adversarial: bool,
2489    ) -> SmallVec<[Oid; 1]> {
2490        let remaining = num_commits - current_idx - 1;
2491        if remaining == 0 {
2492            return smallvec![];
2493        }
2494
2495        if adversarial {
2496            let merge_chance = 0.4;
2497            let octopus_chance = 0.15;
2498
2499            if remaining >= 3 && rng.random_bool(octopus_chance) {
2500                let num_parents = rng.random_range(3..=remaining.min(5));
2501                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2502                parent_indices.shuffle(rng);
2503                parent_indices
2504                    .into_iter()
2505                    .take(num_parents)
2506                    .map(|idx| oids[idx])
2507                    .collect()
2508            } else if remaining >= 2 && rng.random_bool(merge_chance) {
2509                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2510                parent_indices.shuffle(rng);
2511                parent_indices
2512                    .into_iter()
2513                    .take(2)
2514                    .map(|idx| oids[idx])
2515                    .collect()
2516            } else {
2517                let parent_idx = rng.random_range(current_idx + 1..num_commits);
2518                smallvec![oids[parent_idx]]
2519            }
2520        } else {
2521            let merge_chance = 0.15;
2522            let skip_chance = 0.1;
2523
2524            if remaining >= 2 && rng.random_bool(merge_chance) {
2525                let first_parent = current_idx + 1;
2526                let second_parent = rng.random_range(current_idx + 2..num_commits);
2527                smallvec![oids[first_parent], oids[second_parent]]
2528            } else if rng.random_bool(skip_chance) && remaining >= 2 {
2529                let skip = rng.random_range(1..remaining.min(3));
2530                smallvec![oids[current_idx + 1 + skip]]
2531            } else {
2532                smallvec![oids[current_idx + 1]]
2533            }
2534        }
2535    }
2536
2537    fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
2538        graph
2539            .commits
2540            .iter()
2541            .enumerate()
2542            .map(|(idx, entry)| (entry.data.sha, idx))
2543            .collect()
2544    }
2545
2546    fn verify_commit_order(
2547        graph: &GraphData,
2548        commits: &[Arc<InitialGraphCommitData>],
2549    ) -> Result<()> {
2550        if graph.commits.len() != commits.len() {
2551            bail!(
2552                "Commit count mismatch: graph has {} commits, expected {}",
2553                graph.commits.len(),
2554                commits.len()
2555            );
2556        }
2557
2558        for (idx, (graph_commit, expected_commit)) in
2559            graph.commits.iter().zip(commits.iter()).enumerate()
2560        {
2561            if graph_commit.data.sha != expected_commit.sha {
2562                bail!(
2563                    "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
2564                    idx,
2565                    graph_commit.data.sha,
2566                    expected_commit.sha
2567                );
2568            }
2569        }
2570
2571        Ok(())
2572    }
2573
2574    fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
2575        for line in &graph.lines {
2576            let child_row = *oid_to_row
2577                .get(&line.child)
2578                .context("Line references non-existent child commit")?;
2579
2580            let parent_row = *oid_to_row
2581                .get(&line.parent)
2582                .context("Line references non-existent parent commit")?;
2583
2584            if child_row >= parent_row {
2585                bail!(
2586                    "child_row ({}) must be < parent_row ({})",
2587                    child_row,
2588                    parent_row
2589                );
2590            }
2591
2592            if line.full_interval.start != child_row {
2593                bail!(
2594                    "full_interval.start ({}) != child_row ({})",
2595                    line.full_interval.start,
2596                    child_row
2597                );
2598            }
2599
2600            if line.full_interval.end != parent_row {
2601                bail!(
2602                    "full_interval.end ({}) != parent_row ({})",
2603                    line.full_interval.end,
2604                    parent_row
2605                );
2606            }
2607
2608            if let Some(last_segment) = line.segments.last() {
2609                let segment_end_row = match last_segment {
2610                    CommitLineSegment::Straight { to_row } => *to_row,
2611                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2612                };
2613
2614                if segment_end_row != line.full_interval.end {
2615                    bail!(
2616                        "last segment ends at row {} but full_interval.end is {}",
2617                        segment_end_row,
2618                        line.full_interval.end
2619                    );
2620                }
2621            }
2622        }
2623
2624        Ok(())
2625    }
2626
2627    fn verify_column_correctness(
2628        graph: &GraphData,
2629        oid_to_row: &HashMap<Oid, usize>,
2630    ) -> Result<()> {
2631        for line in &graph.lines {
2632            let child_row = *oid_to_row
2633                .get(&line.child)
2634                .context("Line references non-existent child commit")?;
2635
2636            let parent_row = *oid_to_row
2637                .get(&line.parent)
2638                .context("Line references non-existent parent commit")?;
2639
2640            let child_lane = graph.commits[child_row].lane;
2641            if line.child_column != child_lane {
2642                bail!(
2643                    "child_column ({}) != child's lane ({})",
2644                    line.child_column,
2645                    child_lane
2646                );
2647            }
2648
2649            let mut current_column = line.child_column;
2650            for segment in &line.segments {
2651                if let CommitLineSegment::Curve { to_column, .. } = segment {
2652                    current_column = *to_column;
2653                }
2654            }
2655
2656            let parent_lane = graph.commits[parent_row].lane;
2657            if current_column != parent_lane {
2658                bail!(
2659                    "ending column ({}) != parent's lane ({})",
2660                    current_column,
2661                    parent_lane
2662                );
2663            }
2664        }
2665
2666        Ok(())
2667    }
2668
2669    fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
2670        for line in &graph.lines {
2671            if line.segments.is_empty() {
2672                bail!("Line has no segments");
2673            }
2674
2675            let mut current_row = line.full_interval.start;
2676
2677            for (idx, segment) in line.segments.iter().enumerate() {
2678                let segment_end_row = match segment {
2679                    CommitLineSegment::Straight { to_row } => *to_row,
2680                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2681                };
2682
2683                if segment_end_row < current_row {
2684                    bail!(
2685                        "segment {} ends at row {} which is before current row {}",
2686                        idx,
2687                        segment_end_row,
2688                        current_row
2689                    );
2690                }
2691
2692                current_row = segment_end_row;
2693            }
2694        }
2695
2696        Ok(())
2697    }
2698
2699    fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
2700        for line in &graph.lines {
2701            let child_row = line.full_interval.start;
2702
2703            let mut current_column = line.child_column;
2704            let mut current_row = child_row;
2705
2706            for segment in &line.segments {
2707                match segment {
2708                    CommitLineSegment::Straight { to_row } => {
2709                        for row in (current_row + 1)..*to_row {
2710                            if row < graph.commits.len() {
2711                                let commit_at_row = &graph.commits[row];
2712                                if commit_at_row.lane == current_column {
2713                                    bail!(
2714                                        "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
2715                                        current_row,
2716                                        to_row,
2717                                        current_column,
2718                                        commit_at_row.data.sha,
2719                                        row
2720                                    );
2721                                }
2722                            }
2723                        }
2724                        current_row = *to_row;
2725                    }
2726                    CommitLineSegment::Curve {
2727                        to_column, on_row, ..
2728                    } => {
2729                        current_column = *to_column;
2730                        current_row = *on_row;
2731                    }
2732                }
2733            }
2734        }
2735
2736        Ok(())
2737    }
2738
2739    fn verify_coverage(graph: &GraphData) -> Result<()> {
2740        let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
2741        for entry in &graph.commits {
2742            for parent in &entry.data.parents {
2743                expected_edges.insert((entry.data.sha, *parent));
2744            }
2745        }
2746
2747        let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
2748        for line in &graph.lines {
2749            let edge = (line.child, line.parent);
2750
2751            if !found_edges.insert(edge) {
2752                bail!(
2753                    "Duplicate line found for edge {:?} -> {:?}",
2754                    line.child,
2755                    line.parent
2756                );
2757            }
2758
2759            if !expected_edges.contains(&edge) {
2760                bail!(
2761                    "Orphan line found: {:?} -> {:?} is not in the commit graph",
2762                    line.child,
2763                    line.parent
2764                );
2765            }
2766        }
2767
2768        for (child, parent) in &expected_edges {
2769            if !found_edges.contains(&(*child, *parent)) {
2770                bail!("Missing line for edge {:?} -> {:?}", child, parent);
2771            }
2772        }
2773
2774        assert_eq!(
2775            expected_edges.symmetric_difference(&found_edges).count(),
2776            0,
2777            "The symmetric difference should be zero"
2778        );
2779
2780        Ok(())
2781    }
2782
2783    fn verify_merge_line_optimality(
2784        graph: &GraphData,
2785        oid_to_row: &HashMap<Oid, usize>,
2786    ) -> Result<()> {
2787        for line in &graph.lines {
2788            let first_segment = line.segments.first();
2789            let is_merge_line = matches!(
2790                first_segment,
2791                Some(CommitLineSegment::Curve {
2792                    curve_kind: CurveKind::Merge,
2793                    ..
2794                })
2795            );
2796
2797            if !is_merge_line {
2798                continue;
2799            }
2800
2801            let child_row = *oid_to_row
2802                .get(&line.child)
2803                .context("Line references non-existent child commit")?;
2804
2805            let parent_row = *oid_to_row
2806                .get(&line.parent)
2807                .context("Line references non-existent parent commit")?;
2808
2809            let parent_lane = graph.commits[parent_row].lane;
2810
2811            let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
2812                continue;
2813            };
2814
2815            let curves_directly_to_parent = *to_column == parent_lane;
2816
2817            if !curves_directly_to_parent {
2818                continue;
2819            }
2820
2821            let curve_row = child_row + 1;
2822            let has_commits_in_path = graph.commits[curve_row..parent_row]
2823                .iter()
2824                .any(|c| c.lane == parent_lane);
2825
2826            if has_commits_in_path {
2827                bail!(
2828                    "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
2829                    line.child,
2830                    line.parent,
2831                    parent_lane,
2832                    curve_row,
2833                    parent_row
2834                );
2835            }
2836
2837            let curve_ends_at_parent = curve_row == parent_row;
2838
2839            if curve_ends_at_parent {
2840                if line.segments.len() != 1 {
2841                    bail!(
2842                        "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
2843                        line.child,
2844                        line.parent,
2845                        line.segments.len()
2846                    );
2847                }
2848            } else {
2849                if line.segments.len() != 2 {
2850                    bail!(
2851                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
2852                        line.child,
2853                        line.parent,
2854                        line.segments.len()
2855                    );
2856                }
2857
2858                let is_straight_segment = matches!(
2859                    line.segments.get(1),
2860                    Some(CommitLineSegment::Straight { .. })
2861                );
2862
2863                if !is_straight_segment {
2864                    bail!(
2865                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
2866                        line.child,
2867                        line.parent
2868                    );
2869                }
2870            }
2871        }
2872
2873        Ok(())
2874    }
2875
2876    fn verify_all_invariants(
2877        graph: &GraphData,
2878        commits: &[Arc<InitialGraphCommitData>],
2879    ) -> Result<()> {
2880        let oid_to_row = build_oid_to_row_map(graph);
2881
2882        verify_commit_order(graph, commits).context("commit order")?;
2883        verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
2884        verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
2885        verify_segment_continuity(graph).context("segment continuity")?;
2886        verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
2887        verify_coverage(graph).context("coverage")?;
2888        verify_line_overlaps(graph).context("line overlaps")?;
2889        Ok(())
2890    }
2891
2892    #[test]
2893    fn test_git_graph_merge_commits() {
2894        let mut rng = StdRng::seed_from_u64(42);
2895
2896        let oid1 = Oid::random(&mut rng);
2897        let oid2 = Oid::random(&mut rng);
2898        let oid3 = Oid::random(&mut rng);
2899        let oid4 = Oid::random(&mut rng);
2900
2901        let commits = vec![
2902            Arc::new(InitialGraphCommitData {
2903                sha: oid1,
2904                parents: smallvec![oid2, oid3],
2905                ref_names: vec!["HEAD".into()],
2906            }),
2907            Arc::new(InitialGraphCommitData {
2908                sha: oid2,
2909                parents: smallvec![oid4],
2910                ref_names: vec![],
2911            }),
2912            Arc::new(InitialGraphCommitData {
2913                sha: oid3,
2914                parents: smallvec![oid4],
2915                ref_names: vec![],
2916            }),
2917            Arc::new(InitialGraphCommitData {
2918                sha: oid4,
2919                parents: smallvec![],
2920                ref_names: vec![],
2921            }),
2922        ];
2923
2924        let mut graph_data = GraphData::new(8);
2925        graph_data.add_commits(&commits);
2926
2927        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2928            panic!("Graph invariant violation for merge commits:\n{}", error);
2929        }
2930    }
2931
2932    #[test]
2933    fn test_git_graph_linear_commits() {
2934        let mut rng = StdRng::seed_from_u64(42);
2935
2936        let oid1 = Oid::random(&mut rng);
2937        let oid2 = Oid::random(&mut rng);
2938        let oid3 = Oid::random(&mut rng);
2939
2940        let commits = vec![
2941            Arc::new(InitialGraphCommitData {
2942                sha: oid1,
2943                parents: smallvec![oid2],
2944                ref_names: vec!["HEAD".into()],
2945            }),
2946            Arc::new(InitialGraphCommitData {
2947                sha: oid2,
2948                parents: smallvec![oid3],
2949                ref_names: vec![],
2950            }),
2951            Arc::new(InitialGraphCommitData {
2952                sha: oid3,
2953                parents: smallvec![],
2954                ref_names: vec![],
2955            }),
2956        ];
2957
2958        let mut graph_data = GraphData::new(8);
2959        graph_data.add_commits(&commits);
2960
2961        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2962            panic!("Graph invariant violation for linear commits:\n{}", error);
2963        }
2964    }
2965
2966    #[test]
2967    fn test_git_graph_random_commits() {
2968        for seed in 0..100 {
2969            let mut rng = StdRng::seed_from_u64(seed);
2970
2971            let adversarial = rng.random_bool(0.2);
2972            let num_commits = if adversarial {
2973                rng.random_range(10..100)
2974            } else {
2975                rng.random_range(5..50)
2976            };
2977
2978            let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2979
2980            assert_eq!(
2981                num_commits,
2982                commits.len(),
2983                "seed={}: Generate random commit dag didn't generate the correct amount of commits",
2984                seed
2985            );
2986
2987            let mut graph_data = GraphData::new(8);
2988            graph_data.add_commits(&commits);
2989
2990            if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2991                panic!(
2992                    "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
2993                    seed, adversarial, num_commits, error
2994                );
2995            }
2996        }
2997    }
2998
2999    // The full integration test has less iterations because it's significantly slower
3000    // than the random commit test
3001    #[gpui::test(iterations = 10)]
3002    async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
3003        init_test(cx);
3004
3005        let adversarial = rng.random_bool(0.2);
3006        let num_commits = if adversarial {
3007            rng.random_range(10..100)
3008        } else {
3009            rng.random_range(5..50)
3010        };
3011
3012        let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3013
3014        let fs = FakeFs::new(cx.executor());
3015        fs.insert_tree(
3016            Path::new("/project"),
3017            json!({
3018                ".git": {},
3019                "file.txt": "content",
3020            }),
3021        )
3022        .await;
3023
3024        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3025
3026        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3027        cx.run_until_parked();
3028
3029        let repository = project.read_with(cx, |project, cx| {
3030            project
3031                .active_repository(cx)
3032                .expect("should have a repository")
3033        });
3034
3035        repository.update(cx, |repo, cx| {
3036            repo.graph_data(
3037                crate::LogSource::default(),
3038                crate::LogOrder::default(),
3039                0..usize::MAX,
3040                cx,
3041            );
3042        });
3043        cx.run_until_parked();
3044
3045        let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
3046            repo.graph_data(
3047                crate::LogSource::default(),
3048                crate::LogOrder::default(),
3049                0..usize::MAX,
3050                cx,
3051            )
3052            .0
3053            .to_vec()
3054        });
3055
3056        let mut graph_data = GraphData::new(8);
3057        graph_data.add_commits(&graph_commits);
3058
3059        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3060            panic!(
3061                "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
3062                adversarial, num_commits, error
3063            );
3064        }
3065    }
3066
3067    #[gpui::test]
3068    async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
3069        init_test(cx);
3070
3071        let fs = FakeFs::new(cx.executor());
3072        fs.insert_tree(
3073            Path::new("/project"),
3074            json!({
3075                ".git": {},
3076                "file.txt": "content",
3077            }),
3078        )
3079        .await;
3080
3081        let mut rng = StdRng::seed_from_u64(42);
3082        let commits = generate_random_commit_dag(&mut rng, 10, false);
3083        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3084
3085        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3086        let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
3087        project.update(cx, |project, cx| {
3088            let observed_repository_events = observed_repository_events.clone();
3089            cx.subscribe(project.git_store(), move |_, _, event, _| {
3090                if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
3091                    observed_repository_events
3092                        .lock()
3093                        .expect("repository event mutex should be available")
3094                        .push(repository_event.clone());
3095                }
3096            })
3097            .detach();
3098        });
3099
3100        let repository = project.read_with(cx, |project, cx| {
3101            project
3102                .active_repository(cx)
3103                .expect("should have a repository")
3104        });
3105
3106        repository.update(cx, |repo, cx| {
3107            repo.graph_data(
3108                crate::LogSource::default(),
3109                crate::LogOrder::default(),
3110                0..usize::MAX,
3111                cx,
3112            );
3113        });
3114
3115        project
3116            .update(cx, |project, cx| project.git_scans_complete(cx))
3117            .await;
3118        cx.run_until_parked();
3119
3120        let observed_repository_events = observed_repository_events
3121            .lock()
3122            .expect("repository event mutex should be available");
3123        assert!(
3124            observed_repository_events
3125                .iter()
3126                .any(|event| matches!(event, RepositoryEvent::BranchChanged)),
3127            "initial repository scan should emit BranchChanged"
3128        );
3129        assert!(
3130            observed_repository_events
3131                .iter()
3132                .any(|event| matches!(event, RepositoryEvent::MergeHeadsChanged)),
3133            "initial repository scan should emit MergeHeadsChanged"
3134        );
3135
3136        let graph_data_key = (crate::LogOrder::default(), crate::LogSource::default());
3137        let commit_count_after = repository.read_with(cx, |repo, _| {
3138            repo.initial_graph_data
3139                .get(&graph_data_key)
3140                .map(|(_, data)| data.len())
3141                .unwrap_or(0)
3142        });
3143        assert_eq!(
3144            commits.len(),
3145            commit_count_after,
3146            "initial_graph_data should remain populated after events emitted by initial repository scan"
3147        );
3148    }
3149
3150    #[gpui::test]
3151    async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
3152        init_test_with_theme(cx);
3153
3154        let fs = FakeFs::new(cx.executor());
3155        fs.insert_tree(
3156            Path::new("/project_a"),
3157            json!({
3158                ".git": {},
3159                "file.txt": "content",
3160            }),
3161        )
3162        .await;
3163        fs.insert_tree(
3164            Path::new("/project_b"),
3165            json!({
3166                ".git": {},
3167                "other.txt": "content",
3168            }),
3169        )
3170        .await;
3171
3172        let mut rng = StdRng::seed_from_u64(42);
3173        let commits = generate_random_commit_dag(&mut rng, 10, false);
3174        fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
3175
3176        let project = Project::test(
3177            fs.clone(),
3178            [Path::new("/project_a"), Path::new("/project_b")],
3179            cx,
3180        )
3181        .await;
3182        cx.run_until_parked();
3183
3184        let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
3185            let mut first_repository = None;
3186            let mut second_repository = None;
3187
3188            for repository in project.repositories(cx).values() {
3189                let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
3190                if work_directory_abs_path.as_ref() == Path::new("/project_a") {
3191                    first_repository = Some(repository.clone());
3192                } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
3193                    second_repository = Some(repository.clone());
3194                }
3195            }
3196
3197            (
3198                first_repository.expect("should have repository for /project_a"),
3199                second_repository.expect("should have repository for /project_b"),
3200            )
3201        });
3202        first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
3203        cx.run_until_parked();
3204
3205        let (multi_workspace, cx) =
3206            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3207
3208        let workspace_weak =
3209            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
3210        let git_graph = cx.new_window_entity(|window, cx| {
3211            GitGraph::new(project.clone(), workspace_weak, window, cx)
3212        });
3213        cx.run_until_parked();
3214
3215        // Verify initial graph data is loaded
3216        let initial_commit_count =
3217            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3218        assert!(
3219            initial_commit_count > 0,
3220            "graph data should have been loaded, got 0 commits"
3221        );
3222
3223        second_repository.update(&mut *cx, |repository, cx| {
3224            repository.set_as_active_repository(cx)
3225        });
3226        cx.run_until_parked();
3227
3228        let commit_count_after_clear =
3229            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3230        assert_eq!(
3231            commit_count_after_clear, 0,
3232            "graph_data should be cleared after switching away"
3233        );
3234
3235        first_repository.update(&mut *cx, |repository, cx| {
3236            repository.set_as_active_repository(cx)
3237        });
3238
3239        git_graph.update_in(&mut *cx, |this, window, cx| {
3240            this.render(window, cx);
3241        });
3242        cx.run_until_parked();
3243
3244        let commit_count_after_switch_back =
3245            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3246        assert_eq!(
3247            initial_commit_count, commit_count_after_switch_back,
3248            "graph_data should be repopulated from cache after switching back to the same repo"
3249        );
3250    }
3251}