git_graph.rs

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