git_graph.rs

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