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