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