git_graph.rs

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