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