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, false, 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
1274                        .fetch_commit_data(commit.data.sha, false, cx)
1275                        .clone()
1276                });
1277
1278                let short_sha = commit.data.sha.display_short();
1279                let mut formatted_time = String::new();
1280                let subject: SharedString;
1281                let author_name: SharedString;
1282
1283                if let CommitDataState::Loaded(data) = data {
1284                    subject = data.subject.clone();
1285                    author_name = data.author_name.clone();
1286                    formatted_time = format_timestamp(data.commit_timestamp);
1287                } else {
1288                    subject = "Loading…".into();
1289                    author_name = "".into();
1290                }
1291
1292                let accent_colors = cx.theme().accents();
1293                let accent_color = accent_colors
1294                    .0
1295                    .get(commit.color_idx)
1296                    .copied()
1297                    .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1298
1299                let is_selected = self.selected_entry_idx == Some(idx);
1300                let is_matched = self.search_state.matches.contains(&commit.data.sha);
1301                let column_label = |label: SharedString| {
1302                    Label::new(label)
1303                        .when(!is_selected, |c| c.color(Color::Muted))
1304                        .truncate()
1305                        .into_any_element()
1306                };
1307
1308                let subject_label = if is_matched {
1309                    let query = match &self.search_state.state {
1310                        QueryState::Confirmed((query, _)) => Some(query.clone()),
1311                        _ => None,
1312                    };
1313                    let highlight_ranges = query
1314                        .and_then(|q| {
1315                            let ranges = if self.search_state.case_sensitive {
1316                                subject
1317                                    .match_indices(q.as_str())
1318                                    .map(|(start, matched)| start..start + matched.len())
1319                                    .collect::<Vec<_>>()
1320                            } else {
1321                                let q = q.to_lowercase();
1322                                let subject_lower = subject.to_lowercase();
1323
1324                                subject_lower
1325                                    .match_indices(&q)
1326                                    .filter_map(|(start, matched)| {
1327                                        let end = start + matched.len();
1328                                        subject.is_char_boundary(start).then_some(()).and_then(
1329                                            |_| subject.is_char_boundary(end).then_some(start..end),
1330                                        )
1331                                    })
1332                                    .collect::<Vec<_>>()
1333                            };
1334
1335                            (!ranges.is_empty()).then_some(ranges)
1336                        })
1337                        .unwrap_or_default();
1338                    HighlightedLabel::from_ranges(subject.clone(), highlight_ranges)
1339                        .when(!is_selected, |c| c.color(Color::Muted))
1340                        .truncate()
1341                        .into_any_element()
1342                } else {
1343                    column_label(subject.clone())
1344                };
1345
1346                vec![
1347                    div()
1348                        .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
1349                        .overflow_hidden()
1350                        .tooltip(Tooltip::text(subject))
1351                        .child(
1352                            h_flex()
1353                                .gap_2()
1354                                .overflow_hidden()
1355                                .children((!commit.data.ref_names.is_empty()).then(|| {
1356                                    h_flex().gap_1().children(commit.data.ref_names.iter().map(
1357                                        |name| {
1358                                            let is_head =
1359                                                Self::is_head_ref(name.as_ref(), &head_branch_name);
1360                                            self.render_chip(name, accent_color, is_head)
1361                                        },
1362                                    ))
1363                                }))
1364                                .child(subject_label),
1365                        )
1366                        .into_any_element(),
1367                    column_label(formatted_time.into()),
1368                    column_label(author_name),
1369                    column_label(short_sha.into()),
1370                ]
1371            })
1372            .collect()
1373    }
1374
1375    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
1376        self.selected_entry_idx = None;
1377        self.selected_commit_diff = None;
1378        self.selected_commit_diff_stats = None;
1379        cx.notify();
1380    }
1381
1382    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1383        self.select_entry(0, ScrollStrategy::Nearest, cx);
1384    }
1385
1386    fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1387        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1388            self.select_entry(
1389                selected_entry_idx.saturating_sub(1),
1390                ScrollStrategy::Nearest,
1391                cx,
1392            );
1393        } else {
1394            self.select_first(&SelectFirst, window, cx);
1395        }
1396    }
1397
1398    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1399        if let Some(selected_entry_idx) = &self.selected_entry_idx {
1400            self.select_entry(
1401                selected_entry_idx
1402                    .saturating_add(1)
1403                    .min(self.graph_data.commits.len().saturating_sub(1)),
1404                ScrollStrategy::Nearest,
1405                cx,
1406            );
1407        } else {
1408            self.select_prev(&SelectPrevious, window, cx);
1409        }
1410    }
1411
1412    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1413        self.select_entry(
1414            self.graph_data.commits.len().saturating_sub(1),
1415            ScrollStrategy::Nearest,
1416            cx,
1417        );
1418    }
1419
1420    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1421        self.open_selected_commit_view(window, cx);
1422    }
1423
1424    fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
1425        let Some(repo) = self.get_repository(cx) else {
1426            return;
1427        };
1428
1429        self.search_state.matches.clear();
1430        self.search_state.selected_index = None;
1431        self.search_state.editor.update(cx, |editor, _cx| {
1432            editor.set_text_style_refinement(Default::default());
1433        });
1434
1435        if query.as_str().is_empty() {
1436            self.search_state.state = QueryState::Empty;
1437            cx.notify();
1438            return;
1439        }
1440
1441        let (request_tx, request_rx) = smol::channel::unbounded::<Oid>();
1442
1443        repo.update(cx, |repo, cx| {
1444            repo.search_commits(
1445                self.log_source.clone(),
1446                SearchCommitArgs {
1447                    query: query.clone(),
1448                    case_sensitive: self.search_state.case_sensitive,
1449                },
1450                request_tx,
1451                cx,
1452            );
1453        });
1454
1455        let search_task = cx.spawn(async move |this, cx| {
1456            while let Ok(first_oid) = request_rx.recv().await {
1457                let mut pending_oids = vec![first_oid];
1458                while let Ok(oid) = request_rx.try_recv() {
1459                    pending_oids.push(oid);
1460                }
1461
1462                this.update(cx, |this, cx| {
1463                    if this.search_state.selected_index.is_none() {
1464                        this.search_state.selected_index = Some(0);
1465                        this.select_commit_by_sha(first_oid, cx);
1466                    }
1467
1468                    this.search_state.matches.extend(pending_oids);
1469                    cx.notify();
1470                })
1471                .ok();
1472            }
1473
1474            this.update(cx, |this, cx| {
1475                if this.search_state.matches.is_empty() {
1476                    this.search_state.editor.update(cx, |editor, cx| {
1477                        editor.set_text_style_refinement(TextStyleRefinement {
1478                            color: Some(Color::Error.color(cx)),
1479                            ..Default::default()
1480                        });
1481                    });
1482                }
1483            })
1484            .ok();
1485        });
1486
1487        self.search_state.state = QueryState::Confirmed((query, search_task));
1488    }
1489
1490    fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
1491        let query = self.search_state.editor.read(cx).text(cx).into();
1492        self.search(query, cx);
1493    }
1494
1495    fn select_entry(
1496        &mut self,
1497        idx: usize,
1498        scroll_strategy: ScrollStrategy,
1499        cx: &mut Context<Self>,
1500    ) {
1501        if self.selected_entry_idx == Some(idx) {
1502            return;
1503        }
1504
1505        self.selected_entry_idx = Some(idx);
1506        self.selected_commit_diff = None;
1507        self.selected_commit_diff_stats = None;
1508        self.changed_files_scroll_handle
1509            .scroll_to_item(0, ScrollStrategy::Top);
1510        self.table_interaction_state.update(cx, |state, cx| {
1511            state.scroll_handle.scroll_to_item(idx, scroll_strategy);
1512            cx.notify();
1513        });
1514
1515        let Some(commit) = self.graph_data.commits.get(idx) else {
1516            return;
1517        };
1518
1519        let sha = commit.data.sha.to_string();
1520
1521        let Some(repository) = self.get_repository(cx) else {
1522            return;
1523        };
1524
1525        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
1526
1527        self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
1528            if let Ok(Ok(diff)) = diff_receiver.await {
1529                this.update(cx, |this, cx| {
1530                    let stats = compute_diff_stats(&diff);
1531                    this.selected_commit_diff = Some(diff);
1532                    this.selected_commit_diff_stats = Some(stats);
1533                    cx.notify();
1534                })
1535                .ok();
1536            }
1537        }));
1538
1539        cx.notify();
1540    }
1541
1542    fn select_previous_match(&mut self, cx: &mut Context<Self>) {
1543        if self.search_state.matches.is_empty() {
1544            return;
1545        }
1546
1547        let mut prev_selection = self.search_state.selected_index.unwrap_or_default();
1548
1549        if prev_selection == 0 {
1550            prev_selection = self.search_state.matches.len() - 1;
1551        } else {
1552            prev_selection -= 1;
1553        }
1554
1555        let Some(&oid) = self.search_state.matches.get_index(prev_selection) else {
1556            return;
1557        };
1558
1559        self.search_state.selected_index = Some(prev_selection);
1560        self.select_commit_by_sha(oid, cx);
1561    }
1562
1563    fn select_next_match(&mut self, cx: &mut Context<Self>) {
1564        if self.search_state.matches.is_empty() {
1565            return;
1566        }
1567
1568        let mut next_selection = self
1569            .search_state
1570            .selected_index
1571            .map(|index| index + 1)
1572            .unwrap_or_default();
1573
1574        if next_selection >= self.search_state.matches.len() {
1575            next_selection = 0;
1576        }
1577
1578        let Some(&oid) = self.search_state.matches.get_index(next_selection) else {
1579            return;
1580        };
1581
1582        self.search_state.selected_index = Some(next_selection);
1583        self.select_commit_by_sha(oid, cx);
1584    }
1585
1586    pub fn set_repo_id(&mut self, repo_id: RepositoryId, cx: &mut Context<Self>) {
1587        if repo_id != self.repo_id
1588            && self
1589                .git_store
1590                .read(cx)
1591                .repositories()
1592                .contains_key(&repo_id)
1593        {
1594            self.repo_id = repo_id;
1595            self.invalidate_state(cx);
1596        }
1597    }
1598
1599    pub fn select_commit_by_sha(&mut self, sha: impl TryInto<Oid>, cx: &mut Context<Self>) {
1600        fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
1601            let Some(selected_repository) = this.get_repository(cx) else {
1602                return;
1603            };
1604
1605            let Some(index) = selected_repository
1606                .read(cx)
1607                .get_graph_data(this.log_source.clone(), this.log_order)
1608                .and_then(|data| data.commit_oid_to_index.get(&oid))
1609                .copied()
1610            else {
1611                return;
1612            };
1613
1614            this.select_entry(index, ScrollStrategy::Center, cx);
1615        }
1616
1617        if let Ok(oid) = sha.try_into() {
1618            inner(self, oid, cx);
1619        }
1620    }
1621
1622    fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1623        let Some(selected_entry_index) = self.selected_entry_idx else {
1624            return;
1625        };
1626
1627        self.open_commit_view(selected_entry_index, window, cx);
1628    }
1629
1630    fn open_commit_view(
1631        &mut self,
1632        entry_index: usize,
1633        window: &mut Window,
1634        cx: &mut Context<Self>,
1635    ) {
1636        let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
1637            return;
1638        };
1639
1640        let Some(repository) = self.get_repository(cx) else {
1641            return;
1642        };
1643
1644        CommitView::open(
1645            commit_entry.data.sha.to_string(),
1646            repository.downgrade(),
1647            self.workspace.clone(),
1648            None,
1649            None,
1650            window,
1651            cx,
1652        );
1653    }
1654
1655    fn get_remote(
1656        &self,
1657        repository: &Repository,
1658        _window: &mut Window,
1659        cx: &mut App,
1660    ) -> Option<GitRemote> {
1661        let remote_url = repository.default_remote_url()?;
1662        let provider_registry = GitHostingProviderRegistry::default_global(cx);
1663        let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
1664        Some(GitRemote {
1665            host: provider,
1666            owner: parsed.owner.into(),
1667            repo: parsed.repo.into(),
1668        })
1669    }
1670
1671    fn render_search_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1672        let color = cx.theme().colors();
1673        let query_focus_handle = self.search_state.editor.focus_handle(cx);
1674        let search_options = {
1675            let mut options = SearchOptions::NONE;
1676            options.set(
1677                SearchOptions::CASE_SENSITIVE,
1678                self.search_state.case_sensitive,
1679            );
1680            options
1681        };
1682
1683        h_flex()
1684            .w_full()
1685            .p_1p5()
1686            .gap_1p5()
1687            .border_b_1()
1688            .border_color(color.border_variant)
1689            .child(
1690                h_flex()
1691                    .h_8()
1692                    .flex_1()
1693                    .min_w_0()
1694                    .px_1p5()
1695                    .gap_1()
1696                    .border_1()
1697                    .border_color(color.border_variant)
1698                    .rounded_md()
1699                    .bg(color.toolbar_background)
1700                    .on_action(cx.listener(Self::confirm_search))
1701                    .child(self.search_state.editor.clone())
1702                    .child(SearchOption::CaseSensitive.as_button(
1703                        search_options,
1704                        SearchSource::Buffer,
1705                        query_focus_handle,
1706                    )),
1707            )
1708            .child(
1709                h_flex()
1710                    .min_w_64()
1711                    .gap_1()
1712                    .child({
1713                        let focus_handle = self.focus_handle.clone();
1714                        IconButton::new("git-graph-search-prev", IconName::ChevronLeft)
1715                            .shape(ui::IconButtonShape::Square)
1716                            .icon_size(IconSize::Small)
1717                            .tooltip(move |_, cx| {
1718                                Tooltip::for_action_in(
1719                                    "Select Previous Match",
1720                                    &SelectPreviousMatch,
1721                                    &focus_handle,
1722                                    cx,
1723                                )
1724                            })
1725                            .map(|this| {
1726                                if self.search_state.matches.is_empty() {
1727                                    this.disabled(true)
1728                                } else {
1729                                    this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
1730                                        this.select_previous_match(cx);
1731                                    }))
1732                                }
1733                            })
1734                    })
1735                    .child({
1736                        let focus_handle = self.focus_handle.clone();
1737                        IconButton::new("git-graph-search-next", IconName::ChevronRight)
1738                            .shape(ui::IconButtonShape::Square)
1739                            .icon_size(IconSize::Small)
1740                            .tooltip(move |_, cx| {
1741                                Tooltip::for_action_in(
1742                                    "Select Next Match",
1743                                    &SelectNextMatch,
1744                                    &focus_handle,
1745                                    cx,
1746                                )
1747                            })
1748                            .map(|this| {
1749                                if self.search_state.matches.is_empty() {
1750                                    this.disabled(true)
1751                                } else {
1752                                    this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
1753                                        this.select_next_match(cx);
1754                                    }))
1755                                }
1756                            })
1757                    })
1758                    .child(
1759                        h_flex()
1760                            .gap_1p5()
1761                            .child(
1762                                Label::new(format!(
1763                                    "{}/{}",
1764                                    self.search_state
1765                                        .selected_index
1766                                        .map(|index| index + 1)
1767                                        .unwrap_or(0),
1768                                    self.search_state.matches.len()
1769                                ))
1770                                .size(LabelSize::Small)
1771                                .when(self.search_state.matches.is_empty(), |this| {
1772                                    this.color(Color::Disabled)
1773                                }),
1774                            )
1775                            .when(
1776                                matches!(
1777                                    &self.search_state.state,
1778                                    QueryState::Confirmed((_, task)) if !task.is_ready()
1779                                ),
1780                                |this| {
1781                                    this.child(
1782                                        Icon::new(IconName::ArrowCircle)
1783                                            .color(Color::Accent)
1784                                            .size(IconSize::Small)
1785                                            .with_rotate_animation(2)
1786                                            .into_any_element(),
1787                                    )
1788                                },
1789                            ),
1790                    ),
1791            )
1792    }
1793
1794    fn render_loading_spinner(&self, cx: &App) -> AnyElement {
1795        let rems = TextSize::Large.rems(cx);
1796        Icon::new(IconName::LoadCircle)
1797            .size(IconSize::Custom(rems))
1798            .color(Color::Accent)
1799            .with_rotate_animation(3)
1800            .into_any_element()
1801    }
1802
1803    fn render_commit_detail_panel(
1804        &self,
1805        window: &mut Window,
1806        cx: &mut Context<Self>,
1807    ) -> impl IntoElement {
1808        let Some(selected_idx) = self.selected_entry_idx else {
1809            return Empty.into_any_element();
1810        };
1811
1812        let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
1813            return Empty.into_any_element();
1814        };
1815
1816        let Some(repository) = self.get_repository(cx) else {
1817            return Empty.into_any_element();
1818        };
1819
1820        let data = repository.update(cx, |repository, cx| {
1821            repository
1822                .fetch_commit_data(commit_entry.data.sha, false, cx)
1823                .clone()
1824        });
1825
1826        let full_sha: SharedString = commit_entry.data.sha.to_string().into();
1827        let ref_names = commit_entry.data.ref_names.clone();
1828
1829        let head_branch_name: Option<SharedString> = repository
1830            .read(cx)
1831            .snapshot()
1832            .branch
1833            .as_ref()
1834            .map(|branch| SharedString::from(branch.name().to_string()));
1835
1836        let accent_colors = cx.theme().accents();
1837        let accent_color = accent_colors
1838            .0
1839            .get(commit_entry.color_idx)
1840            .copied()
1841            .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
1842
1843        // todo(git graph): We should use the full commit message here
1844        let (author_name, author_email, commit_timestamp, commit_message) = match &data {
1845            CommitDataState::Loaded(data) => (
1846                data.author_name.clone(),
1847                data.author_email.clone(),
1848                Some(data.commit_timestamp),
1849                data.subject.clone(),
1850            ),
1851            CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None, "Loading…".into()),
1852        };
1853
1854        let date_string = commit_timestamp
1855            .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
1856            .map(|datetime| {
1857                let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
1858                let local_datetime = datetime.to_offset(local_offset);
1859                let format =
1860                    time::format_description::parse("[month repr:short] [day], [year]").ok();
1861                format
1862                    .and_then(|f| local_datetime.format(&f).ok())
1863                    .unwrap_or_default()
1864            })
1865            .unwrap_or_default();
1866
1867        let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
1868
1869        let avatar = {
1870            let author_email_for_avatar = if author_email.is_empty() {
1871                None
1872            } else {
1873                Some(author_email.clone())
1874            };
1875
1876            CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
1877                .size(px(40.))
1878                .render(window, cx)
1879        };
1880
1881        let changed_files_count = self
1882            .selected_commit_diff
1883            .as_ref()
1884            .map(|diff| diff.files.len())
1885            .unwrap_or(0);
1886
1887        let (total_lines_added, total_lines_removed) =
1888            self.selected_commit_diff_stats.unwrap_or((0, 0));
1889
1890        let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
1891            self.selected_commit_diff
1892                .as_ref()
1893                .map(|diff| {
1894                    let mut files: Vec<_> = diff.files.iter().collect();
1895                    files.sort_by_key(|file| file.status());
1896                    files
1897                        .into_iter()
1898                        .map(|file| ChangedFileEntry::from_commit_file(file, cx))
1899                        .collect()
1900                })
1901                .unwrap_or_default(),
1902        );
1903
1904        v_flex()
1905            .min_w(px(300.))
1906            .h_full()
1907            .bg(cx.theme().colors().editor_background)
1908            .flex_basis(DefiniteLength::Fraction(
1909                self.commit_details_split_state.read(cx).right_ratio(),
1910            ))
1911            .child(
1912                v_flex()
1913                    .relative()
1914                    .w_full()
1915                    .p_2()
1916                    .gap_2()
1917                    .child(
1918                        div().absolute().top_2().right_2().child(
1919                            IconButton::new("close-detail", IconName::Close)
1920                                .icon_size(IconSize::Small)
1921                                .on_click(cx.listener(move |this, _, _, cx| {
1922                                    this.selected_entry_idx = None;
1923                                    this.selected_commit_diff = None;
1924                                    this.selected_commit_diff_stats = None;
1925                                    this._commit_diff_task = None;
1926                                    cx.notify();
1927                                })),
1928                        ),
1929                    )
1930                    .child(
1931                        v_flex()
1932                            .py_1()
1933                            .w_full()
1934                            .items_center()
1935                            .gap_1()
1936                            .child(avatar)
1937                            .child(
1938                                v_flex()
1939                                    .items_center()
1940                                    .child(Label::new(author_name))
1941                                    .child(
1942                                        Label::new(date_string)
1943                                            .color(Color::Muted)
1944                                            .size(LabelSize::Small),
1945                                    ),
1946                            ),
1947                    )
1948                    .children((!ref_names.is_empty()).then(|| {
1949                        h_flex().gap_1().flex_wrap().justify_center().children(
1950                            ref_names.iter().map(|name| {
1951                                let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name);
1952                                self.render_chip(name, accent_color, is_head)
1953                            }),
1954                        )
1955                    }))
1956                    .child(
1957                        v_flex()
1958                            .ml_neg_1()
1959                            .gap_1p5()
1960                            .when(!author_email.is_empty(), |this| {
1961                                let copied_state: Entity<CopiedState> = window.use_keyed_state(
1962                                    "author-email-copy",
1963                                    cx,
1964                                    CopiedState::new,
1965                                );
1966                                let is_copied = copied_state.read(cx).is_copied();
1967
1968                                let (icon, icon_color, tooltip_label) = if is_copied {
1969                                    (IconName::Check, Color::Success, "Email Copied!")
1970                                } else {
1971                                    (IconName::Envelope, Color::Muted, "Copy Email")
1972                                };
1973
1974                                let copy_email = author_email.clone();
1975                                let author_email_for_tooltip = author_email.clone();
1976
1977                                this.child(
1978                                    Button::new("author-email-copy", author_email.clone())
1979                                        .start_icon(
1980                                            Icon::new(icon).size(IconSize::Small).color(icon_color),
1981                                        )
1982                                        .label_size(LabelSize::Small)
1983                                        .truncate(true)
1984                                        .color(Color::Muted)
1985                                        .tooltip(move |_, cx| {
1986                                            Tooltip::with_meta(
1987                                                tooltip_label,
1988                                                None,
1989                                                author_email_for_tooltip.clone(),
1990                                                cx,
1991                                            )
1992                                        })
1993                                        .on_click(move |_, _, cx| {
1994                                            copied_state.update(cx, |state, _cx| {
1995                                                state.mark_copied();
1996                                            });
1997                                            cx.write_to_clipboard(ClipboardItem::new_string(
1998                                                copy_email.to_string(),
1999                                            ));
2000                                            let state_id = copied_state.entity_id();
2001                                            cx.spawn(async move |cx| {
2002                                                cx.background_executor()
2003                                                    .timer(COPIED_STATE_DURATION)
2004                                                    .await;
2005                                                cx.update(|cx| {
2006                                                    cx.notify(state_id);
2007                                                })
2008                                            })
2009                                            .detach();
2010                                        }),
2011                                )
2012                            })
2013                            .child({
2014                                let copy_sha = full_sha.clone();
2015                                let copied_state: Entity<CopiedState> =
2016                                    window.use_keyed_state("sha-copy", cx, CopiedState::new);
2017                                let is_copied = copied_state.read(cx).is_copied();
2018
2019                                let (icon, icon_color, tooltip_label) = if is_copied {
2020                                    (IconName::Check, Color::Success, "Commit SHA Copied!")
2021                                } else {
2022                                    (IconName::Hash, Color::Muted, "Copy Commit SHA")
2023                                };
2024
2025                                Button::new("sha-button", &full_sha)
2026                                    .start_icon(
2027                                        Icon::new(icon).size(IconSize::Small).color(icon_color),
2028                                    )
2029                                    .label_size(LabelSize::Small)
2030                                    .truncate(true)
2031                                    .color(Color::Muted)
2032                                    .tooltip({
2033                                        let full_sha = full_sha.clone();
2034                                        move |_, cx| {
2035                                            Tooltip::with_meta(
2036                                                tooltip_label,
2037                                                None,
2038                                                full_sha.clone(),
2039                                                cx,
2040                                            )
2041                                        }
2042                                    })
2043                                    .on_click(move |_, _, cx| {
2044                                        copied_state.update(cx, |state, _cx| {
2045                                            state.mark_copied();
2046                                        });
2047                                        cx.write_to_clipboard(ClipboardItem::new_string(
2048                                            copy_sha.to_string(),
2049                                        ));
2050                                        let state_id = copied_state.entity_id();
2051                                        cx.spawn(async move |cx| {
2052                                            cx.background_executor()
2053                                                .timer(COPIED_STATE_DURATION)
2054                                                .await;
2055                                            cx.update(|cx| {
2056                                                cx.notify(state_id);
2057                                            })
2058                                        })
2059                                        .detach();
2060                                    })
2061                            })
2062                            .when_some(remote.clone(), |this, remote| {
2063                                let provider_name = remote.host.name();
2064                                let icon = match provider_name.as_str() {
2065                                    "GitHub" => IconName::Github,
2066                                    _ => IconName::Link,
2067                                };
2068                                let parsed_remote = ParsedGitRemote {
2069                                    owner: remote.owner.as_ref().into(),
2070                                    repo: remote.repo.as_ref().into(),
2071                                };
2072                                let params = BuildCommitPermalinkParams {
2073                                    sha: full_sha.as_ref(),
2074                                };
2075                                let url = remote
2076                                    .host
2077                                    .build_commit_permalink(&parsed_remote, params)
2078                                    .to_string();
2079
2080                                this.child(
2081                                    Button::new(
2082                                        "view-on-provider",
2083                                        format!("View on {}", provider_name),
2084                                    )
2085                                    .start_icon(
2086                                        Icon::new(icon).size(IconSize::Small).color(Color::Muted),
2087                                    )
2088                                    .label_size(LabelSize::Small)
2089                                    .truncate(true)
2090                                    .color(Color::Muted)
2091                                    .on_click(
2092                                        move |_, _, cx| {
2093                                            cx.open_url(&url);
2094                                        },
2095                                    ),
2096                                )
2097                            }),
2098                    ),
2099            )
2100            .child(Divider::horizontal())
2101            .child(div().p_2().child(Label::new(commit_message)))
2102            .child(Divider::horizontal())
2103            .child(
2104                v_flex()
2105                    .min_w_0()
2106                    .p_2()
2107                    .flex_1()
2108                    .gap_1()
2109                    .child(
2110                        h_flex()
2111                            .gap_1()
2112                            .w_full()
2113                            .justify_between()
2114                            .child(
2115                                Label::new(format!(
2116                                    "{} Changed {}",
2117                                    changed_files_count,
2118                                    if changed_files_count == 1 {
2119                                        "File"
2120                                    } else {
2121                                        "Files"
2122                                    }
2123                                ))
2124                                .size(LabelSize::Small)
2125                                .color(Color::Muted),
2126                            )
2127                            .child(DiffStat::new(
2128                                "commit-diff-stat",
2129                                total_lines_added,
2130                                total_lines_removed,
2131                            )),
2132                    )
2133                    .child(
2134                        div()
2135                            .id("changed-files-container")
2136                            .flex_1()
2137                            .min_h_0()
2138                            .child({
2139                                let entries = sorted_file_entries;
2140                                let entry_count = entries.len();
2141                                let commit_sha = full_sha.clone();
2142                                let repository = repository.downgrade();
2143                                let workspace = self.workspace.clone();
2144                                uniform_list(
2145                                    "changed-files-list",
2146                                    entry_count,
2147                                    move |range, _window, cx| {
2148                                        range
2149                                            .map(|ix| {
2150                                                entries[ix].render(
2151                                                    ix,
2152                                                    commit_sha.clone(),
2153                                                    repository.clone(),
2154                                                    workspace.clone(),
2155                                                    cx,
2156                                                )
2157                                            })
2158                                            .collect()
2159                                    },
2160                                )
2161                                .size_full()
2162                                .ml_neg_1()
2163                                .track_scroll(&self.changed_files_scroll_handle)
2164                            })
2165                            .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
2166                    ),
2167            )
2168            .child(Divider::horizontal())
2169            .child(
2170                h_flex().p_1p5().w_full().child(
2171                    Button::new("view-commit", "View Commit")
2172                        .full_width()
2173                        .style(ButtonStyle::OutlinedGhost)
2174                        .on_click(cx.listener(|this, _, window, cx| {
2175                            this.open_selected_commit_view(window, cx);
2176                        })),
2177                ),
2178            )
2179            .into_any_element()
2180    }
2181
2182    pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
2183        let row_height = Self::row_height(window, cx);
2184        let table_state = self.table_interaction_state.read(cx);
2185        let viewport_height = table_state
2186            .scroll_handle
2187            .0
2188            .borrow()
2189            .last_item_size
2190            .map(|size| size.item.height)
2191            .unwrap_or(window.viewport_size().height);
2192        let loaded_commit_count = self.graph_data.commits.len();
2193
2194        let content_height = row_height * loaded_commit_count;
2195        let max_scroll = (content_height - viewport_height).max(px(0.));
2196        let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
2197
2198        let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
2199        let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
2200
2201        let graph_viewport_width = self.graph_viewport_width(window, cx);
2202        let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
2203            self.graph_canvas_content_width()
2204        } else {
2205            graph_viewport_width
2206        };
2207        let last_visible_row =
2208            first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
2209
2210        let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
2211            ..(last_visible_row).min(loaded_commit_count);
2212        let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
2213        let commit_lines: Vec<_> = self
2214            .graph_data
2215            .lines
2216            .iter()
2217            .filter(|line| {
2218                line.full_interval.start <= viewport_range.end
2219                    && line.full_interval.end >= viewport_range.start
2220            })
2221            .cloned()
2222            .collect();
2223
2224        let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
2225
2226        let hovered_entry_idx = self.hovered_entry_idx;
2227        let selected_entry_idx = self.selected_entry_idx;
2228        let is_focused = self.focus_handle.is_focused(window);
2229        let graph_canvas_bounds = self.graph_canvas_bounds.clone();
2230
2231        gpui::canvas(
2232            move |_bounds, _window, _cx| {},
2233            move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
2234                graph_canvas_bounds.set(Some(bounds));
2235
2236                window.paint_layer(bounds, |window| {
2237                    let accent_colors = cx.theme().accents();
2238
2239                    let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
2240                    let selected_bg = if is_focused {
2241                        cx.theme().colors().element_selected
2242                    } else {
2243                        cx.theme().colors().element_hover
2244                    };
2245
2246                    for visible_row_idx in 0..rows.len() {
2247                        let absolute_row_idx = first_visible_row + visible_row_idx;
2248                        let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
2249                        let is_selected = selected_entry_idx == Some(absolute_row_idx);
2250
2251                        if is_hovered || is_selected {
2252                            let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
2253                                - vertical_scroll_offset;
2254
2255                            let row_bounds = Bounds::new(
2256                                point(bounds.origin.x, row_y),
2257                                gpui::Size {
2258                                    width: bounds.size.width,
2259                                    height: row_height,
2260                                },
2261                            );
2262
2263                            let bg_color = if is_selected { selected_bg } else { hover_bg };
2264                            window.paint_quad(gpui::fill(row_bounds, bg_color));
2265                        }
2266                    }
2267
2268                    for (row_idx, row) in rows.into_iter().enumerate() {
2269                        let row_color = accent_colors.color_for_index(row.color_idx as u32);
2270                        let row_y_center =
2271                            bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
2272                                - vertical_scroll_offset;
2273
2274                        let commit_x = lane_center_x(bounds, row.lane as f32);
2275
2276                        draw_commit_circle(commit_x, row_y_center, row_color, window);
2277                    }
2278
2279                    for line in commit_lines {
2280                        let Some((start_segment_idx, start_column)) =
2281                            line.get_first_visible_segment_idx(first_visible_row)
2282                        else {
2283                            continue;
2284                        };
2285
2286                        let line_x = lane_center_x(bounds, start_column as f32);
2287
2288                        let start_row = line.full_interval.start as i32 - first_visible_row as i32;
2289
2290                        let from_y =
2291                            bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
2292                                - vertical_scroll_offset
2293                                + COMMIT_CIRCLE_RADIUS;
2294
2295                        let mut current_row = from_y;
2296                        let mut current_column = line_x;
2297
2298                        let mut builder = PathBuilder::stroke(LINE_WIDTH);
2299                        builder.move_to(point(line_x, from_y));
2300
2301                        let segments = &line.segments[start_segment_idx..];
2302                        let desired_curve_height = row_height / 3.0;
2303                        let desired_curve_width = LANE_WIDTH / 3.0;
2304
2305                        for (segment_idx, segment) in segments.iter().enumerate() {
2306                            let is_last = segment_idx + 1 == segments.len();
2307
2308                            match segment {
2309                                CommitLineSegment::Straight { to_row } => {
2310                                    let mut dest_row = to_row_center(
2311                                        to_row - first_visible_row,
2312                                        row_height,
2313                                        vertical_scroll_offset,
2314                                        bounds,
2315                                    );
2316                                    if is_last {
2317                                        dest_row -= COMMIT_CIRCLE_RADIUS;
2318                                    }
2319
2320                                    let dest_point = point(current_column, dest_row);
2321
2322                                    current_row = dest_point.y;
2323                                    builder.line_to(dest_point);
2324                                    builder.move_to(dest_point);
2325                                }
2326                                CommitLineSegment::Curve {
2327                                    to_column,
2328                                    on_row,
2329                                    curve_kind,
2330                                } => {
2331                                    let mut to_column = lane_center_x(bounds, *to_column as f32);
2332
2333                                    let mut to_row = to_row_center(
2334                                        *on_row - first_visible_row,
2335                                        row_height,
2336                                        vertical_scroll_offset,
2337                                        bounds,
2338                                    );
2339
2340                                    // This means that this branch was a checkout
2341                                    let going_right = to_column > current_column;
2342                                    let column_shift = if going_right {
2343                                        COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
2344                                    } else {
2345                                        -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
2346                                    };
2347
2348                                    match curve_kind {
2349                                        CurveKind::Checkout => {
2350                                            if is_last {
2351                                                to_column -= column_shift;
2352                                            }
2353
2354                                            let available_curve_width =
2355                                                (to_column - current_column).abs();
2356                                            let available_curve_height =
2357                                                (to_row - current_row).abs();
2358                                            let curve_width =
2359                                                desired_curve_width.min(available_curve_width);
2360                                            let curve_height =
2361                                                desired_curve_height.min(available_curve_height);
2362                                            let signed_curve_width = if going_right {
2363                                                curve_width
2364                                            } else {
2365                                                -curve_width
2366                                            };
2367                                            let curve_start =
2368                                                point(current_column, to_row - curve_height);
2369                                            let curve_end =
2370                                                point(current_column + signed_curve_width, to_row);
2371                                            let curve_control = point(current_column, to_row);
2372
2373                                            builder.move_to(point(current_column, current_row));
2374                                            builder.line_to(curve_start);
2375                                            builder.move_to(curve_start);
2376                                            builder.curve_to(curve_end, curve_control);
2377                                            builder.move_to(curve_end);
2378                                            builder.line_to(point(to_column, to_row));
2379                                        }
2380                                        CurveKind::Merge => {
2381                                            if is_last {
2382                                                to_row -= COMMIT_CIRCLE_RADIUS;
2383                                            }
2384
2385                                            let merge_start = point(
2386                                                current_column + column_shift,
2387                                                current_row - COMMIT_CIRCLE_RADIUS,
2388                                            );
2389                                            let available_curve_width =
2390                                                (to_column - merge_start.x).abs();
2391                                            let available_curve_height =
2392                                                (to_row - merge_start.y).abs();
2393                                            let curve_width =
2394                                                desired_curve_width.min(available_curve_width);
2395                                            let curve_height =
2396                                                desired_curve_height.min(available_curve_height);
2397                                            let signed_curve_width = if going_right {
2398                                                curve_width
2399                                            } else {
2400                                                -curve_width
2401                                            };
2402                                            let curve_start = point(
2403                                                to_column - signed_curve_width,
2404                                                merge_start.y,
2405                                            );
2406                                            let curve_end =
2407                                                point(to_column, merge_start.y + curve_height);
2408                                            let curve_control = point(to_column, merge_start.y);
2409
2410                                            builder.move_to(merge_start);
2411                                            builder.line_to(curve_start);
2412                                            builder.move_to(curve_start);
2413                                            builder.curve_to(curve_end, curve_control);
2414                                            builder.move_to(curve_end);
2415                                            builder.line_to(point(to_column, to_row));
2416                                        }
2417                                    }
2418                                    current_row = to_row;
2419                                    current_column = to_column;
2420                                    builder.move_to(point(current_column, current_row));
2421                                }
2422                            }
2423                        }
2424
2425                        builder.close();
2426                        lines.entry(line.color_idx).or_default().push(builder);
2427                    }
2428
2429                    for (color_idx, builders) in lines {
2430                        let line_color = accent_colors.color_for_index(color_idx as u32);
2431
2432                        for builder in builders {
2433                            if let Ok(path) = builder.build() {
2434                                // we paint each color on it's own layer to stop overlapping lines
2435                                // of different colors changing the color of a line
2436                                window.paint_layer(bounds, |window| {
2437                                    window.paint_path(path, line_color);
2438                                });
2439                            }
2440                        }
2441                    }
2442                })
2443            },
2444        )
2445        .w(graph_width)
2446        .h_full()
2447    }
2448
2449    fn row_at_position(
2450        &self,
2451        position_y: Pixels,
2452        window: &Window,
2453        cx: &Context<Self>,
2454    ) -> Option<usize> {
2455        let canvas_bounds = self.graph_canvas_bounds.get()?;
2456        let table_state = self.table_interaction_state.read(cx);
2457        let scroll_offset_y = -table_state.scroll_offset().y;
2458
2459        let local_y = position_y - canvas_bounds.origin.y;
2460
2461        if local_y >= px(0.) && local_y < canvas_bounds.size.height {
2462            let absolute_y = local_y + scroll_offset_y;
2463            let row_height = Self::row_height(window, cx);
2464            let absolute_row = (absolute_y / row_height).floor() as usize;
2465
2466            if absolute_row < self.graph_data.commits.len() {
2467                return Some(absolute_row);
2468            }
2469        }
2470
2471        None
2472    }
2473
2474    fn handle_graph_mouse_move(
2475        &mut self,
2476        event: &gpui::MouseMoveEvent,
2477        window: &mut Window,
2478        cx: &mut Context<Self>,
2479    ) {
2480        if let Some(row) = self.row_at_position(event.position.y, window, cx) {
2481            if self.hovered_entry_idx != Some(row) {
2482                self.hovered_entry_idx = Some(row);
2483                cx.notify();
2484            }
2485        } else if self.hovered_entry_idx.is_some() {
2486            self.hovered_entry_idx = None;
2487            cx.notify();
2488        }
2489    }
2490
2491    fn handle_graph_click(
2492        &mut self,
2493        event: &ClickEvent,
2494        window: &mut Window,
2495        cx: &mut Context<Self>,
2496    ) {
2497        if let Some(row) = self.row_at_position(event.position().y, window, cx) {
2498            self.select_entry(row, ScrollStrategy::Nearest, cx);
2499            if event.click_count() >= 2 {
2500                self.open_commit_view(row, window, cx);
2501            }
2502        }
2503    }
2504
2505    fn handle_graph_scroll(
2506        &mut self,
2507        event: &ScrollWheelEvent,
2508        window: &mut Window,
2509        cx: &mut Context<Self>,
2510    ) {
2511        let line_height = window.line_height();
2512        let delta = event.delta.pixel_delta(line_height);
2513
2514        let table_state = self.table_interaction_state.read(cx);
2515        let current_offset = table_state.scroll_offset();
2516
2517        let viewport_height = table_state.scroll_handle.viewport().size.height;
2518
2519        let commit_count = match self.graph_data.max_commit_count {
2520            AllCommitCount::Loaded(count) => count,
2521            AllCommitCount::NotLoaded => self.graph_data.commits.len(),
2522        };
2523        let content_height = Self::row_height(window, cx) * commit_count;
2524        let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
2525
2526        let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
2527        let new_offset = Point::new(current_offset.x, new_y);
2528
2529        if new_offset != current_offset {
2530            table_state.set_scroll_offset(new_offset);
2531            cx.notify();
2532        }
2533    }
2534
2535    fn render_commit_view_resize_handle(
2536        &self,
2537        _window: &mut Window,
2538        cx: &mut Context<Self>,
2539    ) -> AnyElement {
2540        div()
2541            .id("commit-view-split-resize-container")
2542            .relative()
2543            .h_full()
2544            .flex_shrink_0()
2545            .w(px(1.))
2546            .bg(cx.theme().colors().border_variant)
2547            .child(
2548                div()
2549                    .id("commit-view-split-resize-handle")
2550                    .absolute()
2551                    .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
2552                    .w(px(RESIZE_HANDLE_WIDTH))
2553                    .h_full()
2554                    .cursor_col_resize()
2555                    .block_mouse_except_scroll()
2556                    .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
2557                        if event.click_count() >= 2 {
2558                            this.commit_details_split_state.update(cx, |state, _| {
2559                                state.on_double_click();
2560                            });
2561                        }
2562                        cx.stop_propagation();
2563                    }))
2564                    .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
2565            )
2566            .into_any_element()
2567    }
2568}
2569
2570impl Render for GitGraph {
2571    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2572        // This happens when we changed branches, we should refresh our search as well
2573        if let QueryState::Pending(query) = &mut self.search_state.state {
2574            let query = std::mem::take(query);
2575            self.search_state.state = QueryState::Empty;
2576            self.search(query, cx);
2577        }
2578        let (commit_count, is_loading) = match self.graph_data.max_commit_count {
2579            AllCommitCount::Loaded(count) => (count, true),
2580            AllCommitCount::NotLoaded => {
2581                let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) {
2582                    repository.update(cx, |repository, cx| {
2583                        // Start loading the graph data if we haven't started already
2584                        let GraphDataResponse {
2585                            commits,
2586                            is_loading,
2587                            error: _,
2588                        } = repository.graph_data(
2589                            self.log_source.clone(),
2590                            self.log_order,
2591                            0..usize::MAX,
2592                            cx,
2593                        );
2594                        self.graph_data.add_commits(&commits);
2595                        (commits.len(), is_loading)
2596                    })
2597                } else {
2598                    (0, false)
2599                };
2600
2601                (commit_count, is_loading)
2602            }
2603        };
2604
2605        let error = self.get_repository(cx).and_then(|repo| {
2606            repo.read(cx)
2607                .get_graph_data(self.log_source.clone(), self.log_order)
2608                .and_then(|data| data.error.clone())
2609        });
2610
2611        let content = if commit_count == 0 {
2612            let message = if let Some(error) = &error {
2613                format!("Error loading: {}", error)
2614            } else if is_loading {
2615                "Loading".to_string()
2616            } else {
2617                "No commits found".to_string()
2618            };
2619            let label = Label::new(message)
2620                .color(Color::Muted)
2621                .size(LabelSize::Large);
2622            div()
2623                .size_full()
2624                .h_flex()
2625                .gap_1()
2626                .items_center()
2627                .justify_center()
2628                .child(label)
2629                .when(is_loading && error.is_none(), |this| {
2630                    this.child(self.render_loading_spinner(cx))
2631                })
2632        } else {
2633            let header_resize_info =
2634                HeaderResizeInfo::from_redistributable(&self.column_widths, cx);
2635            let header_context = TableRenderContext::for_column_widths(
2636                Some(self.column_widths.read(cx).widths_to_render()),
2637                true,
2638            );
2639            let [
2640                graph_fraction,
2641                description_fraction,
2642                date_fraction,
2643                author_fraction,
2644                commit_fraction,
2645            ] = self.preview_column_fractions(window, cx);
2646            let table_fraction =
2647                description_fraction + date_fraction + author_fraction + commit_fraction;
2648            let table_width_config = self.table_column_width_config(window, cx);
2649
2650            h_flex()
2651                .size_full()
2652                .child(
2653                    div()
2654                        .flex_1()
2655                        .min_w_0()
2656                        .size_full()
2657                        .flex()
2658                        .flex_col()
2659                        .child(render_table_header(
2660                            TableRow::from_vec(
2661                                vec![
2662                                    Label::new("Graph")
2663                                        .color(Color::Muted)
2664                                        .truncate()
2665                                        .into_any_element(),
2666                                    Label::new("Description")
2667                                        .color(Color::Muted)
2668                                        .into_any_element(),
2669                                    Label::new("Date").color(Color::Muted).into_any_element(),
2670                                    Label::new("Author").color(Color::Muted).into_any_element(),
2671                                    Label::new("Commit").color(Color::Muted).into_any_element(),
2672                                ],
2673                                5,
2674                            ),
2675                            header_context,
2676                            Some(header_resize_info),
2677                            Some(self.column_widths.entity_id()),
2678                            cx,
2679                        ))
2680                        .child({
2681                            let row_height = Self::row_height(window, cx);
2682                            let selected_entry_idx = self.selected_entry_idx;
2683                            let hovered_entry_idx = self.hovered_entry_idx;
2684                            let weak_self = cx.weak_entity();
2685                            let focus_handle = self.focus_handle.clone();
2686
2687                            bind_redistributable_columns(
2688                                div()
2689                                    .relative()
2690                                    .flex_1()
2691                                    .w_full()
2692                                    .overflow_hidden()
2693                                    .child(
2694                                        h_flex()
2695                                            .size_full()
2696                                            .child(
2697                                                div()
2698                                                    .w(DefiniteLength::Fraction(graph_fraction))
2699                                                    .h_full()
2700                                                    .min_w_0()
2701                                                    .overflow_hidden()
2702                                                    .child(
2703                                                        div()
2704                                                            .id("graph-canvas")
2705                                                            .size_full()
2706                                                            .overflow_hidden()
2707                                                            .child(
2708                                                                div()
2709                                                                    .size_full()
2710                                                                    .child(self.render_graph(window, cx)),
2711                                                            )
2712                                                            .on_scroll_wheel(
2713                                                                cx.listener(Self::handle_graph_scroll),
2714                                                            )
2715                                                            .on_mouse_move(
2716                                                                cx.listener(Self::handle_graph_mouse_move),
2717                                                            )
2718                                                            .on_click(cx.listener(Self::handle_graph_click))
2719                                                            .on_hover(cx.listener(
2720                                                                |this, &is_hovered: &bool, _, cx| {
2721                                                                    if !is_hovered
2722                                                                        && this.hovered_entry_idx.is_some()
2723                                                                    {
2724                                                                        this.hovered_entry_idx = None;
2725                                                                        cx.notify();
2726                                                                    }
2727                                                                },
2728                                                            )),
2729                                                    ),
2730                                            )
2731                                            .child(
2732                                                div()
2733                                                    .w(DefiniteLength::Fraction(table_fraction))
2734                                                    .h_full()
2735                                                    .min_w_0()
2736                                                    .child(
2737                                                        Table::new(4)
2738                                                            .interactable(&self.table_interaction_state)
2739                                                            .hide_row_borders()
2740                                                            .hide_row_hover()
2741                                                            .width_config(table_width_config)
2742                                                            .map_row(move |(index, row), window, cx| {
2743                                                                let is_selected =
2744                                                                    selected_entry_idx == Some(index);
2745                                                                let is_hovered =
2746                                                                    hovered_entry_idx == Some(index);
2747                                                                let is_focused =
2748                                                                    focus_handle.is_focused(window);
2749                                                                let weak = weak_self.clone();
2750                                                                let weak_for_hover = weak.clone();
2751
2752                                                                let hover_bg = cx
2753                                                                    .theme()
2754                                                                    .colors()
2755                                                                    .element_hover
2756                                                                    .opacity(0.6);
2757                                                                let selected_bg = if is_focused {
2758                                                                    cx.theme().colors().element_selected
2759                                                                } else {
2760                                                                    cx.theme().colors().element_hover
2761                                                                };
2762
2763                                                                row.h(row_height)
2764                                                                    .when(is_selected, |row| row.bg(selected_bg))
2765                                                                    .when(
2766                                                                        is_hovered && !is_selected,
2767                                                                        |row| row.bg(hover_bg),
2768                                                                    )
2769                                                                    .on_hover(move |&is_hovered, _, cx| {
2770                                                                        weak_for_hover
2771                                                                            .update(cx, |this, cx| {
2772                                                                                if is_hovered {
2773                                                                                    if this.hovered_entry_idx
2774                                                                                        != Some(index)
2775                                                                                    {
2776                                                                                        this.hovered_entry_idx =
2777                                                                                            Some(index);
2778                                                                                        cx.notify();
2779                                                                                    }
2780                                                                                } else if this
2781                                                                                    .hovered_entry_idx
2782                                                                                    == Some(index)
2783                                                                                {
2784                                                                                    this.hovered_entry_idx =
2785                                                                                        None;
2786                                                                                    cx.notify();
2787                                                                                }
2788                                                                            })
2789                                                                            .ok();
2790                                                                    })
2791                                                                    .on_click(move |event, window, cx| {
2792                                                                        let click_count = event.click_count();
2793                                                                        weak.update(cx, |this, cx| {
2794                                                                            this.select_entry(
2795                                                                                index,
2796                                                                                ScrollStrategy::Center,
2797                                                                                cx,
2798                                                                            );
2799                                                                            if click_count >= 2 {
2800                                                                                this.open_commit_view(
2801                                                                                    index,
2802                                                                                    window,
2803                                                                                    cx,
2804                                                                                );
2805                                                                            }
2806                                                                        })
2807                                                                        .ok();
2808                                                                    })
2809                                                                    .into_any_element()
2810                                                            })
2811                                                            .uniform_list(
2812                                                                "git-graph-commits",
2813                                                                commit_count,
2814                                                                cx.processor(Self::render_table_rows),
2815                                                            ),
2816                                                    ),
2817                                            ),
2818                                    )
2819                                    .child(render_redistributable_columns_resize_handles(
2820                                        &self.column_widths,
2821                                        window,
2822                                        cx,
2823                                    )),
2824                                self.column_widths.clone(),
2825                            )
2826                        }),
2827                )
2828                .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
2829                    this.commit_details_split_state.update(cx, |state, cx| {
2830                        state.on_drag_move(event, window, cx);
2831                    });
2832                }))
2833                .on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
2834                    this.commit_details_split_state.update(cx, |state, _cx| {
2835                        state.commit_ratio();
2836                    });
2837                }))
2838                .when(self.selected_entry_idx.is_some(), |this| {
2839                    this.child(self.render_commit_view_resize_handle(window, cx))
2840                        .child(self.render_commit_detail_panel(window, cx))
2841                })
2842        };
2843
2844        div()
2845            .key_context("GitGraph")
2846            .track_focus(&self.focus_handle)
2847            .size_full()
2848            .bg(cx.theme().colors().editor_background)
2849            .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
2850                this.open_selected_commit_view(window, cx);
2851            }))
2852            .on_action(cx.listener(Self::cancel))
2853            .on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
2854                this.search_state
2855                    .editor
2856                    .update(cx, |editor, cx| editor.focus_handle(cx).focus(window, cx));
2857            }))
2858            .on_action(cx.listener(Self::select_first))
2859            .on_action(cx.listener(Self::select_prev))
2860            .on_action(cx.listener(Self::select_next))
2861            .on_action(cx.listener(Self::select_last))
2862            .on_action(cx.listener(Self::confirm))
2863            .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| {
2864                this.select_next_match(cx);
2865            }))
2866            .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| {
2867                this.select_previous_match(cx);
2868            }))
2869            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| {
2870                this.search_state.case_sensitive = !this.search_state.case_sensitive;
2871                this.search_state.state.next_state();
2872                cx.notify();
2873            }))
2874            .child(
2875                v_flex()
2876                    .size_full()
2877                    .child(self.render_search_bar(cx))
2878                    .child(div().flex_1().child(content)),
2879            )
2880            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2881                deferred(
2882                    anchored()
2883                        .position(*position)
2884                        .anchor(Anchor::TopLeft)
2885                        .child(menu.clone()),
2886                )
2887                .with_priority(1)
2888            }))
2889            .on_action(cx.listener(|_, _: &buffer_search::Deploy, window, cx| {
2890                window.dispatch_action(Box::new(FocusSearch), cx);
2891                cx.stop_propagation();
2892            }))
2893    }
2894}
2895
2896impl EventEmitter<ItemEvent> for GitGraph {}
2897
2898impl Focusable for GitGraph {
2899    fn focus_handle(&self, _cx: &App) -> FocusHandle {
2900        self.focus_handle.clone()
2901    }
2902}
2903
2904impl Item for GitGraph {
2905    type Event = ItemEvent;
2906
2907    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
2908        Some(Icon::new(IconName::GitGraph))
2909    }
2910
2911    fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
2912        let repo_name = self.get_repository(cx).and_then(|repo| {
2913            repo.read(cx)
2914                .work_directory_abs_path
2915                .file_name()
2916                .map(|name| name.to_string_lossy().to_string())
2917        });
2918
2919        Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
2920            move |_, _| {
2921                v_flex()
2922                    .child(Label::new("Git Graph"))
2923                    .when_some(repo_name.clone(), |this, name| {
2924                        this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
2925                    })
2926                    .into_any_element()
2927            }
2928        }))))
2929    }
2930
2931    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
2932        self.get_repository(cx)
2933            .and_then(|repo| {
2934                repo.read(cx)
2935                    .work_directory_abs_path
2936                    .file_name()
2937                    .map(|name| name.to_string_lossy().to_string())
2938            })
2939            .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
2940    }
2941
2942    fn show_toolbar(&self) -> bool {
2943        false
2944    }
2945
2946    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
2947        f(*event)
2948    }
2949}
2950
2951impl workspace::SerializableItem for GitGraph {
2952    fn serialized_item_kind() -> &'static str {
2953        "GitGraph"
2954    }
2955
2956    fn cleanup(
2957        workspace_id: workspace::WorkspaceId,
2958        alive_items: Vec<workspace::ItemId>,
2959        _window: &mut Window,
2960        cx: &mut App,
2961    ) -> Task<gpui::Result<()>> {
2962        workspace::delete_unloaded_items(
2963            alive_items,
2964            workspace_id,
2965            "git_graphs",
2966            &persistence::GitGraphsDb::global(cx),
2967            cx,
2968        )
2969    }
2970
2971    fn deserialize(
2972        project: Entity<project::Project>,
2973        workspace: WeakEntity<Workspace>,
2974        workspace_id: workspace::WorkspaceId,
2975        item_id: workspace::ItemId,
2976        window: &mut Window,
2977        cx: &mut App,
2978    ) -> Task<gpui::Result<Entity<Self>>> {
2979        let db = persistence::GitGraphsDb::global(cx);
2980        let Some(repo_work_path) = db.get_git_graph(item_id, workspace_id).ok().flatten() else {
2981            return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")));
2982        };
2983
2984        let window_handle = window.window_handle();
2985        let project = project.read(cx);
2986        let git_store = project.git_store().clone();
2987        let wait = project.wait_for_initial_scan(cx);
2988
2989        cx.spawn(async move |cx| {
2990            wait.await;
2991
2992            cx.update_window(window_handle, |_, window, cx| {
2993                let path = repo_work_path.as_path();
2994
2995                let repositories = git_store.read(cx).repositories();
2996                let repo_id = repositories.iter().find_map(|(&repo_id, repo)| {
2997                    if repo.read(cx).snapshot().work_directory_abs_path.as_ref() == path {
2998                        Some(repo_id)
2999                    } else {
3000                        None
3001                    }
3002                });
3003
3004                let Some(repo_id) = repo_id else {
3005                    return Err(anyhow::anyhow!("Repository not found for path: {:?}", path));
3006                };
3007
3008                Ok(cx.new(|cx| GitGraph::new(repo_id, git_store, workspace, window, cx)))
3009            })?
3010        })
3011    }
3012
3013    fn serialize(
3014        &mut self,
3015        workspace: &mut Workspace,
3016        item_id: workspace::ItemId,
3017        _closing: bool,
3018        _window: &mut Window,
3019        cx: &mut Context<Self>,
3020    ) -> Option<Task<gpui::Result<()>>> {
3021        let workspace_id = workspace.database_id()?;
3022        let repo = self.get_repository(cx)?;
3023        let repo_working_path = repo
3024            .read(cx)
3025            .snapshot()
3026            .work_directory_abs_path
3027            .to_string_lossy()
3028            .to_string();
3029
3030        let db = persistence::GitGraphsDb::global(cx);
3031        Some(cx.background_spawn(async move {
3032            db.save_git_graph(item_id, workspace_id, repo_working_path)
3033                .await
3034        }))
3035    }
3036
3037    fn should_serialize(&self, event: &Self::Event) -> bool {
3038        event == &ItemEvent::UpdateTab
3039    }
3040}
3041
3042mod persistence {
3043    use std::path::PathBuf;
3044
3045    use db::{
3046        query,
3047        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
3048        sqlez_macros::sql,
3049    };
3050    use workspace::WorkspaceDb;
3051
3052    pub struct GitGraphsDb(ThreadSafeConnection);
3053
3054    impl Domain for GitGraphsDb {
3055        const NAME: &str = stringify!(GitGraphsDb);
3056
3057        const MIGRATIONS: &[&str] = &[
3058            sql!(
3059                CREATE TABLE git_graphs (
3060                    workspace_id INTEGER,
3061                    item_id INTEGER UNIQUE,
3062                    is_open INTEGER DEFAULT FALSE,
3063
3064                    PRIMARY KEY(workspace_id, item_id),
3065                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
3066                    ON DELETE CASCADE
3067                ) STRICT;
3068            ),
3069            sql!(
3070                ALTER TABLE git_graphs ADD COLUMN repo_working_path TEXT;
3071            ),
3072        ];
3073    }
3074
3075    db::static_connection!(GitGraphsDb, [WorkspaceDb]);
3076
3077    impl GitGraphsDb {
3078        query! {
3079            pub async fn save_git_graph(
3080                item_id: workspace::ItemId,
3081                workspace_id: workspace::WorkspaceId,
3082                repo_working_path: String
3083            ) -> Result<()> {
3084                INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, repo_working_path)
3085                VALUES (?, ?, ?)
3086            }
3087        }
3088
3089        query! {
3090            pub fn get_git_graph(
3091                item_id: workspace::ItemId,
3092                workspace_id: workspace::WorkspaceId
3093            ) -> Result<Option<PathBuf>> {
3094                SELECT repo_working_path
3095                FROM git_graphs
3096                WHERE item_id = ? AND workspace_id = ?
3097            }
3098        }
3099    }
3100}
3101
3102#[cfg(test)]
3103mod tests {
3104    use super::*;
3105    use anyhow::{Context, Result, bail};
3106    use collections::{HashMap, HashSet};
3107    use fs::FakeFs;
3108    use git::Oid;
3109    use git::repository::InitialGraphCommitData;
3110    use gpui::{TestAppContext, UpdateGlobal};
3111    use project::Project;
3112    use project::git_store::{GitStoreEvent, RepositoryEvent};
3113    use rand::prelude::*;
3114    use serde_json::json;
3115    use settings::{SettingsStore, ThemeSettingsContent};
3116    use smallvec::{SmallVec, smallvec};
3117    use std::path::Path;
3118    use std::sync::{Arc, Mutex};
3119
3120    fn init_test(cx: &mut TestAppContext) {
3121        cx.update(|cx| {
3122            let settings_store = SettingsStore::test(cx);
3123            cx.set_global(settings_store);
3124            theme_settings::init(theme::LoadThemes::JustBase, cx);
3125        });
3126    }
3127
3128    /// Generates a random commit DAG suitable for testing git graph rendering.
3129    ///
3130    /// The commits are ordered newest-first (like git log output), so:
3131    /// - Index 0 = most recent commit (HEAD)
3132    /// - Last index = oldest commit (root, has no parents)
3133    /// - Parents of commit at index I must have index > I
3134    ///
3135    /// When `adversarial` is true, generates complex topologies with many branches
3136    /// and octopus merges. Otherwise generates more realistic linear histories
3137    /// with occasional branches.
3138    fn generate_random_commit_dag(
3139        rng: &mut StdRng,
3140        num_commits: usize,
3141        adversarial: bool,
3142    ) -> Vec<Arc<InitialGraphCommitData>> {
3143        if num_commits == 0 {
3144            return Vec::new();
3145        }
3146
3147        let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
3148        let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
3149
3150        for i in 0..num_commits {
3151            let sha = oids[i];
3152
3153            let parents = if i == num_commits - 1 {
3154                smallvec![]
3155            } else {
3156                generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
3157            };
3158
3159            let ref_names = if i == 0 {
3160                vec!["HEAD".into(), "main".into()]
3161            } else if adversarial && rng.random_bool(0.1) {
3162                vec![format!("branch-{}", i).into()]
3163            } else {
3164                Vec::new()
3165            };
3166
3167            commits.push(Arc::new(InitialGraphCommitData {
3168                sha,
3169                parents,
3170                ref_names,
3171            }));
3172        }
3173
3174        commits
3175    }
3176
3177    fn generate_parents_from_oids(
3178        rng: &mut StdRng,
3179        oids: &[Oid],
3180        current_idx: usize,
3181        num_commits: usize,
3182        adversarial: bool,
3183    ) -> SmallVec<[Oid; 1]> {
3184        let remaining = num_commits - current_idx - 1;
3185        if remaining == 0 {
3186            return smallvec![];
3187        }
3188
3189        if adversarial {
3190            let merge_chance = 0.4;
3191            let octopus_chance = 0.15;
3192
3193            if remaining >= 3 && rng.random_bool(octopus_chance) {
3194                let num_parents = rng.random_range(3..=remaining.min(5));
3195                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
3196                parent_indices.shuffle(rng);
3197                parent_indices
3198                    .into_iter()
3199                    .take(num_parents)
3200                    .map(|idx| oids[idx])
3201                    .collect()
3202            } else if remaining >= 2 && rng.random_bool(merge_chance) {
3203                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
3204                parent_indices.shuffle(rng);
3205                parent_indices
3206                    .into_iter()
3207                    .take(2)
3208                    .map(|idx| oids[idx])
3209                    .collect()
3210            } else {
3211                let parent_idx = rng.random_range(current_idx + 1..num_commits);
3212                smallvec![oids[parent_idx]]
3213            }
3214        } else {
3215            let merge_chance = 0.15;
3216            let skip_chance = 0.1;
3217
3218            if remaining >= 2 && rng.random_bool(merge_chance) {
3219                let first_parent = current_idx + 1;
3220                let second_parent = rng.random_range(current_idx + 2..num_commits);
3221                smallvec![oids[first_parent], oids[second_parent]]
3222            } else if rng.random_bool(skip_chance) && remaining >= 2 {
3223                let skip = rng.random_range(1..remaining.min(3));
3224                smallvec![oids[current_idx + 1 + skip]]
3225            } else {
3226                smallvec![oids[current_idx + 1]]
3227            }
3228        }
3229    }
3230
3231    fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
3232        graph
3233            .commits
3234            .iter()
3235            .enumerate()
3236            .map(|(idx, entry)| (entry.data.sha, idx))
3237            .collect()
3238    }
3239
3240    fn verify_commit_order(
3241        graph: &GraphData,
3242        commits: &[Arc<InitialGraphCommitData>],
3243    ) -> Result<()> {
3244        if graph.commits.len() != commits.len() {
3245            bail!(
3246                "Commit count mismatch: graph has {} commits, expected {}",
3247                graph.commits.len(),
3248                commits.len()
3249            );
3250        }
3251
3252        for (idx, (graph_commit, expected_commit)) in
3253            graph.commits.iter().zip(commits.iter()).enumerate()
3254        {
3255            if graph_commit.data.sha != expected_commit.sha {
3256                bail!(
3257                    "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
3258                    idx,
3259                    graph_commit.data.sha,
3260                    expected_commit.sha
3261                );
3262            }
3263        }
3264
3265        Ok(())
3266    }
3267
3268    fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
3269        for line in &graph.lines {
3270            let child_row = *oid_to_row
3271                .get(&line.child)
3272                .context("Line references non-existent child commit")?;
3273
3274            let parent_row = *oid_to_row
3275                .get(&line.parent)
3276                .context("Line references non-existent parent commit")?;
3277
3278            if child_row >= parent_row {
3279                bail!(
3280                    "child_row ({}) must be < parent_row ({})",
3281                    child_row,
3282                    parent_row
3283                );
3284            }
3285
3286            if line.full_interval.start != child_row {
3287                bail!(
3288                    "full_interval.start ({}) != child_row ({})",
3289                    line.full_interval.start,
3290                    child_row
3291                );
3292            }
3293
3294            if line.full_interval.end != parent_row {
3295                bail!(
3296                    "full_interval.end ({}) != parent_row ({})",
3297                    line.full_interval.end,
3298                    parent_row
3299                );
3300            }
3301
3302            if let Some(last_segment) = line.segments.last() {
3303                let segment_end_row = match last_segment {
3304                    CommitLineSegment::Straight { to_row } => *to_row,
3305                    CommitLineSegment::Curve { on_row, .. } => *on_row,
3306                };
3307
3308                if segment_end_row != line.full_interval.end {
3309                    bail!(
3310                        "last segment ends at row {} but full_interval.end is {}",
3311                        segment_end_row,
3312                        line.full_interval.end
3313                    );
3314                }
3315            }
3316        }
3317
3318        Ok(())
3319    }
3320
3321    fn verify_column_correctness(
3322        graph: &GraphData,
3323        oid_to_row: &HashMap<Oid, usize>,
3324    ) -> Result<()> {
3325        for line in &graph.lines {
3326            let child_row = *oid_to_row
3327                .get(&line.child)
3328                .context("Line references non-existent child commit")?;
3329
3330            let parent_row = *oid_to_row
3331                .get(&line.parent)
3332                .context("Line references non-existent parent commit")?;
3333
3334            let child_lane = graph.commits[child_row].lane;
3335            if line.child_column != child_lane {
3336                bail!(
3337                    "child_column ({}) != child's lane ({})",
3338                    line.child_column,
3339                    child_lane
3340                );
3341            }
3342
3343            let mut current_column = line.child_column;
3344            for segment in &line.segments {
3345                if let CommitLineSegment::Curve { to_column, .. } = segment {
3346                    current_column = *to_column;
3347                }
3348            }
3349
3350            let parent_lane = graph.commits[parent_row].lane;
3351            if current_column != parent_lane {
3352                bail!(
3353                    "ending column ({}) != parent's lane ({})",
3354                    current_column,
3355                    parent_lane
3356                );
3357            }
3358        }
3359
3360        Ok(())
3361    }
3362
3363    fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
3364        for line in &graph.lines {
3365            if line.segments.is_empty() {
3366                bail!("Line has no segments");
3367            }
3368
3369            let mut current_row = line.full_interval.start;
3370
3371            for (idx, segment) in line.segments.iter().enumerate() {
3372                let segment_end_row = match segment {
3373                    CommitLineSegment::Straight { to_row } => *to_row,
3374                    CommitLineSegment::Curve { on_row, .. } => *on_row,
3375                };
3376
3377                if segment_end_row < current_row {
3378                    bail!(
3379                        "segment {} ends at row {} which is before current row {}",
3380                        idx,
3381                        segment_end_row,
3382                        current_row
3383                    );
3384                }
3385
3386                current_row = segment_end_row;
3387            }
3388        }
3389
3390        Ok(())
3391    }
3392
3393    fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
3394        for line in &graph.lines {
3395            let child_row = line.full_interval.start;
3396
3397            let mut current_column = line.child_column;
3398            let mut current_row = child_row;
3399
3400            for segment in &line.segments {
3401                match segment {
3402                    CommitLineSegment::Straight { to_row } => {
3403                        for row in (current_row + 1)..*to_row {
3404                            if row < graph.commits.len() {
3405                                let commit_at_row = &graph.commits[row];
3406                                if commit_at_row.lane == current_column {
3407                                    bail!(
3408                                        "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
3409                                        current_row,
3410                                        to_row,
3411                                        current_column,
3412                                        commit_at_row.data.sha,
3413                                        row
3414                                    );
3415                                }
3416                            }
3417                        }
3418                        current_row = *to_row;
3419                    }
3420                    CommitLineSegment::Curve {
3421                        to_column, on_row, ..
3422                    } => {
3423                        current_column = *to_column;
3424                        current_row = *on_row;
3425                    }
3426                }
3427            }
3428        }
3429
3430        Ok(())
3431    }
3432
3433    fn verify_coverage(graph: &GraphData) -> Result<()> {
3434        let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
3435        for entry in &graph.commits {
3436            for parent in &entry.data.parents {
3437                expected_edges.insert((entry.data.sha, *parent));
3438            }
3439        }
3440
3441        let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
3442        for line in &graph.lines {
3443            let edge = (line.child, line.parent);
3444
3445            if !found_edges.insert(edge) {
3446                bail!(
3447                    "Duplicate line found for edge {:?} -> {:?}",
3448                    line.child,
3449                    line.parent
3450                );
3451            }
3452
3453            if !expected_edges.contains(&edge) {
3454                bail!(
3455                    "Orphan line found: {:?} -> {:?} is not in the commit graph",
3456                    line.child,
3457                    line.parent
3458                );
3459            }
3460        }
3461
3462        for (child, parent) in &expected_edges {
3463            if !found_edges.contains(&(*child, *parent)) {
3464                bail!("Missing line for edge {:?} -> {:?}", child, parent);
3465            }
3466        }
3467
3468        assert_eq!(
3469            expected_edges.symmetric_difference(&found_edges).count(),
3470            0,
3471            "The symmetric difference should be zero"
3472        );
3473
3474        Ok(())
3475    }
3476
3477    fn verify_merge_line_optimality(
3478        graph: &GraphData,
3479        oid_to_row: &HashMap<Oid, usize>,
3480    ) -> Result<()> {
3481        for line in &graph.lines {
3482            let first_segment = line.segments.first();
3483            let is_merge_line = matches!(
3484                first_segment,
3485                Some(CommitLineSegment::Curve {
3486                    curve_kind: CurveKind::Merge,
3487                    ..
3488                })
3489            );
3490
3491            if !is_merge_line {
3492                continue;
3493            }
3494
3495            let child_row = *oid_to_row
3496                .get(&line.child)
3497                .context("Line references non-existent child commit")?;
3498
3499            let parent_row = *oid_to_row
3500                .get(&line.parent)
3501                .context("Line references non-existent parent commit")?;
3502
3503            let parent_lane = graph.commits[parent_row].lane;
3504
3505            let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
3506                continue;
3507            };
3508
3509            let curves_directly_to_parent = *to_column == parent_lane;
3510
3511            if !curves_directly_to_parent {
3512                continue;
3513            }
3514
3515            let curve_row = child_row + 1;
3516            let has_commits_in_path = graph.commits[curve_row..parent_row]
3517                .iter()
3518                .any(|c| c.lane == parent_lane);
3519
3520            if has_commits_in_path {
3521                bail!(
3522                    "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
3523                    line.child,
3524                    line.parent,
3525                    parent_lane,
3526                    curve_row,
3527                    parent_row
3528                );
3529            }
3530
3531            let curve_ends_at_parent = curve_row == parent_row;
3532
3533            if curve_ends_at_parent {
3534                if line.segments.len() != 1 {
3535                    bail!(
3536                        "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
3537                        line.child,
3538                        line.parent,
3539                        line.segments.len()
3540                    );
3541                }
3542            } else {
3543                if line.segments.len() != 2 {
3544                    bail!(
3545                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
3546                        line.child,
3547                        line.parent,
3548                        line.segments.len()
3549                    );
3550                }
3551
3552                let is_straight_segment = matches!(
3553                    line.segments.get(1),
3554                    Some(CommitLineSegment::Straight { .. })
3555                );
3556
3557                if !is_straight_segment {
3558                    bail!(
3559                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
3560                        line.child,
3561                        line.parent
3562                    );
3563                }
3564            }
3565        }
3566
3567        Ok(())
3568    }
3569
3570    fn verify_all_invariants(
3571        graph: &GraphData,
3572        commits: &[Arc<InitialGraphCommitData>],
3573    ) -> Result<()> {
3574        let oid_to_row = build_oid_to_row_map(graph);
3575
3576        verify_commit_order(graph, commits).context("commit order")?;
3577        verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
3578        verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
3579        verify_segment_continuity(graph).context("segment continuity")?;
3580        verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
3581        verify_coverage(graph).context("coverage")?;
3582        verify_line_overlaps(graph).context("line overlaps")?;
3583        Ok(())
3584    }
3585
3586    #[test]
3587    fn test_git_graph_merge_commits() {
3588        let mut rng = StdRng::seed_from_u64(42);
3589
3590        let oid1 = Oid::random(&mut rng);
3591        let oid2 = Oid::random(&mut rng);
3592        let oid3 = Oid::random(&mut rng);
3593        let oid4 = Oid::random(&mut rng);
3594
3595        let commits = vec![
3596            Arc::new(InitialGraphCommitData {
3597                sha: oid1,
3598                parents: smallvec![oid2, oid3],
3599                ref_names: vec!["HEAD".into()],
3600            }),
3601            Arc::new(InitialGraphCommitData {
3602                sha: oid2,
3603                parents: smallvec![oid4],
3604                ref_names: vec![],
3605            }),
3606            Arc::new(InitialGraphCommitData {
3607                sha: oid3,
3608                parents: smallvec![oid4],
3609                ref_names: vec![],
3610            }),
3611            Arc::new(InitialGraphCommitData {
3612                sha: oid4,
3613                parents: smallvec![],
3614                ref_names: vec![],
3615            }),
3616        ];
3617
3618        let mut graph_data = GraphData::new(8);
3619        graph_data.add_commits(&commits);
3620
3621        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3622            panic!("Graph invariant violation for merge commits:\n{}", error);
3623        }
3624    }
3625
3626    #[test]
3627    fn test_git_graph_linear_commits() {
3628        let mut rng = StdRng::seed_from_u64(42);
3629
3630        let oid1 = Oid::random(&mut rng);
3631        let oid2 = Oid::random(&mut rng);
3632        let oid3 = Oid::random(&mut rng);
3633
3634        let commits = vec![
3635            Arc::new(InitialGraphCommitData {
3636                sha: oid1,
3637                parents: smallvec![oid2],
3638                ref_names: vec!["HEAD".into()],
3639            }),
3640            Arc::new(InitialGraphCommitData {
3641                sha: oid2,
3642                parents: smallvec![oid3],
3643                ref_names: vec![],
3644            }),
3645            Arc::new(InitialGraphCommitData {
3646                sha: oid3,
3647                parents: smallvec![],
3648                ref_names: vec![],
3649            }),
3650        ];
3651
3652        let mut graph_data = GraphData::new(8);
3653        graph_data.add_commits(&commits);
3654
3655        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3656            panic!("Graph invariant violation for linear commits:\n{}", error);
3657        }
3658    }
3659
3660    #[test]
3661    fn test_git_graph_random_commits() {
3662        for seed in 0..100 {
3663            let mut rng = StdRng::seed_from_u64(seed);
3664
3665            let adversarial = rng.random_bool(0.2);
3666            let num_commits = if adversarial {
3667                rng.random_range(10..100)
3668            } else {
3669                rng.random_range(5..50)
3670            };
3671
3672            let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3673
3674            assert_eq!(
3675                num_commits,
3676                commits.len(),
3677                "seed={}: Generate random commit dag didn't generate the correct amount of commits",
3678                seed
3679            );
3680
3681            let mut graph_data = GraphData::new(8);
3682            graph_data.add_commits(&commits);
3683
3684            if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3685                panic!(
3686                    "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
3687                    seed, adversarial, num_commits, error
3688                );
3689            }
3690        }
3691    }
3692
3693    // The full integration test has less iterations because it's significantly slower
3694    // than the random commit test
3695    #[gpui::test(iterations = 10)]
3696    async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
3697        init_test(cx);
3698
3699        let adversarial = rng.random_bool(0.2);
3700        let num_commits = if adversarial {
3701            rng.random_range(10..100)
3702        } else {
3703            rng.random_range(5..50)
3704        };
3705
3706        let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
3707
3708        let fs = FakeFs::new(cx.executor());
3709        fs.insert_tree(
3710            Path::new("/project"),
3711            json!({
3712                ".git": {},
3713                "file.txt": "content",
3714            }),
3715        )
3716        .await;
3717
3718        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3719
3720        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3721        cx.run_until_parked();
3722
3723        let repository = project.read_with(cx, |project, cx| {
3724            project
3725                .active_repository(cx)
3726                .expect("should have a repository")
3727        });
3728
3729        repository.update(cx, |repo, cx| {
3730            repo.graph_data(
3731                crate::LogSource::default(),
3732                crate::LogOrder::default(),
3733                0..usize::MAX,
3734                cx,
3735            );
3736        });
3737        cx.run_until_parked();
3738
3739        let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
3740            repo.graph_data(
3741                crate::LogSource::default(),
3742                crate::LogOrder::default(),
3743                0..usize::MAX,
3744                cx,
3745            )
3746            .commits
3747            .to_vec()
3748        });
3749
3750        let mut graph_data = GraphData::new(8);
3751        graph_data.add_commits(&graph_commits);
3752
3753        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
3754            panic!(
3755                "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
3756                adversarial, num_commits, error
3757            );
3758        }
3759    }
3760
3761    #[gpui::test]
3762    async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
3763        init_test(cx);
3764
3765        let fs = FakeFs::new(cx.executor());
3766        fs.insert_tree(
3767            Path::new("/project"),
3768            json!({
3769                ".git": {},
3770                "file.txt": "content",
3771            }),
3772        )
3773        .await;
3774
3775        let mut rng = StdRng::seed_from_u64(42);
3776        let commits = generate_random_commit_dag(&mut rng, 10, false);
3777        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
3778
3779        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3780        let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
3781        project.update(cx, |project, cx| {
3782            let observed_repository_events = observed_repository_events.clone();
3783            cx.subscribe(project.git_store(), move |_, _, event, _| {
3784                if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
3785                    observed_repository_events
3786                        .lock()
3787                        .expect("repository event mutex should be available")
3788                        .push(repository_event.clone());
3789                }
3790            })
3791            .detach();
3792        });
3793
3794        let repository = project.read_with(cx, |project, cx| {
3795            project
3796                .active_repository(cx)
3797                .expect("should have a repository")
3798        });
3799
3800        repository.update(cx, |repo, cx| {
3801            repo.graph_data(
3802                crate::LogSource::default(),
3803                crate::LogOrder::default(),
3804                0..usize::MAX,
3805                cx,
3806            );
3807        });
3808
3809        project
3810            .update(cx, |project, cx| project.git_scans_complete(cx))
3811            .await;
3812        cx.run_until_parked();
3813
3814        let observed_repository_events = observed_repository_events
3815            .lock()
3816            .expect("repository event mutex should be available");
3817        assert!(
3818            observed_repository_events
3819                .iter()
3820                .any(|event| matches!(event, RepositoryEvent::HeadChanged)),
3821            "initial repository scan should emit HeadChanged"
3822        );
3823        let commit_count_after = repository.read_with(cx, |repo, _| {
3824            repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
3825                .map(|data| data.commit_data.len())
3826                .unwrap()
3827        });
3828        assert_eq!(
3829            commits.len(),
3830            commit_count_after,
3831            "initial_graph_data should remain populated after events emitted by initial repository scan"
3832        );
3833    }
3834
3835    #[gpui::test]
3836    async fn test_initial_graph_data_propagates_error(cx: &mut TestAppContext) {
3837        init_test(cx);
3838
3839        let fs = FakeFs::new(cx.executor());
3840        fs.insert_tree(
3841            Path::new("/project"),
3842            json!({
3843                ".git": {},
3844                "file.txt": "content",
3845            }),
3846        )
3847        .await;
3848
3849        fs.set_graph_error(
3850            Path::new("/project/.git"),
3851            Some("fatal: bad default revision 'HEAD'".to_string()),
3852        );
3853
3854        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
3855
3856        let repository = project.read_with(cx, |project, cx| {
3857            project
3858                .active_repository(cx)
3859                .expect("should have a repository")
3860        });
3861
3862        repository.update(cx, |repo, cx| {
3863            repo.graph_data(
3864                crate::LogSource::default(),
3865                crate::LogOrder::default(),
3866                0..usize::MAX,
3867                cx,
3868            );
3869        });
3870
3871        cx.run_until_parked();
3872
3873        let error = repository.read_with(cx, |repo, _| {
3874            repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
3875                .and_then(|data| data.error.clone())
3876        });
3877
3878        assert!(
3879            error.is_some(),
3880            "graph data should contain an error after initial_graph_data fails"
3881        );
3882        let error_message = error.unwrap();
3883        assert!(
3884            error_message.contains("bad default revision"),
3885            "error should contain the git error message, got: {}",
3886            error_message
3887        );
3888    }
3889
3890    #[gpui::test]
3891    async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
3892        init_test(cx);
3893
3894        let fs = FakeFs::new(cx.executor());
3895        fs.insert_tree(
3896            Path::new("/project_a"),
3897            json!({
3898                ".git": {},
3899                "file.txt": "content",
3900            }),
3901        )
3902        .await;
3903        fs.insert_tree(
3904            Path::new("/project_b"),
3905            json!({
3906                ".git": {},
3907                "other.txt": "content",
3908            }),
3909        )
3910        .await;
3911
3912        let mut rng = StdRng::seed_from_u64(42);
3913        let commits = generate_random_commit_dag(&mut rng, 10, false);
3914        fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
3915
3916        let project = Project::test(
3917            fs.clone(),
3918            [Path::new("/project_a"), Path::new("/project_b")],
3919            cx,
3920        )
3921        .await;
3922        cx.run_until_parked();
3923
3924        let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
3925            let mut first_repository = None;
3926            let mut second_repository = None;
3927
3928            for repository in project.repositories(cx).values() {
3929                let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
3930                if work_directory_abs_path.as_ref() == Path::new("/project_a") {
3931                    first_repository = Some(repository.clone());
3932                } else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
3933                    second_repository = Some(repository.clone());
3934                }
3935            }
3936
3937            (
3938                first_repository.expect("should have repository for /project_a"),
3939                second_repository.expect("should have repository for /project_b"),
3940            )
3941        });
3942        first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
3943        cx.run_until_parked();
3944
3945        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
3946            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
3947        });
3948
3949        let workspace_weak =
3950            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
3951        let git_graph = cx.new_window_entity(|window, cx| {
3952            GitGraph::new(
3953                first_repository.read(cx).id,
3954                project.read(cx).git_store().clone(),
3955                workspace_weak,
3956                window,
3957                cx,
3958            )
3959        });
3960        cx.run_until_parked();
3961
3962        // Verify initial graph data is loaded
3963        let initial_commit_count =
3964            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3965        assert!(
3966            initial_commit_count > 0,
3967            "graph data should have been loaded, got 0 commits"
3968        );
3969
3970        git_graph.update(cx, |graph, cx| {
3971            graph.set_repo_id(second_repository.read(cx).id, cx)
3972        });
3973        cx.run_until_parked();
3974
3975        let commit_count_after_clear =
3976            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3977        assert_eq!(
3978            commit_count_after_clear, 0,
3979            "graph_data should be cleared after switching away"
3980        );
3981
3982        git_graph.update(cx, |graph, cx| {
3983            graph.set_repo_id(first_repository.read(cx).id, cx)
3984        });
3985        cx.run_until_parked();
3986
3987        cx.draw(
3988            point(px(0.), px(0.)),
3989            gpui::size(px(1200.), px(800.)),
3990            |_, _| git_graph.clone().into_any_element(),
3991        );
3992        cx.run_until_parked();
3993
3994        // Verify graph data is reloaded from repository cache on switch back
3995        let reloaded_commit_count =
3996            git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
3997        assert_eq!(
3998            reloaded_commit_count,
3999            commits.len(),
4000            "graph data should be reloaded after switching back"
4001        );
4002    }
4003
4004    #[gpui::test]
4005    async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) {
4006        init_test(cx);
4007
4008        let fs = FakeFs::new(cx.executor());
4009        fs.insert_tree(
4010            Path::new("/project"),
4011            json!({
4012                ".git": {},
4013                "file.txt": "content",
4014            }),
4015        )
4016        .await;
4017
4018        let initial_head = Oid::from_bytes(&[1; 20]).unwrap();
4019        let initial_stash = Oid::from_bytes(&[2; 20]).unwrap();
4020        let updated_head = Oid::from_bytes(&[3; 20]).unwrap();
4021        let updated_stash = Oid::from_bytes(&[4; 20]).unwrap();
4022
4023        fs.set_graph_commits(
4024            Path::new("/project/.git"),
4025            vec![
4026                Arc::new(InitialGraphCommitData {
4027                    sha: initial_head,
4028                    parents: smallvec![initial_stash],
4029                    ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4030                }),
4031                Arc::new(InitialGraphCommitData {
4032                    sha: initial_stash,
4033                    parents: smallvec![],
4034                    ref_names: vec!["refs/stash".into()],
4035                }),
4036            ],
4037        );
4038        fs.with_git_state(Path::new("/project/.git"), true, |state| {
4039            state.stash_entries = git::stash::GitStash {
4040                entries: vec![git::stash::StashEntry {
4041                    index: 0,
4042                    oid: initial_stash,
4043                    message: "initial stash".to_string(),
4044                    branch: Some("main".to_string()),
4045                    timestamp: 1,
4046                }]
4047                .into(),
4048            };
4049        })
4050        .unwrap();
4051
4052        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4053        cx.run_until_parked();
4054
4055        let repository = project.read_with(cx, |project, cx| {
4056            project
4057                .active_repository(cx)
4058                .expect("should have a repository")
4059        });
4060
4061        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4062            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4063        });
4064        let workspace_weak =
4065            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4066        let git_graph = cx.new_window_entity(|window, cx| {
4067            GitGraph::new(
4068                repository.read(cx).id,
4069                project.read(cx).git_store().clone(),
4070                workspace_weak,
4071                window,
4072                cx,
4073            )
4074        });
4075        cx.run_until_parked();
4076
4077        let initial_shas = git_graph.read_with(&*cx, |graph, _| {
4078            graph
4079                .graph_data
4080                .commits
4081                .iter()
4082                .map(|commit| commit.data.sha)
4083                .collect::<Vec<_>>()
4084        });
4085        assert_eq!(initial_shas, vec![initial_head, initial_stash]);
4086
4087        fs.set_graph_commits(
4088            Path::new("/project/.git"),
4089            vec![
4090                Arc::new(InitialGraphCommitData {
4091                    sha: updated_head,
4092                    parents: smallvec![updated_stash],
4093                    ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
4094                }),
4095                Arc::new(InitialGraphCommitData {
4096                    sha: updated_stash,
4097                    parents: smallvec![],
4098                    ref_names: vec!["refs/stash".into()],
4099                }),
4100            ],
4101        );
4102        fs.with_git_state(Path::new("/project/.git"), true, |state| {
4103            state.stash_entries = git::stash::GitStash {
4104                entries: vec![git::stash::StashEntry {
4105                    index: 0,
4106                    oid: updated_stash,
4107                    message: "updated stash".to_string(),
4108                    branch: Some("main".to_string()),
4109                    timestamp: 1,
4110                }]
4111                .into(),
4112            };
4113        })
4114        .unwrap();
4115
4116        project
4117            .update(cx, |project, cx| project.git_scans_complete(cx))
4118            .await;
4119        cx.run_until_parked();
4120
4121        cx.draw(
4122            point(px(0.), px(0.)),
4123            gpui::size(px(1200.), px(800.)),
4124            |_, _| git_graph.clone().into_any_element(),
4125        );
4126        cx.run_until_parked();
4127
4128        let reloaded_shas = git_graph.read_with(&*cx, |graph, _| {
4129            graph
4130                .graph_data
4131                .commits
4132                .iter()
4133                .map(|commit| commit.data.sha)
4134                .collect::<Vec<_>>()
4135        });
4136        assert_eq!(reloaded_shas, vec![updated_head, updated_stash]);
4137    }
4138
4139    #[gpui::test]
4140    async fn test_git_graph_row_at_position_rounding(cx: &mut TestAppContext) {
4141        init_test(cx);
4142
4143        let fs = FakeFs::new(cx.executor());
4144        fs.insert_tree(
4145            Path::new("/project"),
4146            serde_json::json!({
4147                ".git": {},
4148                "file.txt": "content",
4149            }),
4150        )
4151        .await;
4152
4153        let mut rng = StdRng::seed_from_u64(42);
4154        let commits = generate_random_commit_dag(&mut rng, 10, false);
4155        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
4156
4157        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4158        cx.run_until_parked();
4159
4160        let repository = project.read_with(cx, |project, cx| {
4161            project
4162                .active_repository(cx)
4163                .expect("should have a repository")
4164        });
4165
4166        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4167            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4168        });
4169
4170        let workspace_weak =
4171            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4172
4173        let git_graph = cx.new_window_entity(|window, cx| {
4174            GitGraph::new(
4175                repository.read(cx).id,
4176                project.read(cx).git_store().clone(),
4177                workspace_weak,
4178                window,
4179                cx,
4180            )
4181        });
4182        cx.run_until_parked();
4183
4184        git_graph.update_in(cx, |graph, window, cx| {
4185            assert!(
4186                graph.graph_data.commits.len() >= 10,
4187                "graph should load dummy commits"
4188            );
4189
4190            let row_height = GitGraph::row_height(window, cx);
4191            let origin_y = px(100.0);
4192            graph.graph_canvas_bounds.set(Some(Bounds {
4193                origin: point(px(0.0), origin_y),
4194                size: gpui::size(px(100.0), row_height * 50.0),
4195            }));
4196
4197            // Scroll down by half a row so the row under a position near the
4198            // top of the canvas is row 1 rather than row 0.
4199            let scroll_offset = row_height * 0.75;
4200            graph.table_interaction_state.update(cx, |state, _| {
4201                state.set_scroll_offset(point(px(0.0), -scroll_offset))
4202            });
4203            let pos_y = origin_y + row_height * 0.5;
4204            let absolute_calc_row = graph.row_at_position(pos_y, window, cx);
4205
4206            assert_eq!(
4207                absolute_calc_row,
4208                Some(1),
4209                "Row calculation should yield absolute row exactly"
4210            );
4211        });
4212    }
4213
4214    #[gpui::test]
4215    async fn test_row_height_matches_uniform_list_item_height(cx: &mut TestAppContext) {
4216        init_test(cx);
4217
4218        cx.update(|cx| {
4219            SettingsStore::update_global(cx, |store, cx| {
4220                store.update_user_settings(cx, |settings| {
4221                    *settings.theme = ThemeSettingsContent {
4222                        ui_font_size: Some(12.7.into()),
4223                        ..Default::default()
4224                    }
4225                });
4226            })
4227        });
4228
4229        let fs = FakeFs::new(cx.executor());
4230        fs.insert_tree(
4231            Path::new("/project"),
4232            serde_json::json!({
4233                ".git": {},
4234                "file.txt": "content",
4235            }),
4236        )
4237        .await;
4238
4239        let mut rng = StdRng::seed_from_u64(99);
4240        let commits = generate_random_commit_dag(&mut rng, 20, false);
4241        fs.set_graph_commits(Path::new("/project/.git"), commits);
4242
4243        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4244        cx.run_until_parked();
4245
4246        let repository = project.read_with(cx, |project, cx| {
4247            project
4248                .active_repository(cx)
4249                .expect("should have a repository")
4250        });
4251
4252        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4253            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
4254        });
4255
4256        let workspace_weak =
4257            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
4258
4259        let git_graph = cx.new_window_entity(|window, cx| {
4260            GitGraph::new(
4261                repository.read(cx).id,
4262                project.read(cx).git_store().clone(),
4263                workspace_weak,
4264                window,
4265                cx,
4266            )
4267        });
4268        cx.run_until_parked();
4269
4270        cx.draw(
4271            point(px(0.), px(0.)),
4272            gpui::size(px(1200.), px(800.)),
4273            |_, _| git_graph.clone().into_any_element(),
4274        );
4275        cx.run_until_parked();
4276
4277        git_graph.update_in(cx, |graph, window, cx| {
4278            let commit_count = graph.graph_data.commits.len();
4279            assert!(
4280                commit_count > 0,
4281                "need at least one commit to measure item height"
4282            );
4283
4284            let table_state = graph.table_interaction_state.read(cx);
4285            let item_size = table_state.scroll_handle.0.borrow().last_item_size.expect(
4286                "uniform_list should have populated last_item_size after draw(); \
4287                     the table has not been laid out",
4288            );
4289
4290            let measured_item_height = item_size.contents.height / commit_count as f32;
4291            let computed_row_height = GitGraph::row_height(window, cx);
4292
4293            assert_eq!(
4294                computed_row_height, measured_item_height,
4295                "GitGraph::row_height ({}) must exactly match the height that \
4296                 uniform_list measured for each table row ({}). \
4297                 A mismatch means the canvas and table rows will drift when scrolling.",
4298                computed_row_height, measured_item_height,
4299            );
4300        });
4301    }
4302}