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