git_graph.rs

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