git_graph.rs

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