git_graph.rs

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