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