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