git_graph.rs

   1use collections::{BTreeMap, HashMap};
   2use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
   3use git::{
   4    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
   5    parse_git_remote_url,
   6    repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath},
   7    status::{FileStatus, StatusCode, TrackedStatus},
   8};
   9use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon};
  10use gpui::{
  11    AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent,
  12    ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels,
  13    Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task,
  14    UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
  15    px, uniform_list,
  16};
  17use language::line_diff;
  18use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  19use project::{
  20    Project,
  21    git_store::{
  22        CommitDataState, GitGraphEvent, GitStoreEvent, GraphDataResponse, Repository,
  23        RepositoryEvent, RepositoryId,
  24    },
  25};
  26use settings::Settings;
  27use smallvec::{SmallVec, smallvec};
  28use std::{
  29    cell::Cell,
  30    ops::Range,
  31    rc::Rc,
  32    sync::Arc,
  33    sync::OnceLock,
  34    time::{Duration, Instant},
  35};
  36use theme::{AccentColors, 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 => {
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_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1175        self.select_entry(0, cx);
1176    }
1177
1178    fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1179        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1180            self.select_entry(selected_entry_idx.saturating_sub(1), cx);
1181        } else {
1182            self.select_first(&SelectFirst, window, cx);
1183        }
1184    }
1185
1186    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1187        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1188            self.select_entry(
1189                selected_entry_idx
1190                    .saturating_add(1)
1191                    .min(self.graph_data.commits.len().saturating_sub(1)),
1192                cx,
1193            );
1194        } else {
1195            self.select_prev(&SelectPrevious, window, cx);
1196        }
1197    }
1198
1199    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1200        self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx);
1201    }
1202
1203    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1204        self.open_selected_commit_view(window, cx);
1205    }
1206
1207    fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
1208        if self.selected_entry_idx == Some(idx) {
1209            return;
1210        }
1211
1212        self.selected_entry_idx = Some(idx);
1213        self.selected_commit_diff = None;
1214        self.selected_commit_diff_stats = None;
1215        self.changed_files_scroll_handle
1216            .scroll_to_item(0, ScrollStrategy::Top);
1217        self.table_interaction_state.update(cx, |state, cx| {
1218            state
1219                .scroll_handle
1220                .scroll_to_item(idx, ScrollStrategy::Nearest);
1221            cx.notify();
1222        });
1223
1224        let Some(commit) = self.graph_data.commits.get(idx) else {
1225            return;
1226        };
1227
1228        let sha = commit.data.sha.to_string();
1229
1230        let Some(repository) = self.get_selected_repository(cx) else {
1231            return;
1232        };
1233
1234        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1235
1236        self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1237            if let Ok(Ok(diff)) = diff_receiver.await {
1238                this.update(cx, |this, cx| {
1239                    let stats = compute_diff_stats(&diff);
1240                    this.selected_commit_diff = Some(diff);
1241                    this.selected_commit_diff_stats = Some(stats);
1242                    cx.notify();
1243                })
1244                .ok();
1245            }
1246        }));
1247
1248        cx.notify();
1249    }
1250
1251    pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context<Self>) {
1252        let Ok(oid) = sha.parse::<Oid>() else {
1253            return;
1254        };
1255
1256        let Some(selected_repository) = self.get_selected_repository(cx) else {
1257            return;
1258        };
1259
1260        let Some(index) = selected_repository
1261            .read(cx)
1262            .get_graph_data(self.log_source.clone(), self.log_order)
1263            .and_then(|data| data.commit_oid_to_index.get(&oid))
1264            .copied()
1265        else {
1266            return;
1267        };
1268
1269        self.select_entry(index, cx);
1270    }
1271
1272    fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1273        let Some(selected_entry_index) = self.selected_entry_idx else {
1274            return;
1275        };
1276
1277        self.open_commit_view(selected_entry_index, window, cx);
1278    }
1279
1280    fn open_commit_view(
1281        &mut self,
1282        entry_index: usize,
1283        window: &mut Window,
1284        cx: &mut Context<Self>,
1285    ) {
1286        let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1287            return;
1288        };
1289
1290        let Some(repository) = self.get_selected_repository(cx) else {
1291            return;
1292        };
1293
1294        CommitView::open(
1295            commit_entry.data.sha.to_string(),
1296            repository.downgrade(),
1297            self.workspace.clone(),
1298            None,
1299            None,
1300            window,
1301            cx,
1302        );
1303    }
1304
1305    fn get_remote(
1306        &self,
1307        repository: &Repository,
1308        _window: &mut Window,
1309        cx: &mut App,
1310    ) -> Option<GitRemote> {
1311        let remote_url = repository.default_remote_url()?;
1312        let provider_registry = GitHostingProviderRegistry::default_global(cx);
1313        let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1314        Some(GitRemote {
1315            host: provider,
1316            owner: parsed.owner.into(),
1317            repo: parsed.repo.into(),
1318        })
1319    }
1320
1321    fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1322        let rems = TextSize::Large.rems(cx);
1323        Icon::new(IconName::LoadCircle)
1324            .size(IconSize::Custom(rems))
1325            .color(Color::Accent)
1326            .with_rotate_animation(3)
1327            .into_any_element()
1328    }
1329
1330    fn render_commit_detail_panel(
1331        &self,
1332        window: &mut Window,
1333        cx: &mut Context<Self>,
1334    ) -> impl IntoElement {
1335        let Some(selected_idx) = self.selected_entry_idx else {
1336            return Empty.into_any_element();
1337        };
1338
1339        let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1340            return Empty.into_any_element();
1341        };
1342
1343        let Some(repository) = self.get_selected_repository(cx) else {
1344            return Empty.into_any_element();
1345        };
1346
1347        let data = repository.update(cx, |repository, cx| {
1348            repository
1349                .fetch_commit_data(commit_entry.data.sha, cx)
1350                .clone()
1351        });
1352
1353        let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1354        let ref_names = commit_entry.data.ref_names.clone();
1355
1356        let accent_colors = cx.theme().accents();
1357        let accent_color = accent_colors
1358            .0
1359            .get(commit_entry.color_idx)
1360            .copied()
1361            .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1362
1363        let (author_name, author_email, commit_timestamp, subject) = match &data {
1364            CommitDataState::Loaded(data) => (
1365                data.author_name.clone(),
1366                data.author_email.clone(),
1367                Some(data.commit_timestamp),
1368                data.subject.clone(),
1369            ),
1370            CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
1371        };
1372
1373        let date_string = commit_timestamp
1374            .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1375            .map(|datetime| {
1376                let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1377                let local_datetime = datetime.to_offset(local_offset);
1378                let format =
1379                    time::format_description::parse("[month repr:short] [day], [year]").ok();
1380                format
1381                    .and_then(|f| local_datetime.format(&f).ok())
1382                    .unwrap_or_default()
1383            })
1384            .unwrap_or_default();
1385
1386        let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1387
1388        let avatar = {
1389            let author_email_for_avatar = if author_email.is_empty() {
1390                None
1391            } else {
1392                Some(author_email.clone())
1393            };
1394
1395            CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
1396                .size(px(40.))
1397                .render(window, cx)
1398        };
1399
1400        let changed_files_count = self
1401            .selected_commit_diff
1402            .as_ref()
1403            .map(|diff| diff.files.len())
1404            .unwrap_or(0);
1405
1406        let (total_lines_added, total_lines_removed) =
1407            self.selected_commit_diff_stats.unwrap_or((0, 0));
1408
1409        let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1410            self.selected_commit_diff
1411                .as_ref()
1412                .map(|diff| {
1413                    let mut files: Vec<_> = diff.files.iter().collect();
1414                    files.sort_by_key(|file| file.status());
1415                    files
1416                        .into_iter()
1417                        .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1418                        .collect()
1419                })
1420                .unwrap_or_default(),
1421        );
1422
1423        v_flex()
1424            .min_w(px(300.))
1425            .h_full()
1426            .bg(cx.theme().colors().surface_background)
1427            .flex_basis(DefiniteLength::Fraction(
1428                self.commit_details_split_state.read(cx).right_ratio(),
1429            ))
1430            .child(
1431                v_flex()
1432                    .relative()
1433                    .w_full()
1434                    .p_2()
1435                    .gap_2()
1436                    .child(
1437                        div().absolute().top_2().right_2().child(
1438                            IconButton::new("close-detail", IconName::Close)
1439                                .icon_size(IconSize::Small)
1440                                .on_click(cx.listener(move |this, _, _, cx| {
1441                                    this.selected_entry_idx = None;
1442                                    this.selected_commit_diff = None;
1443                                    this.selected_commit_diff_stats = None;
1444                                    this._commit_diff_task = None;
1445                                    cx.notify();
1446                                })),
1447                        ),
1448                    )
1449                    .child(
1450                        v_flex()
1451                            .py_1()
1452                            .w_full()
1453                            .items_center()
1454                            .gap_1()
1455                            .child(avatar)
1456                            .child(
1457                                v_flex()
1458                                    .items_center()
1459                                    .child(Label::new(author_name))
1460                                    .child(
1461                                        Label::new(date_string)
1462                                            .color(Color::Muted)
1463                                            .size(LabelSize::Small),
1464                                    ),
1465                            ),
1466                    )
1467                    .children((!ref_names.is_empty()).then(|| {
1468                        h_flex().gap_1().flex_wrap().justify_center().children(
1469                            ref_names
1470                                .iter()
1471                                .map(|name| self.render_chip(name, accent_color)),
1472                        )
1473                    }))
1474                    .child(
1475                        v_flex()
1476                            .ml_neg_1()
1477                            .gap_1p5()
1478                            .when(!author_email.is_empty(), |this| {
1479                                let copied_state: Entity<CopiedState> = window.use_keyed_state(
1480                                    "author-email-copy",
1481                                    cx,
1482                                    CopiedState::new,
1483                                );
1484                                let is_copied = copied_state.read(cx).is_copied();
1485
1486                                let (icon, icon_color, tooltip_label) = if is_copied {
1487                                    (IconName::Check, Color::Success, "Email Copied!")
1488                                } else {
1489                                    (IconName::Envelope, Color::Muted, "Copy Email")
1490                                };
1491
1492                                let copy_email = author_email.clone();
1493                                let author_email_for_tooltip = author_email.clone();
1494
1495                                this.child(
1496                                    Button::new("author-email-copy", author_email.clone())
1497                                        .start_icon(
1498                                            Icon::new(icon).size(IconSize::Small).color(icon_color),
1499                                        )
1500                                        .label_size(LabelSize::Small)
1501                                        .truncate(true)
1502                                        .color(Color::Muted)
1503                                        .tooltip(move |_, cx| {
1504                                            Tooltip::with_meta(
1505                                                tooltip_label,
1506                                                None,
1507                                                author_email_for_tooltip.clone(),
1508                                                cx,
1509                                            )
1510                                        })
1511                                        .on_click(move |_, _, cx| {
1512                                            copied_state.update(cx, |state, _cx| {
1513                                                state.mark_copied();
1514                                            });
1515                                            cx.write_to_clipboard(ClipboardItem::new_string(
1516                                                copy_email.to_string(),
1517                                            ));
1518                                            let state_id = copied_state.entity_id();
1519                                            cx.spawn(async move |cx| {
1520                                                cx.background_executor()
1521                                                    .timer(COPIED_STATE_DURATION)
1522                                                    .await;
1523                                                cx.update(|cx| {
1524                                                    cx.notify(state_id);
1525                                                })
1526                                            })
1527                                            .detach();
1528                                        }),
1529                                )
1530                            })
1531                            .child({
1532                                let copy_sha = full_sha.clone();
1533                                let copied_state: Entity<CopiedState> =
1534                                    window.use_keyed_state("sha-copy", cx, CopiedState::new);
1535                                let is_copied = copied_state.read(cx).is_copied();
1536
1537                                let (icon, icon_color, tooltip_label) = if is_copied {
1538                                    (IconName::Check, Color::Success, "Commit SHA Copied!")
1539                                } else {
1540                                    (IconName::Hash, Color::Muted, "Copy Commit SHA")
1541                                };
1542
1543                                Button::new("sha-button", &full_sha)
1544                                    .start_icon(
1545                                        Icon::new(icon).size(IconSize::Small).color(icon_color),
1546                                    )
1547                                    .label_size(LabelSize::Small)
1548                                    .truncate(true)
1549                                    .color(Color::Muted)
1550                                    .tooltip({
1551                                        let full_sha = full_sha.clone();
1552                                        move |_, cx| {
1553                                            Tooltip::with_meta(
1554                                                tooltip_label,
1555                                                None,
1556                                                full_sha.clone(),
1557                                                cx,
1558                                            )
1559                                        }
1560                                    })
1561                                    .on_click(move |_, _, cx| {
1562                                        copied_state.update(cx, |state, _cx| {
1563                                            state.mark_copied();
1564                                        });
1565                                        cx.write_to_clipboard(ClipboardItem::new_string(
1566                                            copy_sha.to_string(),
1567                                        ));
1568                                        let state_id = copied_state.entity_id();
1569                                        cx.spawn(async move |cx| {
1570                                            cx.background_executor()
1571                                                .timer(COPIED_STATE_DURATION)
1572                                                .await;
1573                                            cx.update(|cx| {
1574                                                cx.notify(state_id);
1575                                            })
1576                                        })
1577                                        .detach();
1578                                    })
1579                            })
1580                            .when_some(remote.clone(), |this, remote| {
1581                                let provider_name = remote.host.name();
1582                                let icon = match provider_name.as_str() {
1583                                    "GitHub" => IconName::Github,
1584                                    _ => IconName::Link,
1585                                };
1586                                let parsed_remote = ParsedGitRemote {
1587                                    owner: remote.owner.as_ref().into(),
1588                                    repo: remote.repo.as_ref().into(),
1589                                };
1590                                let params = BuildCommitPermalinkParams {
1591                                    sha: full_sha.as_ref(),
1592                                };
1593                                let url = remote
1594                                    .host
1595                                    .build_commit_permalink(&parsed_remote, params)
1596                                    .to_string();
1597
1598                                this.child(
1599                                    Button::new(
1600                                        "view-on-provider",
1601                                        format!("View on {}", provider_name),
1602                                    )
1603                                    .start_icon(
1604                                        Icon::new(icon).size(IconSize::Small).color(Color::Muted),
1605                                    )
1606                                    .label_size(LabelSize::Small)
1607                                    .truncate(true)
1608                                    .color(Color::Muted)
1609                                    .on_click(
1610                                        move |_, _, cx| {
1611                                            cx.open_url(&url);
1612                                        },
1613                                    ),
1614                                )
1615                            }),
1616                    ),
1617            )
1618            .child(Divider::horizontal())
1619            .child(div().min_w_0().p_2().child(Label::new(subject)))
1620            .child(Divider::horizontal())
1621            .child(
1622                v_flex()
1623                    .min_w_0()
1624                    .p_2()
1625                    .flex_1()
1626                    .gap_1()
1627                    .child(
1628                        h_flex()
1629                            .gap_1()
1630                            .child(
1631                                Label::new(format!("{} Changed Files", changed_files_count))
1632                                    .size(LabelSize::Small)
1633                                    .color(Color::Muted),
1634                            )
1635                            .child(DiffStat::new(
1636                                "commit-diff-stat",
1637                                total_lines_added,
1638                                total_lines_removed,
1639                            )),
1640                    )
1641                    .child(
1642                        div()
1643                            .id("changed-files-container")
1644                            .flex_1()
1645                            .min_h_0()
1646                            .child({
1647                                let entries = sorted_file_entries;
1648                                let entry_count = entries.len();
1649                                let commit_sha = full_sha.clone();
1650                                let repository = repository.downgrade();
1651                                let workspace = self.workspace.clone();
1652                                uniform_list(
1653                                    "changed-files-list",
1654                                    entry_count,
1655                                    move |range, _window, cx| {
1656                                        range
1657                                            .map(|ix| {
1658                                                entries[ix].render(
1659                                                    ix,
1660                                                    commit_sha.clone(),
1661                                                    repository.clone(),
1662                                                    workspace.clone(),
1663                                                    cx,
1664                                                )
1665                                            })
1666                                            .collect()
1667                                    },
1668                                )
1669                                .size_full()
1670                                .ml_neg_1()
1671                                .track_scroll(&self.changed_files_scroll_handle)
1672                            })
1673                            .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
1674                    ),
1675            )
1676            .child(Divider::horizontal())
1677            .child(
1678                h_flex().p_1p5().w_full().child(
1679                    Button::new("view-commit", "View Commit")
1680                        .full_width()
1681                        .style(ButtonStyle::Outlined)
1682                        .on_click(cx.listener(|this, _, window, cx| {
1683                            this.open_selected_commit_view(window, cx);
1684                        })),
1685                ),
1686            )
1687            .into_any_element()
1688    }
1689
1690    pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
1691        let row_height = self.row_height;
1692        let table_state = self.table_interaction_state.read(cx);
1693        let viewport_height = table_state
1694            .scroll_handle
1695            .0
1696            .borrow()
1697            .last_item_size
1698            .map(|size| size.item.height)
1699            .unwrap_or(px(600.0));
1700        let loaded_commit_count = self.graph_data.commits.len();
1701
1702        let content_height = row_height * loaded_commit_count;
1703        let max_scroll = (content_height - viewport_height).max(px(0.));
1704        let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
1705
1706        let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
1707        let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
1708        let horizontal_scroll_offset = self.horizontal_scroll_offset;
1709
1710        let max_lanes = self.graph_data.max_lanes.max(6);
1711        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1712        let last_visible_row =
1713            first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
1714
1715        let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
1716            ..(last_visible_row).min(loaded_commit_count);
1717        let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
1718        let commit_lines: Vec<_> = self
1719            .graph_data
1720            .lines
1721            .iter()
1722            .filter(|line| {
1723                line.full_interval.start <= viewport_range.end
1724                    && line.full_interval.end >= viewport_range.start
1725            })
1726            .cloned()
1727            .collect();
1728
1729        let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
1730
1731        let hovered_entry_idx = self.hovered_entry_idx;
1732        let selected_entry_idx = self.selected_entry_idx;
1733        let is_focused = self.focus_handle.is_focused(window);
1734        let graph_canvas_bounds = self.graph_canvas_bounds.clone();
1735
1736        gpui::canvas(
1737            move |_bounds, _window, _cx| {},
1738            move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
1739                graph_canvas_bounds.set(Some(bounds));
1740
1741                window.paint_layer(bounds, |window| {
1742                    let accent_colors = cx.theme().accents();
1743
1744                    let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
1745                    let selected_bg = if is_focused {
1746                        cx.theme().colors().element_selected
1747                    } else {
1748                        cx.theme().colors().element_hover
1749                    };
1750
1751                    for visible_row_idx in 0..rows.len() {
1752                        let absolute_row_idx = first_visible_row + visible_row_idx;
1753                        let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
1754                        let is_selected = selected_entry_idx == Some(absolute_row_idx);
1755
1756                        if is_hovered || is_selected {
1757                            let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
1758                                - vertical_scroll_offset;
1759
1760                            let row_bounds = Bounds::new(
1761                                point(bounds.origin.x, row_y),
1762                                gpui::Size {
1763                                    width: bounds.size.width,
1764                                    height: row_height,
1765                                },
1766                            );
1767
1768                            let bg_color = if is_selected { selected_bg } else { hover_bg };
1769                            window.paint_quad(gpui::fill(row_bounds, bg_color));
1770                        }
1771                    }
1772
1773                    for (row_idx, row) in rows.into_iter().enumerate() {
1774                        let row_color = accent_colors.color_for_index(row.color_idx as u32);
1775                        let row_y_center =
1776                            bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
1777                                - vertical_scroll_offset;
1778
1779                        let commit_x =
1780                            lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
1781
1782                        draw_commit_circle(commit_x, row_y_center, row_color, window);
1783                    }
1784
1785                    for line in commit_lines {
1786                        let Some((start_segment_idx, start_column)) =
1787                            line.get_first_visible_segment_idx(first_visible_row)
1788                        else {
1789                            continue;
1790                        };
1791
1792                        let line_x =
1793                            lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
1794
1795                        let start_row = line.full_interval.start as i32 - first_visible_row as i32;
1796
1797                        let from_y =
1798                            bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
1799                                - vertical_scroll_offset
1800                                + COMMIT_CIRCLE_RADIUS;
1801
1802                        let mut current_row = from_y;
1803                        let mut current_column = line_x;
1804
1805                        let mut builder = PathBuilder::stroke(LINE_WIDTH);
1806                        builder.move_to(point(line_x, from_y));
1807
1808                        let segments = &line.segments[start_segment_idx..];
1809
1810                        for (segment_idx, segment) in segments.iter().enumerate() {
1811                            let is_last = segment_idx + 1 == segments.len();
1812
1813                            match segment {
1814                                CommitLineSegment::Straight { to_row } => {
1815                                    let mut dest_row = to_row_center(
1816                                        to_row - first_visible_row,
1817                                        row_height,
1818                                        vertical_scroll_offset,
1819                                        bounds,
1820                                    );
1821                                    if is_last {
1822                                        dest_row -= COMMIT_CIRCLE_RADIUS;
1823                                    }
1824
1825                                    let dest_point = point(current_column, dest_row);
1826
1827                                    current_row = dest_point.y;
1828                                    builder.line_to(dest_point);
1829                                    builder.move_to(dest_point);
1830                                }
1831                                CommitLineSegment::Curve {
1832                                    to_column,
1833                                    on_row,
1834                                    curve_kind,
1835                                } => {
1836                                    let mut to_column = lane_center_x(
1837                                        bounds,
1838                                        *to_column as f32,
1839                                        horizontal_scroll_offset,
1840                                    );
1841
1842                                    let mut to_row = to_row_center(
1843                                        *on_row - first_visible_row,
1844                                        row_height,
1845                                        vertical_scroll_offset,
1846                                        bounds,
1847                                    );
1848
1849                                    // This means that this branch was a checkout
1850                                    let going_right = to_column > current_column;
1851                                    let column_shift = if going_right {
1852                                        COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
1853                                    } else {
1854                                        -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
1855                                    };
1856
1857                                    let control = match curve_kind {
1858                                        CurveKind::Checkout => {
1859                                            if is_last {
1860                                                to_column -= column_shift;
1861                                            }
1862                                            builder.move_to(point(current_column, current_row));
1863                                            point(current_column, to_row)
1864                                        }
1865                                        CurveKind::Merge => {
1866                                            if is_last {
1867                                                to_row -= COMMIT_CIRCLE_RADIUS;
1868                                            }
1869                                            builder.move_to(point(
1870                                                current_column + column_shift,
1871                                                current_row - COMMIT_CIRCLE_RADIUS,
1872                                            ));
1873                                            point(to_column, current_row)
1874                                        }
1875                                    };
1876
1877                                    match curve_kind {
1878                                        CurveKind::Checkout
1879                                            if (to_row - current_row).abs() > row_height =>
1880                                        {
1881                                            let start_curve =
1882                                                point(current_column, current_row + row_height);
1883                                            builder.line_to(start_curve);
1884                                            builder.move_to(start_curve);
1885                                        }
1886                                        CurveKind::Merge
1887                                            if (to_column - current_column).abs() > LANE_WIDTH =>
1888                                        {
1889                                            let column_shift =
1890                                                if going_right { LANE_WIDTH } else { -LANE_WIDTH };
1891
1892                                            let start_curve = point(
1893                                                current_column + column_shift,
1894                                                current_row - COMMIT_CIRCLE_RADIUS,
1895                                            );
1896
1897                                            builder.line_to(start_curve);
1898                                            builder.move_to(start_curve);
1899                                        }
1900                                        _ => {}
1901                                    };
1902
1903                                    builder.curve_to(point(to_column, to_row), control);
1904                                    current_row = to_row;
1905                                    current_column = to_column;
1906                                    builder.move_to(point(current_column, current_row));
1907                                }
1908                            }
1909                        }
1910
1911                        builder.close();
1912                        lines.entry(line.color_idx).or_default().push(builder);
1913                    }
1914
1915                    for (color_idx, builders) in lines {
1916                        let line_color = accent_colors.color_for_index(color_idx as u32);
1917
1918                        for builder in builders {
1919                            if let Ok(path) = builder.build() {
1920                                // we paint each color on it's own layer to stop overlapping lines
1921                                // of different colors changing the color of a line
1922                                window.paint_layer(bounds, |window| {
1923                                    window.paint_path(path, line_color);
1924                                });
1925                            }
1926                        }
1927                    }
1928                })
1929            },
1930        )
1931        .w(graph_width)
1932        .h_full()
1933    }
1934
1935    fn row_at_position(&self, position_y: Pixels, cx: &Context<Self>) -> Option<usize> {
1936        let canvas_bounds = self.graph_canvas_bounds.get()?;
1937        let table_state = self.table_interaction_state.read(cx);
1938        let scroll_offset_y = -table_state.scroll_offset().y;
1939
1940        let local_y = position_y - canvas_bounds.origin.y;
1941
1942        if local_y >= px(0.) && local_y < canvas_bounds.size.height {
1943            let row_in_viewport = (local_y / self.row_height).floor() as usize;
1944            let scroll_rows = (scroll_offset_y / self.row_height).floor() as usize;
1945            let absolute_row = scroll_rows + row_in_viewport;
1946
1947            if absolute_row < self.graph_data.commits.len() {
1948                return Some(absolute_row);
1949            }
1950        }
1951
1952        None
1953    }
1954
1955    fn handle_graph_mouse_move(
1956        &mut self,
1957        event: &gpui::MouseMoveEvent,
1958        _window: &mut Window,
1959        cx: &mut Context<Self>,
1960    ) {
1961        if let Some(row) = self.row_at_position(event.position.y, cx) {
1962            if self.hovered_entry_idx != Some(row) {
1963                self.hovered_entry_idx = Some(row);
1964                cx.notify();
1965            }
1966        } else if self.hovered_entry_idx.is_some() {
1967            self.hovered_entry_idx = None;
1968            cx.notify();
1969        }
1970    }
1971
1972    fn handle_graph_click(
1973        &mut self,
1974        event: &ClickEvent,
1975        window: &mut Window,
1976        cx: &mut Context<Self>,
1977    ) {
1978        if let Some(row) = self.row_at_position(event.position().y, cx) {
1979            self.select_entry(row, cx);
1980            if event.click_count() >= 2 {
1981                self.open_commit_view(row, window, cx);
1982            }
1983        }
1984    }
1985
1986    fn handle_graph_scroll(
1987        &mut self,
1988        event: &ScrollWheelEvent,
1989        window: &mut Window,
1990        cx: &mut Context<Self>,
1991    ) {
1992        let line_height = window.line_height();
1993        let delta = event.delta.pixel_delta(line_height);
1994
1995        let table_state = self.table_interaction_state.read(cx);
1996        let current_offset = table_state.scroll_offset();
1997
1998        let viewport_height = table_state.scroll_handle.viewport().size.height;
1999
2000        let commit_count = match self.graph_data.max_commit_count {
2001            AllCommitCount::Loaded(count) => count,
2002            AllCommitCount::NotLoaded => self.graph_data.commits.len(),
2003        };
2004        let content_height = self.row_height * commit_count;
2005        let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
2006
2007        let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
2008        let new_offset = Point::new(current_offset.x, new_y);
2009
2010        let max_lanes = self.graph_data.max_lanes.max(1);
2011        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
2012        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
2013
2014        let new_horizontal_offset =
2015            (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
2016
2017        let vertical_changed = new_offset != current_offset;
2018        let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
2019
2020        if vertical_changed {
2021            table_state.set_scroll_offset(new_offset);
2022        }
2023
2024        if horizontal_changed {
2025            self.horizontal_scroll_offset = new_horizontal_offset;
2026        }
2027
2028        if vertical_changed || horizontal_changed {
2029            cx.notify();
2030        }
2031    }
2032
2033    fn render_commit_view_resize_handle(
2034        &self,
2035        _window: &mut Window,
2036        cx: &mut Context<Self>,
2037    ) -> AnyElement {
2038        div()
2039            .id("commit-view-split-resize-container")
2040            .relative()
2041            .h_full()
2042            .flex_shrink_0()
2043            .w(px(1.))
2044            .bg(cx.theme().colors().border_variant)
2045            .child(
2046                div()
2047                    .id("commit-view-split-resize-handle")
2048                    .absolute()
2049                    .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
2050                    .w(px(RESIZE_HANDLE_WIDTH))
2051                    .h_full()
2052                    .cursor_col_resize()
2053                    .block_mouse_except_scroll()
2054                    .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
2055                        if event.click_count() >= 2 {
2056                            this.commit_details_split_state.update(cx, |state, _| {
2057                                state.on_double_click();
2058                            });
2059                        }
2060                        cx.stop_propagation();
2061                    }))
2062                    .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
2063            )
2064            .into_any_element()
2065    }
2066}
2067
2068impl Render for GitGraph {
2069    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2070        let description_width_fraction = 0.72;
2071        let date_width_fraction = 0.12;
2072        let author_width_fraction = 0.10;
2073        let commit_width_fraction = 0.06;
2074
2075        let (commit_count, is_loading) = match self.graph_data.max_commit_count {
2076            AllCommitCount::Loaded(count) => (count, true),
2077            AllCommitCount::NotLoaded => {
2078                let (commit_count, is_loading) =
2079                    if let Some(repository) = self.get_selected_repository(cx) {
2080                        repository.update(cx, |repository, cx| {
2081                            // Start loading the graph data if we haven't started already
2082                            let GraphDataResponse {
2083                                commits,
2084                                is_loading,
2085                                error: _,
2086                            } = repository.graph_data(
2087                                self.log_source.clone(),
2088                                self.log_order,
2089                                0..usize::MAX,
2090                                cx,
2091                            );
2092                            self.graph_data.add_commits(&commits);
2093                            (commits.len(), is_loading)
2094                        })
2095                    } else {
2096                        (0, false)
2097                    };
2098
2099                (commit_count, is_loading)
2100            }
2101        };
2102
2103        let content = if commit_count == 0 {
2104            let message = if is_loading {
2105                "Loading"
2106            } else {
2107                "No commits found"
2108            };
2109            let label = Label::new(message)
2110                .color(Color::Muted)
2111                .size(LabelSize::Large);
2112            div()
2113                .size_full()
2114                .h_flex()
2115                .gap_1()
2116                .items_center()
2117                .justify_center()
2118                .child(label)
2119                .when(is_loading, |this| {
2120                    this.child(self.render_loading_spinner(cx))
2121                })
2122        } else {
2123            div()
2124                .size_full()
2125                .flex()
2126                .flex_row()
2127                .child(
2128                    div()
2129                        .w(self.graph_content_width())
2130                        .h_full()
2131                        .flex()
2132                        .flex_col()
2133                        .child(
2134                            div()
2135                                .p_2()
2136                                .border_b_1()
2137                                .whitespace_nowrap()
2138                                .border_color(cx.theme().colors().border)
2139                                .child(Label::new("Graph").color(Color::Muted)),
2140                        )
2141                        .child(
2142                            div()
2143                                .id("graph-canvas")
2144                                .flex_1()
2145                                .overflow_hidden()
2146                                .child(self.render_graph(window, cx))
2147                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
2148                                .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
2149                                .on_click(cx.listener(Self::handle_graph_click))
2150                                .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
2151                                    if !is_hovered && this.hovered_entry_idx.is_some() {
2152                                        this.hovered_entry_idx = None;
2153                                        cx.notify();
2154                                    }
2155                                })),
2156                        ),
2157                )
2158                .child({
2159                    let row_height = self.row_height;
2160                    let selected_entry_idx = self.selected_entry_idx;
2161                    let hovered_entry_idx = self.hovered_entry_idx;
2162                    let weak_self = cx.weak_entity();
2163                    let focus_handle = self.focus_handle.clone();
2164                    div().flex_1().size_full().child(
2165                        Table::new(4)
2166                            .interactable(&self.table_interaction_state)
2167                            .hide_row_borders()
2168                            .hide_row_hover()
2169                            .header(vec![
2170                                Label::new("Description")
2171                                    .color(Color::Muted)
2172                                    .into_any_element(),
2173                                Label::new("Date").color(Color::Muted).into_any_element(),
2174                                Label::new("Author").color(Color::Muted).into_any_element(),
2175                                Label::new("Commit").color(Color::Muted).into_any_element(),
2176                            ])
2177                            .column_widths(
2178                                [
2179                                    DefiniteLength::Fraction(description_width_fraction),
2180                                    DefiniteLength::Fraction(date_width_fraction),
2181                                    DefiniteLength::Fraction(author_width_fraction),
2182                                    DefiniteLength::Fraction(commit_width_fraction),
2183                                ]
2184                                .to_vec(),
2185                            )
2186                            .resizable_columns(
2187                                vec![
2188                                    TableResizeBehavior::Resizable,
2189                                    TableResizeBehavior::Resizable,
2190                                    TableResizeBehavior::Resizable,
2191                                    TableResizeBehavior::Resizable,
2192                                ],
2193                                &self.table_column_widths,
2194                                cx,
2195                            )
2196                            .map_row(move |(index, row), window, cx| {
2197                                let is_selected = selected_entry_idx == Some(index);
2198                                let is_hovered = hovered_entry_idx == Some(index);
2199                                let is_focused = focus_handle.is_focused(window);
2200                                let weak = weak_self.clone();
2201                                let weak_for_hover = weak.clone();
2202
2203                                let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
2204                                let selected_bg = if is_focused {
2205                                    cx.theme().colors().element_selected
2206                                } else {
2207                                    cx.theme().colors().element_hover
2208                                };
2209
2210                                row.h(row_height)
2211                                    .when(is_selected, |row| row.bg(selected_bg))
2212                                    .when(is_hovered && !is_selected, |row| row.bg(hover_bg))
2213                                    .on_hover(move |&is_hovered, _, cx| {
2214                                        weak_for_hover
2215                                            .update(cx, |this, cx| {
2216                                                if is_hovered {
2217                                                    if this.hovered_entry_idx != Some(index) {
2218                                                        this.hovered_entry_idx = Some(index);
2219                                                        cx.notify();
2220                                                    }
2221                                                } else if this.hovered_entry_idx == Some(index) {
2222                                                    // Only clear if this row was the hovered one
2223                                                    this.hovered_entry_idx = None;
2224                                                    cx.notify();
2225                                                }
2226                                            })
2227                                            .ok();
2228                                    })
2229                                    .on_click(move |event, window, cx| {
2230                                        let click_count = event.click_count();
2231                                        weak.update(cx, |this, cx| {
2232                                            this.select_entry(index, cx);
2233                                            if click_count >= 2 {
2234                                                this.open_commit_view(index, window, cx);
2235                                            }
2236                                        })
2237                                        .ok();
2238                                    })
2239                                    .into_any_element()
2240                            })
2241                            .uniform_list(
2242                                "git-graph-commits",
2243                                commit_count,
2244                                cx.processor(Self::render_table_rows),
2245                            ),
2246                    )
2247                })
2248                .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
2249                    this.commit_details_split_state.update(cx, |state, cx| {
2250                        state.on_drag_move(event, window, cx);
2251                    });
2252                }))
2253                .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
2254                    this.commit_details_split_state.update(cx, |state, _cx| {
2255                        state.commit_ratio();
2256                    });
2257                }))
2258                .when(self.selected_entry_idx.is_some(), |this| {
2259                    this.child(self.render_commit_view_resize_handle(window, cx))
2260                        .child(self.render_commit_detail_panel(window, cx))
2261                })
2262        };
2263
2264        div()
2265            .key_context("GitGraph")
2266            .track_focus(&self.focus_handle)
2267            .size_full()
2268            .bg(cx.theme().colors().editor_background)
2269            .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
2270                this.open_selected_commit_view(window, cx);
2271            }))
2272            .on_action(cx.listener(Self::cancel))
2273            .on_action(cx.listener(Self::select_first))
2274            .on_action(cx.listener(Self::select_prev))
2275            .on_action(cx.listener(Self::select_next))
2276            .on_action(cx.listener(Self::select_last))
2277            .on_action(cx.listener(Self::confirm))
2278            .child(content)
2279            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2280                deferred(
2281                    anchored()
2282                        .position(*position)
2283                        .anchor(Corner::TopLeft)
2284                        .child(menu.clone()),
2285                )
2286                .with_priority(1)
2287            }))
2288    }
2289}
2290
2291impl EventEmitter<ItemEvent> for GitGraph {}
2292
2293impl Focusable for GitGraph {
2294    fn focus_handle(&self, _cx: &App) -> FocusHandle {
2295        self.focus_handle.clone()
2296    }
2297}
2298
2299impl Item for GitGraph {
2300    type Event = ItemEvent;
2301
2302    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
2303        Some(Icon::new(IconName::GitGraph))
2304    }
2305
2306    fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
2307        let repo_name = self.get_selected_repository(cx).and_then(|repo| {
2308            repo.read(cx)
2309                .work_directory_abs_path
2310                .file_name()
2311                .map(|name| name.to_string_lossy().to_string())
2312        });
2313
2314        Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
2315            move |_, _| {
2316                v_flex()
2317                    .child(Label::new("Git Graph"))
2318                    .when_some(repo_name.clone(), |this, name| {
2319                        this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
2320                    })
2321                    .into_any_element()
2322            }
2323        }))))
2324    }
2325
2326    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
2327        self.get_selected_repository(cx)
2328            .and_then(|repo| {
2329                repo.read(cx)
2330                    .work_directory_abs_path
2331                    .file_name()
2332                    .map(|name| name.to_string_lossy().to_string())
2333            })
2334            .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
2335    }
2336
2337    fn show_toolbar(&self) -> bool {
2338        false
2339    }
2340
2341    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
2342        f(*event)
2343    }
2344}
2345
2346impl SerializableItem for GitGraph {
2347    fn serialized_item_kind() -> &'static str {
2348        "GitGraph"
2349    }
2350
2351    fn cleanup(
2352        workspace_id: workspace::WorkspaceId,
2353        alive_items: Vec<workspace::ItemId>,
2354        _window: &mut Window,
2355        cx: &mut App,
2356    ) -> Task<gpui::Result<()>> {
2357        workspace::delete_unloaded_items(
2358            alive_items,
2359            workspace_id,
2360            "git_graphs",
2361            &persistence::GitGraphsDb::global(cx),
2362            cx,
2363        )
2364    }
2365
2366    fn deserialize(
2367        project: Entity<Project>,
2368        workspace: WeakEntity<Workspace>,
2369        workspace_id: workspace::WorkspaceId,
2370        item_id: workspace::ItemId,
2371        window: &mut Window,
2372        cx: &mut App,
2373    ) -> Task<gpui::Result<Entity<Self>>> {
2374        let db = persistence::GitGraphsDb::global(cx);
2375        if db
2376            .get_git_graph(item_id, workspace_id)
2377            .ok()
2378            .is_some_and(|is_open| is_open)
2379        {
2380            let git_graph = cx.new(|cx| GitGraph::new(project, workspace, window, cx));
2381            Task::ready(Ok(git_graph))
2382        } else {
2383            Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
2384        }
2385    }
2386
2387    fn serialize(
2388        &mut self,
2389        workspace: &mut Workspace,
2390        item_id: workspace::ItemId,
2391        _closing: bool,
2392        _window: &mut Window,
2393        cx: &mut Context<Self>,
2394    ) -> Option<Task<gpui::Result<()>>> {
2395        let workspace_id = workspace.database_id()?;
2396        let db = persistence::GitGraphsDb::global(cx);
2397        Some(
2398            cx.background_spawn(
2399                async move { db.save_git_graph(item_id, workspace_id, true).await },
2400            ),
2401        )
2402    }
2403
2404    fn should_serialize(&self, event: &Self::Event) -> bool {
2405        event == &ItemEvent::UpdateTab
2406    }
2407}
2408
2409mod persistence {
2410    use db::{
2411        query,
2412        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
2413        sqlez_macros::sql,
2414    };
2415    use workspace::WorkspaceDb;
2416
2417    pub struct GitGraphsDb(ThreadSafeConnection);
2418
2419    impl Domain for GitGraphsDb {
2420        const NAME: &str = stringify!(GitGraphsDb);
2421
2422        const MIGRATIONS: &[&str] = (&[sql!(
2423            CREATE TABLE git_graphs (
2424                workspace_id INTEGER,
2425                item_id INTEGER UNIQUE,
2426                is_open INTEGER DEFAULT FALSE,
2427
2428                PRIMARY KEY(workspace_id, item_id),
2429                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2430                ON DELETE CASCADE
2431            ) STRICT;
2432        )]);
2433    }
2434
2435    db::static_connection!(GitGraphsDb, [WorkspaceDb]);
2436
2437    impl GitGraphsDb {
2438        query! {
2439            pub async fn save_git_graph(
2440                item_id: workspace::ItemId,
2441                workspace_id: workspace::WorkspaceId,
2442                is_open: bool
2443            ) -> Result<()> {
2444                INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
2445                VALUES (?, ?, ?)
2446            }
2447        }
2448
2449        query! {
2450            pub fn get_git_graph(
2451                item_id: workspace::ItemId,
2452                workspace_id: workspace::WorkspaceId
2453            ) -> Result<bool> {
2454                SELECT is_open
2455                FROM git_graphs
2456                WHERE item_id = ? AND workspace_id = ?
2457            }
2458        }
2459    }
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464    use super::*;
2465    use anyhow::{Context, Result, bail};
2466    use collections::{HashMap, HashSet};
2467    use fs::FakeFs;
2468    use git::Oid;
2469    use git::repository::InitialGraphCommitData;
2470    use gpui::TestAppContext;
2471    use project::Project;
2472    use project::git_store::{GitStoreEvent, RepositoryEvent};
2473    use rand::prelude::*;
2474    use serde_json::json;
2475    use settings::SettingsStore;
2476    use smallvec::{SmallVec, smallvec};
2477    use std::path::Path;
2478    use std::sync::{Arc, Mutex};
2479    use workspace::MultiWorkspace;
2480
2481    fn init_test(cx: &mut TestAppContext) {
2482        cx.update(|cx| {
2483            let settings_store = SettingsStore::test(cx);
2484            cx.set_global(settings_store);
2485        });
2486    }
2487
2488    fn init_test_with_theme(cx: &mut TestAppContext) {
2489        cx.update(|cx| {
2490            let settings_store = SettingsStore::test(cx);
2491            cx.set_global(settings_store);
2492            theme::init(theme::LoadThemes::JustBase, cx);
2493        });
2494    }
2495
2496    /// Generates a random commit DAG suitable for testing git graph rendering.
2497    ///
2498    /// The commits are ordered newest-first (like git log output), so:
2499    /// - Index 0 = most recent commit (HEAD)
2500    /// - Last index = oldest commit (root, has no parents)
2501    /// - Parents of commit at index I must have index > I
2502    ///
2503    /// When `adversarial` is true, generates complex topologies with many branches
2504    /// and octopus merges. Otherwise generates more realistic linear histories
2505    /// with occasional branches.
2506    fn generate_random_commit_dag(
2507        rng: &mut StdRng,
2508        num_commits: usize,
2509        adversarial: bool,
2510    ) -> Vec<Arc<InitialGraphCommitData>> {
2511        if num_commits == 0 {
2512            return Vec::new();
2513        }
2514
2515        let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
2516        let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
2517
2518        for i in 0..num_commits {
2519            let sha = oids[i];
2520
2521            let parents = if i == num_commits - 1 {
2522                smallvec![]
2523            } else {
2524                generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
2525            };
2526
2527            let ref_names = if i == 0 {
2528                vec!["HEAD".into(), "main".into()]
2529            } else if adversarial && rng.random_bool(0.1) {
2530                vec![format!("branch-{}", i).into()]
2531            } else {
2532                Vec::new()
2533            };
2534
2535            commits.push(Arc::new(InitialGraphCommitData {
2536                sha,
2537                parents,
2538                ref_names,
2539            }));
2540        }
2541
2542        commits
2543    }
2544
2545    fn generate_parents_from_oids(
2546        rng: &mut StdRng,
2547        oids: &[Oid],
2548        current_idx: usize,
2549        num_commits: usize,
2550        adversarial: bool,
2551    ) -> SmallVec<[Oid; 1]> {
2552        let remaining = num_commits - current_idx - 1;
2553        if remaining == 0 {
2554            return smallvec![];
2555        }
2556
2557        if adversarial {
2558            let merge_chance = 0.4;
2559            let octopus_chance = 0.15;
2560
2561            if remaining >= 3 && rng.random_bool(octopus_chance) {
2562                let num_parents = rng.random_range(3..=remaining.min(5));
2563                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2564                parent_indices.shuffle(rng);
2565                parent_indices
2566                    .into_iter()
2567                    .take(num_parents)
2568                    .map(|idx| oids[idx])
2569                    .collect()
2570            } else if remaining >= 2 && rng.random_bool(merge_chance) {
2571                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
2572                parent_indices.shuffle(rng);
2573                parent_indices
2574                    .into_iter()
2575                    .take(2)
2576                    .map(|idx| oids[idx])
2577                    .collect()
2578            } else {
2579                let parent_idx = rng.random_range(current_idx + 1..num_commits);
2580                smallvec![oids[parent_idx]]
2581            }
2582        } else {
2583            let merge_chance = 0.15;
2584            let skip_chance = 0.1;
2585
2586            if remaining >= 2 && rng.random_bool(merge_chance) {
2587                let first_parent = current_idx + 1;
2588                let second_parent = rng.random_range(current_idx + 2..num_commits);
2589                smallvec![oids[first_parent], oids[second_parent]]
2590            } else if rng.random_bool(skip_chance) && remaining >= 2 {
2591                let skip = rng.random_range(1..remaining.min(3));
2592                smallvec![oids[current_idx + 1 + skip]]
2593            } else {
2594                smallvec![oids[current_idx + 1]]
2595            }
2596        }
2597    }
2598
2599    fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
2600        graph
2601            .commits
2602            .iter()
2603            .enumerate()
2604            .map(|(idx, entry)| (entry.data.sha, idx))
2605            .collect()
2606    }
2607
2608    fn verify_commit_order(
2609        graph: &GraphData,
2610        commits: &[Arc<InitialGraphCommitData>],
2611    ) -> Result<()> {
2612        if graph.commits.len() != commits.len() {
2613            bail!(
2614                "Commit count mismatch: graph has {} commits, expected {}",
2615                graph.commits.len(),
2616                commits.len()
2617            );
2618        }
2619
2620        for (idx, (graph_commit, expected_commit)) in
2621            graph.commits.iter().zip(commits.iter()).enumerate()
2622        {
2623            if graph_commit.data.sha != expected_commit.sha {
2624                bail!(
2625                    "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
2626                    idx,
2627                    graph_commit.data.sha,
2628                    expected_commit.sha
2629                );
2630            }
2631        }
2632
2633        Ok(())
2634    }
2635
2636    fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
2637        for line in &graph.lines {
2638            let child_row = *oid_to_row
2639                .get(&line.child)
2640                .context("Line references non-existent child commit")?;
2641
2642            let parent_row = *oid_to_row
2643                .get(&line.parent)
2644                .context("Line references non-existent parent commit")?;
2645
2646            if child_row >= parent_row {
2647                bail!(
2648                    "child_row ({}) must be < parent_row ({})",
2649                    child_row,
2650                    parent_row
2651                );
2652            }
2653
2654            if line.full_interval.start != child_row {
2655                bail!(
2656                    "full_interval.start ({}) != child_row ({})",
2657                    line.full_interval.start,
2658                    child_row
2659                );
2660            }
2661
2662            if line.full_interval.end != parent_row {
2663                bail!(
2664                    "full_interval.end ({}) != parent_row ({})",
2665                    line.full_interval.end,
2666                    parent_row
2667                );
2668            }
2669
2670            if let Some(last_segment) = line.segments.last() {
2671                let segment_end_row = match last_segment {
2672                    CommitLineSegment::Straight { to_row } => *to_row,
2673                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2674                };
2675
2676                if segment_end_row != line.full_interval.end {
2677                    bail!(
2678                        "last segment ends at row {} but full_interval.end is {}",
2679                        segment_end_row,
2680                        line.full_interval.end
2681                    );
2682                }
2683            }
2684        }
2685
2686        Ok(())
2687    }
2688
2689    fn verify_column_correctness(
2690        graph: &GraphData,
2691        oid_to_row: &HashMap<Oid, usize>,
2692    ) -> Result<()> {
2693        for line in &graph.lines {
2694            let child_row = *oid_to_row
2695                .get(&line.child)
2696                .context("Line references non-existent child commit")?;
2697
2698            let parent_row = *oid_to_row
2699                .get(&line.parent)
2700                .context("Line references non-existent parent commit")?;
2701
2702            let child_lane = graph.commits[child_row].lane;
2703            if line.child_column != child_lane {
2704                bail!(
2705                    "child_column ({}) != child's lane ({})",
2706                    line.child_column,
2707                    child_lane
2708                );
2709            }
2710
2711            let mut current_column = line.child_column;
2712            for segment in &line.segments {
2713                if let CommitLineSegment::Curve { to_column, .. } = segment {
2714                    current_column = *to_column;
2715                }
2716            }
2717
2718            let parent_lane = graph.commits[parent_row].lane;
2719            if current_column != parent_lane {
2720                bail!(
2721                    "ending column ({}) != parent's lane ({})",
2722                    current_column,
2723                    parent_lane
2724                );
2725            }
2726        }
2727
2728        Ok(())
2729    }
2730
2731    fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
2732        for line in &graph.lines {
2733            if line.segments.is_empty() {
2734                bail!("Line has no segments");
2735            }
2736
2737            let mut current_row = line.full_interval.start;
2738
2739            for (idx, segment) in line.segments.iter().enumerate() {
2740                let segment_end_row = match segment {
2741                    CommitLineSegment::Straight { to_row } => *to_row,
2742                    CommitLineSegment::Curve { on_row, .. } => *on_row,
2743                };
2744
2745                if segment_end_row < current_row {
2746                    bail!(
2747                        "segment {} ends at row {} which is before current row {}",
2748                        idx,
2749                        segment_end_row,
2750                        current_row
2751                    );
2752                }
2753
2754                current_row = segment_end_row;
2755            }
2756        }
2757
2758        Ok(())
2759    }
2760
2761    fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
2762        for line in &graph.lines {
2763            let child_row = line.full_interval.start;
2764
2765            let mut current_column = line.child_column;
2766            let mut current_row = child_row;
2767
2768            for segment in &line.segments {
2769                match segment {
2770                    CommitLineSegment::Straight { to_row } => {
2771                        for row in (current_row + 1)..*to_row {
2772                            if row < graph.commits.len() {
2773                                let commit_at_row = &graph.commits[row];
2774                                if commit_at_row.lane == current_column {
2775                                    bail!(
2776                                        "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
2777                                        current_row,
2778                                        to_row,
2779                                        current_column,
2780                                        commit_at_row.data.sha,
2781                                        row
2782                                    );
2783                                }
2784                            }
2785                        }
2786                        current_row = *to_row;
2787                    }
2788                    CommitLineSegment::Curve {
2789                        to_column, on_row, ..
2790                    } => {
2791                        current_column = *to_column;
2792                        current_row = *on_row;
2793                    }
2794                }
2795            }
2796        }
2797
2798        Ok(())
2799    }
2800
2801    fn verify_coverage(graph: &GraphData) -> Result<()> {
2802        let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
2803        for entry in &graph.commits {
2804            for parent in &entry.data.parents {
2805                expected_edges.insert((entry.data.sha, *parent));
2806            }
2807        }
2808
2809        let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
2810        for line in &graph.lines {
2811            let edge = (line.child, line.parent);
2812
2813            if !found_edges.insert(edge) {
2814                bail!(
2815                    "Duplicate line found for edge {:?} -> {:?}",
2816                    line.child,
2817                    line.parent
2818                );
2819            }
2820
2821            if !expected_edges.contains(&edge) {
2822                bail!(
2823                    "Orphan line found: {:?} -> {:?} is not in the commit graph",
2824                    line.child,
2825                    line.parent
2826                );
2827            }
2828        }
2829
2830        for (child, parent) in &expected_edges {
2831            if !found_edges.contains(&(*child, *parent)) {
2832                bail!("Missing line for edge {:?} -> {:?}", child, parent);
2833            }
2834        }
2835
2836        assert_eq!(
2837            expected_edges.symmetric_difference(&found_edges).count(),
2838            0,
2839            "The symmetric difference should be zero"
2840        );
2841
2842        Ok(())
2843    }
2844
2845    fn verify_merge_line_optimality(
2846        graph: &GraphData,
2847        oid_to_row: &HashMap<Oid, usize>,
2848    ) -> Result<()> {
2849        for line in &graph.lines {
2850            let first_segment = line.segments.first();
2851            let is_merge_line = matches!(
2852                first_segment,
2853                Some(CommitLineSegment::Curve {
2854                    curve_kind: CurveKind::Merge,
2855                    ..
2856                })
2857            );
2858
2859            if !is_merge_line {
2860                continue;
2861            }
2862
2863            let child_row = *oid_to_row
2864                .get(&line.child)
2865                .context("Line references non-existent child commit")?;
2866
2867            let parent_row = *oid_to_row
2868                .get(&line.parent)
2869                .context("Line references non-existent parent commit")?;
2870
2871            let parent_lane = graph.commits[parent_row].lane;
2872
2873            let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
2874                continue;
2875            };
2876
2877            let curves_directly_to_parent = *to_column == parent_lane;
2878
2879            if !curves_directly_to_parent {
2880                continue;
2881            }
2882
2883            let curve_row = child_row + 1;
2884            let has_commits_in_path = graph.commits[curve_row..parent_row]
2885                .iter()
2886                .any(|c| c.lane == parent_lane);
2887
2888            if has_commits_in_path {
2889                bail!(
2890                    "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
2891                    line.child,
2892                    line.parent,
2893                    parent_lane,
2894                    curve_row,
2895                    parent_row
2896                );
2897            }
2898
2899            let curve_ends_at_parent = curve_row == parent_row;
2900
2901            if curve_ends_at_parent {
2902                if line.segments.len() != 1 {
2903                    bail!(
2904                        "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
2905                        line.child,
2906                        line.parent,
2907                        line.segments.len()
2908                    );
2909                }
2910            } else {
2911                if line.segments.len() != 2 {
2912                    bail!(
2913                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
2914                        line.child,
2915                        line.parent,
2916                        line.segments.len()
2917                    );
2918                }
2919
2920                let is_straight_segment = matches!(
2921                    line.segments.get(1),
2922                    Some(CommitLineSegment::Straight { .. })
2923                );
2924
2925                if !is_straight_segment {
2926                    bail!(
2927                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
2928                        line.child,
2929                        line.parent
2930                    );
2931                }
2932            }
2933        }
2934
2935        Ok(())
2936    }
2937
2938    fn verify_all_invariants(
2939        graph: &GraphData,
2940        commits: &[Arc<InitialGraphCommitData>],
2941    ) -> Result<()> {
2942        let oid_to_row = build_oid_to_row_map(graph);
2943
2944        verify_commit_order(graph, commits).context("commit order")?;
2945        verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
2946        verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
2947        verify_segment_continuity(graph).context("segment continuity")?;
2948        verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
2949        verify_coverage(graph).context("coverage")?;
2950        verify_line_overlaps(graph).context("line overlaps")?;
2951        Ok(())
2952    }
2953
2954    #[test]
2955    fn test_git_graph_merge_commits() {
2956        let mut rng = StdRng::seed_from_u64(42);
2957
2958        let oid1 = Oid::random(&mut rng);
2959        let oid2 = Oid::random(&mut rng);
2960        let oid3 = Oid::random(&mut rng);
2961        let oid4 = Oid::random(&mut rng);
2962
2963        let commits = vec![
2964            Arc::new(InitialGraphCommitData {
2965                sha: oid1,
2966                parents: smallvec![oid2, oid3],
2967                ref_names: vec!["HEAD".into()],
2968            }),
2969            Arc::new(InitialGraphCommitData {
2970                sha: oid2,
2971                parents: smallvec![oid4],
2972                ref_names: vec![],
2973            }),
2974            Arc::new(InitialGraphCommitData {
2975                sha: oid3,
2976                parents: smallvec![oid4],
2977                ref_names: vec![],
2978            }),
2979            Arc::new(InitialGraphCommitData {
2980                sha: oid4,
2981                parents: smallvec![],
2982                ref_names: vec![],
2983            }),
2984        ];
2985
2986        let mut graph_data = GraphData::new(8);
2987        graph_data.add_commits(&commits);
2988
2989        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2990            panic!("Graph invariant violation for merge commits:\n{}", error);
2991        }
2992    }
2993
2994    #[test]
2995    fn test_git_graph_linear_commits() {
2996        let mut rng = StdRng::seed_from_u64(42);
2997
2998        let oid1 = Oid::random(&mut rng);
2999        let oid2 = Oid::random(&mut rng);
3000        let oid3 = Oid::random(&mut rng);
3001
3002        let commits = vec![
3003            Arc::new(InitialGraphCommitData {
3004                sha: oid1,
3005                parents: smallvec![oid2],
3006                ref_names: vec!["HEAD".into()],
3007            }),
3008            Arc::new(InitialGraphCommitData {
3009                sha: oid2,
3010                parents: smallvec![oid3],
3011                ref_names: vec![],
3012            }),
3013            Arc::new(InitialGraphCommitData {
3014                sha: oid3,
3015                parents: smallvec![],
3016                ref_names: vec![],
3017            }),
3018        ];
3019
3020        let mut graph_data = GraphData::new(8);
3021        graph_data.add_commits(&commits);
3022
3023        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3024            panic!("Graph invariant violation for linear commits:\n{}", error);
3025        }
3026    }
3027
3028    #[test]
3029    fn test_git_graph_random_commits() {
3030        for seed in 0..100 {
3031            let mut rng = StdRng::seed_from_u64(seed);
3032
3033            let adversarial = rng.random_bool(0.2);
3034            let num_commits = if adversarial {
3035                rng.random_range(10..100)
3036            } else {
3037                rng.random_range(5..50)
3038            };
3039
3040            let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3041
3042            assert_eq!(
3043                num_commits,
3044                commits.len(),
3045                "seed={}: Generate random commit dag didn't generate the correct amount of commits",
3046                seed
3047            );
3048
3049            let mut graph_data = GraphData::new(8);
3050            graph_data.add_commits(&commits);
3051
3052            if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3053                panic!(
3054                    "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
3055                    seed, adversarial, num_commits, error
3056                );
3057            }
3058        }
3059    }
3060
3061    // The full integration test has less iterations because it's significantly slower
3062    // than the random commit test
3063    #[gpui::test(iterations = 10)]
3064    async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
3065        init_test(cx);
3066
3067        let adversarial = rng.random_bool(0.2);
3068        let num_commits = if adversarial {
3069            rng.random_range(10..100)
3070        } else {
3071            rng.random_range(5..50)
3072        };
3073
3074        let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3075
3076        let fs = FakeFs::new(cx.executor());
3077        fs.insert_tree(
3078            Path::new("/project"),
3079            json!({
3080                ".git": {},
3081                "file.txt": "content",
3082            }),
3083        )
3084        .await;
3085
3086        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3087
3088        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3089        cx.run_until_parked();
3090
3091        let repository = project.read_with(cx, |project, cx| {
3092            project
3093                .active_repository(cx)
3094                .expect("should have a repository")
3095        });
3096
3097        repository.update(cx, |repo, cx| {
3098            repo.graph_data(
3099                crate::LogSource::default(),
3100                crate::LogOrder::default(),
3101                0..usize::MAX,
3102                cx,
3103            );
3104        });
3105        cx.run_until_parked();
3106
3107        let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
3108            repo.graph_data(
3109                crate::LogSource::default(),
3110                crate::LogOrder::default(),
3111                0..usize::MAX,
3112                cx,
3113            )
3114            .commits
3115            .to_vec()
3116        });
3117
3118        let mut graph_data = GraphData::new(8);
3119        graph_data.add_commits(&graph_commits);
3120
3121        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3122            panic!(
3123                "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
3124                adversarial, num_commits, error
3125            );
3126        }
3127    }
3128
3129    #[gpui::test]
3130    async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
3131        init_test(cx);
3132
3133        let fs = FakeFs::new(cx.executor());
3134        fs.insert_tree(
3135            Path::new("/project"),
3136            json!({
3137                ".git": {},
3138                "file.txt": "content",
3139            }),
3140        )
3141        .await;
3142
3143        let mut rng = StdRng::seed_from_u64(42);
3144        let commits = generate_random_commit_dag(&mut rng, 10, false);
3145        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3146
3147        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3148        let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
3149        project.update(cx, |project, cx| {
3150            let observed_repository_events = observed_repository_events.clone();
3151            cx.subscribe(project.git_store(), move |_, _, event, _| {
3152                if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
3153                    observed_repository_events
3154                        .lock()
3155                        .expect("repository event mutex should be available")
3156                        .push(repository_event.clone());
3157                }
3158            })
3159            .detach();
3160        });
3161
3162        let repository = project.read_with(cx, |project, cx| {
3163            project
3164                .active_repository(cx)
3165                .expect("should have a repository")
3166        });
3167
3168        repository.update(cx, |repo, cx| {
3169            repo.graph_data(
3170                crate::LogSource::default(),
3171                crate::LogOrder::default(),
3172                0..usize::MAX,
3173                cx,
3174            );
3175        });
3176
3177        project
3178            .update(cx, |project, cx| project.git_scans_complete(cx))
3179            .await;
3180        cx.run_until_parked();
3181
3182        let observed_repository_events = observed_repository_events
3183            .lock()
3184            .expect("repository event mutex should be available");
3185        assert!(
3186            observed_repository_events
3187                .iter()
3188                .any(|event| matches!(event, RepositoryEvent::BranchChanged)),
3189            "initial repository scan should emit BranchChanged"
3190        );
3191        let commit_count_after = repository.read_with(cx, |repo, _| {
3192            repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
3193                .map(|data| data.commit_data.len())
3194                .unwrap()
3195        });
3196        assert_eq!(
3197            commits.len(),
3198            commit_count_after,
3199            "initial_graph_data should remain populated after events emitted by initial repository scan"
3200        );
3201    }
3202
3203    #[gpui::test]
3204    async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
3205        init_test_with_theme(cx);
3206
3207        let fs = FakeFs::new(cx.executor());
3208        fs.insert_tree(
3209            Path::new("/project_a"),
3210            json!({
3211                ".git": {},
3212                "file.txt": "content",
3213            }),
3214        )
3215        .await;
3216        fs.insert_tree(
3217            Path::new("/project_b"),
3218            json!({
3219                ".git": {},
3220                "other.txt": "content",
3221            }),
3222        )
3223        .await;
3224
3225        let mut rng = StdRng::seed_from_u64(42);
3226        let commits = generate_random_commit_dag(&mut rng, 10, false);
3227        fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
3228
3229        let project = Project::test(
3230            fs.clone(),
3231            [Path::new("/project_a"), Path::new("/project_b")],
3232            cx,
3233        )
3234        .await;
3235        cx.run_until_parked();
3236
3237        let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
3238            let mut first_repository = None;
3239            let mut second_repository = None;
3240
3241            for repository in project.repositories(cx).values() {
3242                let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
3243                if work_directory_abs_path.as_ref() == Path::new("/project_a") {
3244                    first_repository = Some(repository.clone());
3245                } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
3246                    second_repository = Some(repository.clone());
3247                }
3248            }
3249
3250            (
3251                first_repository.expect("should have repository for /project_a"),
3252                second_repository.expect("should have repository for /project_b"),
3253            )
3254        });
3255        first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
3256        cx.run_until_parked();
3257
3258        let (multi_workspace, cx) =
3259            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3260
3261        let workspace_weak =
3262            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
3263        let git_graph = cx.new_window_entity(|window, cx| {
3264            GitGraph::new(project.clone(), workspace_weak, window, cx)
3265        });
3266        cx.run_until_parked();
3267
3268        // Verify initial graph data is loaded
3269        let initial_commit_count =
3270            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3271        assert!(
3272            initial_commit_count > 0,
3273            "graph data should have been loaded, got 0 commits"
3274        );
3275
3276        second_repository.update(&mut *cx, |repository, cx| {
3277            repository.set_as_active_repository(cx)
3278        });
3279        cx.run_until_parked();
3280
3281        let commit_count_after_clear =
3282            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3283        assert_eq!(
3284            commit_count_after_clear, 0,
3285            "graph_data should be cleared after switching away"
3286        );
3287
3288        first_repository.update(&mut *cx, |repository, cx| {
3289            repository.set_as_active_repository(cx)
3290        });
3291
3292        git_graph.update_in(&mut *cx, |this, window, cx| {
3293            this.render(window, cx);
3294        });
3295        cx.run_until_parked();
3296
3297        let commit_count_after_switch_back =
3298            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3299        assert_eq!(
3300            initial_commit_count, commit_count_after_switch_back,
3301            "graph_data should be repopulated from cache after switching back to the same repo"
3302        );
3303    }
3304}