git_graph.rs

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