### Summary This PR integrates the git graph canvas with the `Table` component’s `RedistributableColumnsState`, making the graph column resizable while preserving the table’s existing resize behavior. In particular, column resizing continues to use the same cascading redistribution behavior as the table. This is also the last PR needed to remove the feature flag on the git graph! ### Table API changes I pulled the redistributable column logic out of `Table` into reusable UI helpers so layouts outside of `Table` can participate in the same column resizing behavior. This adds a shared `RedistributableColumnsState` API, along with helpers for binding drag/drop behavior, rendering resize handles, and constructing header resize metadata. I also added `ColumnWidthConfig::explicit` and `TableRenderContext::for_column_widths` so callers can render table like headers and content with externally managed column widths. The reason for this change is that the git graph now renders a custom split layout: a graph canvas on the left and table content on the right. By reusing the same column state and resize machinery, the graph column can resize together with the table columns while preserving the existing table behavior, including cascading column redistribution and double click reset to default sizing. I also adjusted the resize handle interaction styling so the divider stays in its hovered/highlighted state while a drag is active, which makes the drag target feel more stable and visually consistent during resizing. ### Preview https://github.com/user-attachments/assets/347eed71-0cc1-4db4-9dee-a86ee5ab6f91 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ...
3896 lines
152 KiB
Rust
3896 lines
152 KiB
Rust
use collections::{BTreeMap, HashMap, IndexSet};
|
|
use editor::Editor;
|
|
use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
|
|
use git::{
|
|
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
|
|
parse_git_remote_url,
|
|
repository::{
|
|
CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath,
|
|
SearchCommitArgs,
|
|
},
|
|
status::{FileStatus, StatusCode, TrackedStatus},
|
|
};
|
|
use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon};
|
|
use gpui::{
|
|
AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent,
|
|
ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels,
|
|
Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement,
|
|
UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
|
|
px, uniform_list,
|
|
};
|
|
use language::line_diff;
|
|
use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
|
|
use project::git_store::{
|
|
CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository,
|
|
RepositoryEvent, RepositoryId,
|
|
};
|
|
use search::{
|
|
SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
|
|
ToggleCaseSensitive,
|
|
};
|
|
use settings::Settings;
|
|
use smallvec::{SmallVec, smallvec};
|
|
use std::{
|
|
cell::Cell,
|
|
ops::Range,
|
|
rc::Rc,
|
|
sync::{Arc, OnceLock},
|
|
time::{Duration, Instant},
|
|
};
|
|
use theme::AccentColors;
|
|
use theme_settings::ThemeSettings;
|
|
use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
|
|
use ui::{
|
|
ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
|
|
HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table,
|
|
TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar,
|
|
bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles,
|
|
render_table_header, table_row::TableRow,
|
|
};
|
|
use workspace::{
|
|
Workspace,
|
|
item::{Item, ItemEvent, TabTooltipContent},
|
|
};
|
|
|
|
const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);
|
|
const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
|
|
const LANE_WIDTH: Pixels = px(16.0);
|
|
const LEFT_PADDING: Pixels = px(12.0);
|
|
const LINE_WIDTH: Pixels = px(1.5);
|
|
const RESIZE_HANDLE_WIDTH: f32 = 8.0;
|
|
const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
|
|
|
|
struct CopiedState {
|
|
copied_at: Option<Instant>,
|
|
}
|
|
|
|
impl CopiedState {
|
|
fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
|
Self { copied_at: None }
|
|
}
|
|
|
|
fn is_copied(&self) -> bool {
|
|
self.copied_at
|
|
.map(|t| t.elapsed() < COPIED_STATE_DURATION)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn mark_copied(&mut self) {
|
|
self.copied_at = Some(Instant::now());
|
|
}
|
|
}
|
|
|
|
struct DraggedSplitHandle;
|
|
|
|
#[derive(Clone)]
|
|
struct ChangedFileEntry {
|
|
status: FileStatus,
|
|
file_name: SharedString,
|
|
dir_path: SharedString,
|
|
repo_path: RepoPath,
|
|
}
|
|
|
|
impl ChangedFileEntry {
|
|
fn from_commit_file(file: &CommitFile, _cx: &App) -> Self {
|
|
let file_name: SharedString = file
|
|
.path
|
|
.file_name()
|
|
.map(|n| n.to_string())
|
|
.unwrap_or_default()
|
|
.into();
|
|
let dir_path: SharedString = file
|
|
.path
|
|
.parent()
|
|
.map(|p| p.as_unix_str().to_string())
|
|
.unwrap_or_default()
|
|
.into();
|
|
|
|
let status_code = match (&file.old_text, &file.new_text) {
|
|
(None, Some(_)) => StatusCode::Added,
|
|
(Some(_), None) => StatusCode::Deleted,
|
|
_ => StatusCode::Modified,
|
|
};
|
|
|
|
let status = FileStatus::Tracked(TrackedStatus {
|
|
index_status: status_code,
|
|
worktree_status: StatusCode::Unmodified,
|
|
});
|
|
|
|
Self {
|
|
status,
|
|
file_name,
|
|
dir_path,
|
|
repo_path: file.path.clone(),
|
|
}
|
|
}
|
|
|
|
fn open_in_commit_view(
|
|
&self,
|
|
commit_sha: &SharedString,
|
|
repository: &WeakEntity<Repository>,
|
|
workspace: &WeakEntity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) {
|
|
CommitView::open(
|
|
commit_sha.to_string(),
|
|
repository.clone(),
|
|
workspace.clone(),
|
|
None,
|
|
Some(self.repo_path.clone()),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
ix: usize,
|
|
commit_sha: SharedString,
|
|
repository: WeakEntity<Repository>,
|
|
workspace: WeakEntity<Workspace>,
|
|
_cx: &App,
|
|
) -> AnyElement {
|
|
let file_name = self.file_name.clone();
|
|
let dir_path = self.dir_path.clone();
|
|
|
|
div()
|
|
.w_full()
|
|
.child(
|
|
ButtonLike::new(("changed-file", ix))
|
|
.child(
|
|
h_flex()
|
|
.min_w_0()
|
|
.w_full()
|
|
.gap_1()
|
|
.overflow_hidden()
|
|
.child(git_status_icon(self.status))
|
|
.child(
|
|
Label::new(file_name.clone())
|
|
.size(LabelSize::Small)
|
|
.truncate(),
|
|
)
|
|
.when(!dir_path.is_empty(), |this| {
|
|
this.child(
|
|
Label::new(dir_path.clone())
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted)
|
|
.truncate_start(),
|
|
)
|
|
}),
|
|
)
|
|
.tooltip({
|
|
let meta = if dir_path.is_empty() {
|
|
file_name
|
|
} else {
|
|
format!("{}/{}", dir_path, file_name).into()
|
|
};
|
|
move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx)
|
|
})
|
|
.on_click({
|
|
let entry = self.clone();
|
|
move |_, window, cx| {
|
|
entry.open_in_commit_view(
|
|
&commit_sha,
|
|
&repository,
|
|
&workspace,
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
}),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
enum QueryState {
|
|
Pending(SharedString),
|
|
Confirmed((SharedString, Task<()>)),
|
|
Empty,
|
|
}
|
|
|
|
impl QueryState {
|
|
fn next_state(&mut self) {
|
|
match self {
|
|
Self::Confirmed((query, _)) => *self = Self::Pending(std::mem::take(query)),
|
|
_ => {}
|
|
};
|
|
}
|
|
}
|
|
|
|
struct SearchState {
|
|
case_sensitive: bool,
|
|
editor: Entity<Editor>,
|
|
state: QueryState,
|
|
pub matches: IndexSet<Oid>,
|
|
pub selected_index: Option<usize>,
|
|
}
|
|
|
|
pub struct SplitState {
|
|
left_ratio: f32,
|
|
visible_left_ratio: f32,
|
|
}
|
|
|
|
impl SplitState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
left_ratio: 1.0,
|
|
visible_left_ratio: 1.0,
|
|
}
|
|
}
|
|
|
|
pub fn right_ratio(&self) -> f32 {
|
|
1.0 - self.visible_left_ratio
|
|
}
|
|
|
|
fn on_drag_move(
|
|
&mut self,
|
|
drag_event: &DragMoveEvent<DraggedSplitHandle>,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Self>,
|
|
) {
|
|
let drag_position = drag_event.event.position;
|
|
let bounds = drag_event.bounds;
|
|
let bounds_width = bounds.right() - bounds.left();
|
|
|
|
let min_ratio = 0.1;
|
|
let max_ratio = 0.9;
|
|
|
|
let new_ratio = (drag_position.x - bounds.left()) / bounds_width;
|
|
self.visible_left_ratio = new_ratio.clamp(min_ratio, max_ratio);
|
|
}
|
|
|
|
fn commit_ratio(&mut self) {
|
|
self.left_ratio = self.visible_left_ratio;
|
|
}
|
|
|
|
fn on_double_click(&mut self) {
|
|
self.left_ratio = 1.0;
|
|
self.visible_left_ratio = 1.0;
|
|
}
|
|
}
|
|
|
|
actions!(
|
|
git_graph,
|
|
[
|
|
/// Opens the commit view for the selected commit.
|
|
OpenCommitView,
|
|
]
|
|
);
|
|
|
|
fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
|
|
static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
|
|
FORMAT.get_or_init(|| {
|
|
time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
|
|
.unwrap_or_default()
|
|
})
|
|
}
|
|
|
|
fn format_timestamp(timestamp: i64) -> String {
|
|
let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
|
|
return "Unknown".to_string();
|
|
};
|
|
|
|
let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
|
|
let local_datetime = datetime.to_offset(local_offset);
|
|
|
|
local_datetime
|
|
.format(timestamp_format())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn accent_colors_count(accents: &AccentColors) -> usize {
|
|
accents.0.len()
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug)]
|
|
struct BranchColor(u8);
|
|
|
|
#[derive(Debug)]
|
|
enum LaneState {
|
|
Empty,
|
|
Active {
|
|
child: Oid,
|
|
parent: Oid,
|
|
color: Option<BranchColor>,
|
|
starting_row: usize,
|
|
starting_col: usize,
|
|
destination_column: Option<usize>,
|
|
segments: SmallVec<[CommitLineSegment; 1]>,
|
|
},
|
|
}
|
|
|
|
impl LaneState {
|
|
fn to_commit_lines(
|
|
&mut self,
|
|
ending_row: usize,
|
|
lane_column: usize,
|
|
parent_column: usize,
|
|
parent_color: BranchColor,
|
|
) -> Option<CommitLine> {
|
|
let state = std::mem::replace(self, LaneState::Empty);
|
|
|
|
match state {
|
|
LaneState::Active {
|
|
#[cfg_attr(not(test), allow(unused_variables))]
|
|
parent,
|
|
#[cfg_attr(not(test), allow(unused_variables))]
|
|
child,
|
|
color,
|
|
starting_row,
|
|
starting_col,
|
|
destination_column,
|
|
mut segments,
|
|
} => {
|
|
let final_destination = destination_column.unwrap_or(parent_column);
|
|
let final_color = color.unwrap_or(parent_color);
|
|
|
|
Some(CommitLine {
|
|
#[cfg(test)]
|
|
child,
|
|
#[cfg(test)]
|
|
parent,
|
|
child_column: starting_col,
|
|
full_interval: starting_row..ending_row,
|
|
color_idx: final_color.0 as usize,
|
|
segments: {
|
|
match segments.last_mut() {
|
|
Some(CommitLineSegment::Straight { to_row })
|
|
if *to_row == usize::MAX =>
|
|
{
|
|
if final_destination != lane_column {
|
|
*to_row = ending_row - 1;
|
|
|
|
let curved_line = CommitLineSegment::Curve {
|
|
to_column: final_destination,
|
|
on_row: ending_row,
|
|
curve_kind: CurveKind::Checkout,
|
|
};
|
|
|
|
if *to_row == starting_row {
|
|
let last_index = segments.len() - 1;
|
|
segments[last_index] = curved_line;
|
|
} else {
|
|
segments.push(curved_line);
|
|
}
|
|
} else {
|
|
*to_row = ending_row;
|
|
}
|
|
}
|
|
Some(CommitLineSegment::Curve {
|
|
on_row,
|
|
to_column,
|
|
curve_kind,
|
|
}) if *on_row == usize::MAX => {
|
|
if *to_column == usize::MAX {
|
|
*to_column = final_destination;
|
|
}
|
|
if matches!(curve_kind, CurveKind::Merge) {
|
|
*on_row = starting_row + 1;
|
|
if *on_row < ending_row {
|
|
if *to_column != final_destination {
|
|
segments.push(CommitLineSegment::Straight {
|
|
to_row: ending_row - 1,
|
|
});
|
|
segments.push(CommitLineSegment::Curve {
|
|
to_column: final_destination,
|
|
on_row: ending_row,
|
|
curve_kind: CurveKind::Checkout,
|
|
});
|
|
} else {
|
|
segments.push(CommitLineSegment::Straight {
|
|
to_row: ending_row,
|
|
});
|
|
}
|
|
} else if *to_column != final_destination {
|
|
segments.push(CommitLineSegment::Curve {
|
|
to_column: final_destination,
|
|
on_row: ending_row,
|
|
curve_kind: CurveKind::Checkout,
|
|
});
|
|
}
|
|
} else {
|
|
*on_row = ending_row;
|
|
if *to_column != final_destination {
|
|
segments.push(CommitLineSegment::Straight {
|
|
to_row: ending_row,
|
|
});
|
|
segments.push(CommitLineSegment::Curve {
|
|
to_column: final_destination,
|
|
on_row: ending_row,
|
|
curve_kind: CurveKind::Checkout,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Some(CommitLineSegment::Curve {
|
|
on_row, to_column, ..
|
|
}) => {
|
|
if *on_row < ending_row {
|
|
if *to_column != final_destination {
|
|
segments.push(CommitLineSegment::Straight {
|
|
to_row: ending_row - 1,
|
|
});
|
|
segments.push(CommitLineSegment::Curve {
|
|
to_column: final_destination,
|
|
on_row: ending_row,
|
|
curve_kind: CurveKind::Checkout,
|
|
});
|
|
} else {
|
|
segments.push(CommitLineSegment::Straight {
|
|
to_row: ending_row,
|
|
});
|
|
}
|
|
} else if *to_column != final_destination {
|
|
segments.push(CommitLineSegment::Curve {
|
|
to_column: final_destination,
|
|
on_row: ending_row,
|
|
curve_kind: CurveKind::Checkout,
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
segments
|
|
},
|
|
})
|
|
}
|
|
LaneState::Empty => None,
|
|
}
|
|
}
|
|
|
|
fn is_empty(&self) -> bool {
|
|
match self {
|
|
LaneState::Empty => true,
|
|
LaneState::Active { .. } => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CommitEntry {
|
|
data: Arc<InitialGraphCommitData>,
|
|
lane: usize,
|
|
color_idx: usize,
|
|
}
|
|
|
|
type ActiveLaneIdx = usize;
|
|
|
|
enum AllCommitCount {
|
|
NotLoaded,
|
|
Loaded(usize),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum CurveKind {
|
|
Merge,
|
|
Checkout,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum CommitLineSegment {
|
|
Straight {
|
|
to_row: usize,
|
|
},
|
|
Curve {
|
|
to_column: usize,
|
|
on_row: usize,
|
|
curve_kind: CurveKind,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CommitLine {
|
|
#[cfg(test)]
|
|
child: Oid,
|
|
#[cfg(test)]
|
|
parent: Oid,
|
|
child_column: usize,
|
|
full_interval: Range<usize>,
|
|
color_idx: usize,
|
|
segments: SmallVec<[CommitLineSegment; 1]>,
|
|
}
|
|
|
|
impl CommitLine {
|
|
fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
|
|
if first_visible_row > self.full_interval.end {
|
|
return None;
|
|
} else if first_visible_row <= self.full_interval.start {
|
|
return Some((0, self.child_column));
|
|
}
|
|
|
|
let mut current_column = self.child_column;
|
|
|
|
for (idx, segment) in self.segments.iter().enumerate() {
|
|
match segment {
|
|
CommitLineSegment::Straight { to_row } => {
|
|
if *to_row >= first_visible_row {
|
|
return Some((idx, current_column));
|
|
}
|
|
}
|
|
CommitLineSegment::Curve {
|
|
to_column, on_row, ..
|
|
} => {
|
|
if *on_row >= first_visible_row {
|
|
return Some((idx, current_column));
|
|
}
|
|
current_column = *to_column;
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
struct CommitLineKey {
|
|
child: Oid,
|
|
parent: Oid,
|
|
}
|
|
|
|
struct GraphData {
|
|
lane_states: SmallVec<[LaneState; 8]>,
|
|
lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
|
|
parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
|
|
next_color: BranchColor,
|
|
accent_colors_count: usize,
|
|
commits: Vec<Rc<CommitEntry>>,
|
|
max_commit_count: AllCommitCount,
|
|
max_lanes: usize,
|
|
lines: Vec<Rc<CommitLine>>,
|
|
active_commit_lines: HashMap<CommitLineKey, usize>,
|
|
active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
|
|
}
|
|
|
|
impl GraphData {
|
|
fn new(accent_colors_count: usize) -> Self {
|
|
GraphData {
|
|
lane_states: SmallVec::default(),
|
|
lane_colors: HashMap::default(),
|
|
parent_to_lanes: HashMap::default(),
|
|
next_color: BranchColor(0),
|
|
accent_colors_count,
|
|
commits: Vec::default(),
|
|
max_commit_count: AllCommitCount::NotLoaded,
|
|
max_lanes: 0,
|
|
lines: Vec::default(),
|
|
active_commit_lines: HashMap::default(),
|
|
active_commit_lines_by_parent: HashMap::default(),
|
|
}
|
|
}
|
|
|
|
fn clear(&mut self) {
|
|
self.lane_states.clear();
|
|
self.lane_colors.clear();
|
|
self.parent_to_lanes.clear();
|
|
self.commits.clear();
|
|
self.lines.clear();
|
|
self.active_commit_lines.clear();
|
|
self.active_commit_lines_by_parent.clear();
|
|
self.next_color = BranchColor(0);
|
|
self.max_commit_count = AllCommitCount::NotLoaded;
|
|
self.max_lanes = 0;
|
|
}
|
|
|
|
fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
|
|
self.lane_states
|
|
.iter()
|
|
.position(LaneState::is_empty)
|
|
.unwrap_or_else(|| {
|
|
self.lane_states.push(LaneState::Empty);
|
|
self.lane_states.len() - 1
|
|
})
|
|
}
|
|
|
|
fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
|
|
let accent_colors_count = self.accent_colors_count;
|
|
*self.lane_colors.entry(lane_idx).or_insert_with(|| {
|
|
let color_idx = self.next_color;
|
|
self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
|
|
color_idx
|
|
})
|
|
}
|
|
|
|
fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
|
|
self.commits.reserve(commits.len());
|
|
self.lines.reserve(commits.len() / 2);
|
|
|
|
for commit in commits.iter() {
|
|
let commit_row = self.commits.len();
|
|
|
|
let commit_lane = self
|
|
.parent_to_lanes
|
|
.get(&commit.sha)
|
|
.and_then(|lanes| lanes.first().copied());
|
|
|
|
let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
|
|
|
|
let commit_color = self.get_lane_color(commit_lane);
|
|
|
|
if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
|
|
for lane_column in lanes {
|
|
let state = &mut self.lane_states[lane_column];
|
|
|
|
if let LaneState::Active {
|
|
starting_row,
|
|
segments,
|
|
..
|
|
} = state
|
|
{
|
|
if let Some(CommitLineSegment::Curve {
|
|
to_column,
|
|
curve_kind: CurveKind::Merge,
|
|
..
|
|
}) = segments.first_mut()
|
|
{
|
|
let curve_row = *starting_row + 1;
|
|
let would_overlap =
|
|
if lane_column != commit_lane && curve_row < commit_row {
|
|
self.commits[curve_row..commit_row]
|
|
.iter()
|
|
.any(|c| c.lane == commit_lane)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if would_overlap {
|
|
*to_column = lane_column;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(commit_line) =
|
|
state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color)
|
|
{
|
|
self.lines.push(Rc::new(commit_line));
|
|
}
|
|
}
|
|
}
|
|
|
|
commit
|
|
.parents
|
|
.iter()
|
|
.enumerate()
|
|
.for_each(|(parent_idx, parent)| {
|
|
if parent_idx == 0 {
|
|
self.lane_states[commit_lane] = LaneState::Active {
|
|
parent: *parent,
|
|
child: commit.sha,
|
|
color: Some(commit_color),
|
|
starting_col: commit_lane,
|
|
starting_row: commit_row,
|
|
destination_column: None,
|
|
segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
|
|
};
|
|
|
|
self.parent_to_lanes
|
|
.entry(*parent)
|
|
.or_default()
|
|
.push(commit_lane);
|
|
} else {
|
|
let new_lane = self.first_empty_lane_idx();
|
|
|
|
self.lane_states[new_lane] = LaneState::Active {
|
|
parent: *parent,
|
|
child: commit.sha,
|
|
color: None,
|
|
starting_col: commit_lane,
|
|
starting_row: commit_row,
|
|
destination_column: None,
|
|
segments: smallvec![CommitLineSegment::Curve {
|
|
to_column: usize::MAX,
|
|
on_row: usize::MAX,
|
|
curve_kind: CurveKind::Merge,
|
|
},],
|
|
};
|
|
|
|
self.parent_to_lanes
|
|
.entry(*parent)
|
|
.or_default()
|
|
.push(new_lane);
|
|
}
|
|
});
|
|
|
|
self.max_lanes = self.max_lanes.max(self.lane_states.len());
|
|
|
|
self.commits.push(Rc::new(CommitEntry {
|
|
data: commit.clone(),
|
|
lane: commit_lane,
|
|
color_idx: commit_color.0 as usize,
|
|
}));
|
|
}
|
|
|
|
self.max_commit_count = AllCommitCount::Loaded(self.commits.len());
|
|
}
|
|
}
|
|
|
|
pub fn init(cx: &mut App) {
|
|
workspace::register_serializable_item::<GitGraph>(cx);
|
|
|
|
cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
|
|
workspace.register_action_renderer(|div, workspace, _, cx| {
|
|
div.when(
|
|
workspace.project().read(cx).active_repository(cx).is_some()
|
|
&& cx.has_flag::<GitGraphFeatureFlag>(),
|
|
|div| {
|
|
let workspace = workspace.weak_handle();
|
|
|
|
div.on_action({
|
|
let workspace = workspace.clone();
|
|
move |_: &git_ui::git_panel::Open, window, cx| {
|
|
workspace
|
|
.update(cx, |workspace, cx| {
|
|
let Some(repo) =
|
|
workspace.project().read(cx).active_repository(cx)
|
|
else {
|
|
return;
|
|
};
|
|
let selected_repo_id = repo.read(cx).id;
|
|
|
|
let existing = workspace
|
|
.items_of_type::<GitGraph>(cx)
|
|
.find(|graph| graph.read(cx).repo_id == selected_repo_id);
|
|
if let Some(existing) = existing {
|
|
workspace.activate_item(&existing, true, true, window, cx);
|
|
return;
|
|
}
|
|
|
|
let git_store =
|
|
workspace.project().read(cx).git_store().clone();
|
|
let workspace_handle = workspace.weak_handle();
|
|
let git_graph = cx.new(|cx| {
|
|
GitGraph::new(
|
|
selected_repo_id,
|
|
git_store,
|
|
workspace_handle,
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
workspace.add_item_to_active_pane(
|
|
Box::new(git_graph),
|
|
None,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.on_action(
|
|
move |action: &git_ui::git_panel::OpenAtCommit, window, cx| {
|
|
let sha = action.sha.clone();
|
|
workspace
|
|
.update(cx, |workspace, cx| {
|
|
let Some(repo) =
|
|
workspace.project().read(cx).active_repository(cx)
|
|
else {
|
|
return;
|
|
};
|
|
let selected_repo_id = repo.read(cx).id;
|
|
|
|
let existing = workspace
|
|
.items_of_type::<GitGraph>(cx)
|
|
.find(|graph| graph.read(cx).repo_id == selected_repo_id);
|
|
if let Some(existing) = existing {
|
|
existing.update(cx, |graph, cx| {
|
|
graph.select_commit_by_sha(sha.as_str(), cx);
|
|
});
|
|
workspace.activate_item(&existing, true, true, window, cx);
|
|
return;
|
|
}
|
|
|
|
let git_store =
|
|
workspace.project().read(cx).git_store().clone();
|
|
let workspace_handle = workspace.weak_handle();
|
|
let git_graph = cx.new(|cx| {
|
|
let mut graph = GitGraph::new(
|
|
selected_repo_id,
|
|
git_store,
|
|
workspace_handle,
|
|
window,
|
|
cx,
|
|
);
|
|
graph.select_commit_by_sha(sha.as_str(), cx);
|
|
graph
|
|
});
|
|
workspace.add_item_to_active_pane(
|
|
Box::new(git_graph),
|
|
None,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
},
|
|
)
|
|
},
|
|
)
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn lane_center_x(bounds: Bounds<Pixels>, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels {
|
|
bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset
|
|
}
|
|
|
|
fn to_row_center(
|
|
to_row: usize,
|
|
row_height: Pixels,
|
|
scroll_offset: Pixels,
|
|
bounds: Bounds<Pixels>,
|
|
) -> Pixels {
|
|
bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
|
|
}
|
|
|
|
fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
|
|
let radius = COMMIT_CIRCLE_RADIUS;
|
|
|
|
let mut builder = PathBuilder::fill();
|
|
|
|
// Start at the rightmost point of the circle
|
|
builder.move_to(point(center_x + radius, center_y));
|
|
|
|
// Draw the circle using two arc_to calls (top half, then bottom half)
|
|
builder.arc_to(
|
|
point(radius, radius),
|
|
px(0.),
|
|
false,
|
|
true,
|
|
point(center_x - radius, center_y),
|
|
);
|
|
builder.arc_to(
|
|
point(radius, radius),
|
|
px(0.),
|
|
false,
|
|
true,
|
|
point(center_x + radius, center_y),
|
|
);
|
|
builder.close();
|
|
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
|
|
fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
|
|
diff.files.iter().fold((0, 0), |(added, removed), file| {
|
|
let old_text = file.old_text.as_deref().unwrap_or("");
|
|
let new_text = file.new_text.as_deref().unwrap_or("");
|
|
let hunks = line_diff(old_text, new_text);
|
|
hunks
|
|
.iter()
|
|
.fold((added, removed), |(a, r), (old_range, new_range)| {
|
|
(
|
|
a + (new_range.end - new_range.start) as usize,
|
|
r + (old_range.end - old_range.start) as usize,
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
pub struct GitGraph {
|
|
focus_handle: FocusHandle,
|
|
search_state: SearchState,
|
|
graph_data: GraphData,
|
|
git_store: Entity<GitStore>,
|
|
workspace: WeakEntity<Workspace>,
|
|
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
|
row_height: Pixels,
|
|
table_interaction_state: Entity<TableInteractionState>,
|
|
column_widths: Entity<RedistributableColumnsState>,
|
|
horizontal_scroll_offset: Pixels,
|
|
selected_entry_idx: Option<usize>,
|
|
hovered_entry_idx: Option<usize>,
|
|
graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
|
log_source: LogSource,
|
|
log_order: LogOrder,
|
|
selected_commit_diff: Option<CommitDiff>,
|
|
selected_commit_diff_stats: Option<(usize, usize)>,
|
|
_commit_diff_task: Option<Task<()>>,
|
|
commit_details_split_state: Entity<SplitState>,
|
|
repo_id: RepositoryId,
|
|
changed_files_scroll_handle: UniformListScrollHandle,
|
|
pending_select_sha: Option<Oid>,
|
|
}
|
|
|
|
impl GitGraph {
|
|
fn invalidate_state(&mut self, cx: &mut Context<Self>) {
|
|
self.graph_data.clear();
|
|
self.search_state.matches.clear();
|
|
self.search_state.selected_index = None;
|
|
self.search_state.state.next_state();
|
|
cx.notify();
|
|
}
|
|
|
|
fn row_height(cx: &App) -> Pixels {
|
|
let settings = ThemeSettings::get_global(cx);
|
|
let font_size = settings.buffer_font_size(cx);
|
|
font_size + px(12.0)
|
|
}
|
|
|
|
fn graph_canvas_content_width(&self) -> Pixels {
|
|
(LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0
|
|
}
|
|
|
|
fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] {
|
|
let fractions = self
|
|
.column_widths
|
|
.read(cx)
|
|
.preview_fractions(window.rem_size());
|
|
[
|
|
fractions[0],
|
|
fractions[1],
|
|
fractions[2],
|
|
fractions[3],
|
|
fractions[4],
|
|
]
|
|
}
|
|
|
|
fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig {
|
|
let [_, description, date, author, commit] = self.preview_column_fractions(window, cx);
|
|
let table_total = description + date + author + commit;
|
|
|
|
let widths = if table_total > 0.0 {
|
|
vec![
|
|
DefiniteLength::Fraction(description / table_total),
|
|
DefiniteLength::Fraction(date / table_total),
|
|
DefiniteLength::Fraction(author / table_total),
|
|
DefiniteLength::Fraction(commit / table_total),
|
|
]
|
|
} else {
|
|
vec![
|
|
DefiniteLength::Fraction(0.25),
|
|
DefiniteLength::Fraction(0.25),
|
|
DefiniteLength::Fraction(0.25),
|
|
DefiniteLength::Fraction(0.25),
|
|
]
|
|
};
|
|
|
|
ColumnWidthConfig::explicit(widths)
|
|
}
|
|
|
|
fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels {
|
|
self.column_widths
|
|
.read(cx)
|
|
.preview_column_width(0, window)
|
|
.unwrap_or_else(|| self.graph_canvas_content_width())
|
|
}
|
|
|
|
fn clamp_horizontal_scroll_offset(&mut self, graph_viewport_width: Pixels) {
|
|
let max_horizontal_scroll =
|
|
(self.graph_canvas_content_width() - graph_viewport_width).max(px(0.));
|
|
self.horizontal_scroll_offset = self
|
|
.horizontal_scroll_offset
|
|
.clamp(px(0.), max_horizontal_scroll);
|
|
}
|
|
|
|
pub fn new(
|
|
repo_id: RepositoryId,
|
|
git_store: Entity<GitStore>,
|
|
workspace: WeakEntity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let focus_handle = cx.focus_handle();
|
|
cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
|
|
.detach();
|
|
|
|
let accent_colors = cx.theme().accents();
|
|
let graph = GraphData::new(accent_colors_count(accent_colors));
|
|
let log_source = LogSource::default();
|
|
let log_order = LogOrder::default();
|
|
|
|
cx.subscribe(&git_store, |this, _, event, cx| match event {
|
|
GitStoreEvent::RepositoryUpdated(updated_repo_id, repo_event, _) => {
|
|
if this.repo_id == *updated_repo_id {
|
|
if let Some(repository) = this.get_repository(cx) {
|
|
this.on_repository_event(repository, repo_event, cx);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
})
|
|
.detach();
|
|
|
|
let search_editor = cx.new(|cx| {
|
|
let mut editor = Editor::single_line(window, cx);
|
|
editor.set_placeholder_text("Search commits…", window, cx);
|
|
editor
|
|
});
|
|
|
|
let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
|
|
let column_widths = cx.new(|_cx| {
|
|
RedistributableColumnsState::new(
|
|
5,
|
|
vec![
|
|
DefiniteLength::Fraction(0.14),
|
|
DefiniteLength::Fraction(0.6192),
|
|
DefiniteLength::Fraction(0.1032),
|
|
DefiniteLength::Fraction(0.086),
|
|
DefiniteLength::Fraction(0.0516),
|
|
],
|
|
vec![
|
|
TableResizeBehavior::Resizable,
|
|
TableResizeBehavior::Resizable,
|
|
TableResizeBehavior::Resizable,
|
|
TableResizeBehavior::Resizable,
|
|
TableResizeBehavior::Resizable,
|
|
],
|
|
)
|
|
});
|
|
let mut row_height = Self::row_height(cx);
|
|
|
|
cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
|
|
let new_row_height = Self::row_height(cx);
|
|
if new_row_height != row_height {
|
|
this.row_height = new_row_height;
|
|
this.table_interaction_state.update(cx, |state, _cx| {
|
|
state.scroll_handle.0.borrow_mut().last_item_size = None;
|
|
});
|
|
row_height = new_row_height;
|
|
cx.notify();
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let mut this = GitGraph {
|
|
focus_handle,
|
|
git_store,
|
|
search_state: SearchState {
|
|
case_sensitive: false,
|
|
editor: search_editor,
|
|
matches: IndexSet::default(),
|
|
selected_index: None,
|
|
state: QueryState::Empty,
|
|
},
|
|
workspace,
|
|
graph_data: graph,
|
|
_commit_diff_task: None,
|
|
context_menu: None,
|
|
row_height,
|
|
table_interaction_state,
|
|
column_widths,
|
|
horizontal_scroll_offset: px(0.),
|
|
selected_entry_idx: None,
|
|
hovered_entry_idx: None,
|
|
graph_canvas_bounds: Rc::new(Cell::new(None)),
|
|
selected_commit_diff: None,
|
|
selected_commit_diff_stats: None,
|
|
log_source,
|
|
log_order,
|
|
commit_details_split_state: cx.new(|_cx| SplitState::new()),
|
|
repo_id,
|
|
changed_files_scroll_handle: UniformListScrollHandle::new(),
|
|
pending_select_sha: None,
|
|
};
|
|
|
|
this.fetch_initial_graph_data(cx);
|
|
this
|
|
}
|
|
|
|
fn on_repository_event(
|
|
&mut self,
|
|
repository: Entity<Repository>,
|
|
event: &RepositoryEvent,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
RepositoryEvent::GraphEvent((source, order), event)
|
|
if source == &self.log_source && order == &self.log_order =>
|
|
{
|
|
match event {
|
|
GitGraphEvent::FullyLoaded => {
|
|
if let Some(pending_sha_index) =
|
|
self.pending_select_sha.take().and_then(|oid| {
|
|
repository
|
|
.read(cx)
|
|
.get_graph_data(source.clone(), *order)
|
|
.and_then(|data| data.commit_oid_to_index.get(&oid).copied())
|
|
})
|
|
{
|
|
self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx);
|
|
}
|
|
}
|
|
GitGraphEvent::LoadingError => {
|
|
// todo(git_graph): Wire this up with the UI
|
|
}
|
|
GitGraphEvent::CountUpdated(commit_count) => {
|
|
let old_count = self.graph_data.commits.len();
|
|
|
|
if let Some(pending_selection_index) =
|
|
repository.update(cx, |repository, cx| {
|
|
let GraphDataResponse {
|
|
commits,
|
|
is_loading,
|
|
error: _,
|
|
} = repository.graph_data(
|
|
source.clone(),
|
|
*order,
|
|
old_count..*commit_count,
|
|
cx,
|
|
);
|
|
self.graph_data.add_commits(commits);
|
|
|
|
let pending_sha_index = self.pending_select_sha.and_then(|oid| {
|
|
repository.get_graph_data(source.clone(), *order).and_then(
|
|
|data| data.commit_oid_to_index.get(&oid).copied(),
|
|
)
|
|
});
|
|
|
|
if !is_loading && pending_sha_index.is_none() {
|
|
self.pending_select_sha.take();
|
|
}
|
|
|
|
pending_sha_index
|
|
})
|
|
{
|
|
self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx);
|
|
self.pending_select_sha.take();
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
RepositoryEvent::BranchChanged => {
|
|
self.pending_select_sha = None;
|
|
// Only invalidate if we scanned atleast once,
|
|
// meaning we are not inside the initial repo loading state
|
|
// NOTE: this fixes an loading performance regression
|
|
if repository.read(cx).scan_id > 1 {
|
|
self.invalidate_state(cx);
|
|
}
|
|
}
|
|
RepositoryEvent::GraphEvent(_, _) => {}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn fetch_initial_graph_data(&mut self, cx: &mut App) {
|
|
if let Some(repository) = self.get_repository(cx) {
|
|
repository.update(cx, |repository, cx| {
|
|
let commits = repository
|
|
.graph_data(self.log_source.clone(), self.log_order, 0..usize::MAX, cx)
|
|
.commits;
|
|
self.graph_data.add_commits(commits);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn get_repository(&self, cx: &App) -> Option<Entity<Repository>> {
|
|
let git_store = self.git_store.read(cx);
|
|
git_store.repositories().get(&self.repo_id).cloned()
|
|
}
|
|
|
|
fn render_chip(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
|
|
Chip::new(name.clone())
|
|
.label_size(LabelSize::Small)
|
|
.bg_color(accent_color.opacity(0.1))
|
|
.border_color(accent_color.opacity(0.5))
|
|
}
|
|
|
|
fn render_table_rows(
|
|
&mut self,
|
|
range: Range<usize>,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Vec<Vec<AnyElement>> {
|
|
let repository = self.get_repository(cx);
|
|
|
|
let row_height = self.row_height;
|
|
|
|
// We fetch data outside the visible viewport to avoid loading entries when
|
|
// users scroll through the git graph
|
|
if let Some(repository) = repository.as_ref() {
|
|
const FETCH_RANGE: usize = 100;
|
|
repository.update(cx, |repository, cx| {
|
|
self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
|
|
..(range.end + FETCH_RANGE)
|
|
.min(self.graph_data.commits.len().saturating_sub(1))]
|
|
.iter()
|
|
.for_each(|commit| {
|
|
repository.fetch_commit_data(commit.data.sha, cx);
|
|
});
|
|
});
|
|
}
|
|
|
|
range
|
|
.map(|idx| {
|
|
let Some((commit, repository)) =
|
|
self.graph_data.commits.get(idx).zip(repository.as_ref())
|
|
else {
|
|
return vec![
|
|
div().h(row_height).into_any_element(),
|
|
div().h(row_height).into_any_element(),
|
|
div().h(row_height).into_any_element(),
|
|
div().h(row_height).into_any_element(),
|
|
];
|
|
};
|
|
|
|
let data = repository.update(cx, |repository, cx| {
|
|
repository.fetch_commit_data(commit.data.sha, cx).clone()
|
|
});
|
|
|
|
let short_sha = commit.data.sha.display_short();
|
|
let mut formatted_time = String::new();
|
|
let subject: SharedString;
|
|
let author_name: SharedString;
|
|
|
|
if let CommitDataState::Loaded(data) = data {
|
|
subject = data.subject.clone();
|
|
author_name = data.author_name.clone();
|
|
formatted_time = format_timestamp(data.commit_timestamp);
|
|
} else {
|
|
subject = "Loading…".into();
|
|
author_name = "".into();
|
|
}
|
|
|
|
let accent_colors = cx.theme().accents();
|
|
let accent_color = accent_colors
|
|
.0
|
|
.get(commit.color_idx)
|
|
.copied()
|
|
.unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
|
|
|
|
let is_selected = self.selected_entry_idx == Some(idx);
|
|
let is_matched = self.search_state.matches.contains(&commit.data.sha);
|
|
let column_label = |label: SharedString| {
|
|
Label::new(label)
|
|
.when(!is_selected, |c| c.color(Color::Muted))
|
|
.truncate()
|
|
.into_any_element()
|
|
};
|
|
|
|
let subject_label = if is_matched {
|
|
let query = match &self.search_state.state {
|
|
QueryState::Confirmed((query, _)) => Some(query.clone()),
|
|
_ => None,
|
|
};
|
|
let highlight_ranges = query
|
|
.and_then(|q| {
|
|
let ranges = if self.search_state.case_sensitive {
|
|
subject
|
|
.match_indices(q.as_str())
|
|
.map(|(start, matched)| start..start + matched.len())
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
let q = q.to_lowercase();
|
|
let subject_lower = subject.to_lowercase();
|
|
|
|
subject_lower
|
|
.match_indices(&q)
|
|
.filter_map(|(start, matched)| {
|
|
let end = start + matched.len();
|
|
subject.is_char_boundary(start).then_some(()).and_then(
|
|
|_| subject.is_char_boundary(end).then_some(start..end),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
(!ranges.is_empty()).then_some(ranges)
|
|
})
|
|
.unwrap_or_default();
|
|
HighlightedLabel::from_ranges(subject.clone(), highlight_ranges)
|
|
.when(!is_selected, |c| c.color(Color::Muted))
|
|
.truncate()
|
|
.into_any_element()
|
|
} else {
|
|
column_label(subject.clone())
|
|
};
|
|
|
|
vec![
|
|
div()
|
|
.id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
|
|
.overflow_hidden()
|
|
.tooltip(Tooltip::text(subject))
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.overflow_hidden()
|
|
.children((!commit.data.ref_names.is_empty()).then(|| {
|
|
h_flex().gap_1().children(
|
|
commit
|
|
.data
|
|
.ref_names
|
|
.iter()
|
|
.map(|name| self.render_chip(name, accent_color)),
|
|
)
|
|
}))
|
|
.child(subject_label),
|
|
)
|
|
.into_any_element(),
|
|
column_label(formatted_time.into()),
|
|
column_label(author_name),
|
|
column_label(short_sha.into()),
|
|
]
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.selected_entry_idx = None;
|
|
self.selected_commit_diff = None;
|
|
self.selected_commit_diff_stats = None;
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_entry(0, ScrollStrategy::Nearest, cx);
|
|
}
|
|
|
|
fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(selected_entry_idx) = &self.selected_entry_idx {
|
|
self.select_entry(
|
|
selected_entry_idx.saturating_sub(1),
|
|
ScrollStrategy::Nearest,
|
|
cx,
|
|
);
|
|
} else {
|
|
self.select_first(&SelectFirst, window, cx);
|
|
}
|
|
}
|
|
|
|
fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(selected_entry_idx) = &self.selected_entry_idx {
|
|
self.select_entry(
|
|
selected_entry_idx
|
|
.saturating_add(1)
|
|
.min(self.graph_data.commits.len().saturating_sub(1)),
|
|
ScrollStrategy::Nearest,
|
|
cx,
|
|
);
|
|
} else {
|
|
self.select_prev(&SelectPrevious, window, cx);
|
|
}
|
|
}
|
|
|
|
fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_entry(
|
|
self.graph_data.commits.len().saturating_sub(1),
|
|
ScrollStrategy::Nearest,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.open_selected_commit_view(window, cx);
|
|
}
|
|
|
|
fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
|
|
let Some(repo) = self.get_repository(cx) else {
|
|
return;
|
|
};
|
|
|
|
self.search_state.matches.clear();
|
|
self.search_state.selected_index = None;
|
|
self.search_state.editor.update(cx, |editor, _cx| {
|
|
editor.set_text_style_refinement(Default::default());
|
|
});
|
|
|
|
if query.as_str().is_empty() {
|
|
self.search_state.state = QueryState::Empty;
|
|
cx.notify();
|
|
return;
|
|
}
|
|
|
|
let (request_tx, request_rx) = smol::channel::unbounded::<Oid>();
|
|
|
|
repo.update(cx, |repo, cx| {
|
|
repo.search_commits(
|
|
self.log_source.clone(),
|
|
SearchCommitArgs {
|
|
query: query.clone(),
|
|
case_sensitive: self.search_state.case_sensitive,
|
|
},
|
|
request_tx,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
let search_task = cx.spawn(async move |this, cx| {
|
|
while let Ok(first_oid) = request_rx.recv().await {
|
|
let mut pending_oids = vec![first_oid];
|
|
while let Ok(oid) = request_rx.try_recv() {
|
|
pending_oids.push(oid);
|
|
}
|
|
|
|
this.update(cx, |this, cx| {
|
|
if this.search_state.selected_index.is_none() {
|
|
this.search_state.selected_index = Some(0);
|
|
this.select_commit_by_sha(first_oid, cx);
|
|
}
|
|
|
|
this.search_state.matches.extend(pending_oids);
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
this.update(cx, |this, cx| {
|
|
if this.search_state.matches.is_empty() {
|
|
this.search_state.editor.update(cx, |editor, cx| {
|
|
editor.set_text_style_refinement(TextStyleRefinement {
|
|
color: Some(Color::Error.color(cx)),
|
|
..Default::default()
|
|
});
|
|
});
|
|
}
|
|
})
|
|
.ok();
|
|
});
|
|
|
|
self.search_state.state = QueryState::Confirmed((query, search_task));
|
|
}
|
|
|
|
fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let query = self.search_state.editor.read(cx).text(cx).into();
|
|
self.search(query, cx);
|
|
}
|
|
|
|
fn select_entry(
|
|
&mut self,
|
|
idx: usize,
|
|
scroll_strategy: ScrollStrategy,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.selected_entry_idx == Some(idx) {
|
|
return;
|
|
}
|
|
|
|
self.selected_entry_idx = Some(idx);
|
|
self.selected_commit_diff = None;
|
|
self.selected_commit_diff_stats = None;
|
|
self.changed_files_scroll_handle
|
|
.scroll_to_item(0, ScrollStrategy::Top);
|
|
self.table_interaction_state.update(cx, |state, cx| {
|
|
state.scroll_handle.scroll_to_item(idx, scroll_strategy);
|
|
cx.notify();
|
|
});
|
|
|
|
let Some(commit) = self.graph_data.commits.get(idx) else {
|
|
return;
|
|
};
|
|
|
|
let sha = commit.data.sha.to_string();
|
|
|
|
let Some(repository) = self.get_repository(cx) else {
|
|
return;
|
|
};
|
|
|
|
let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
|
|
|
|
self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
|
|
if let Ok(Ok(diff)) = diff_receiver.await {
|
|
this.update(cx, |this, cx| {
|
|
let stats = compute_diff_stats(&diff);
|
|
this.selected_commit_diff = Some(diff);
|
|
this.selected_commit_diff_stats = Some(stats);
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
}
|
|
}));
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_previous_match(&mut self, cx: &mut Context<Self>) {
|
|
if self.search_state.matches.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut prev_selection = self.search_state.selected_index.unwrap_or_default();
|
|
|
|
if prev_selection == 0 {
|
|
prev_selection = self.search_state.matches.len() - 1;
|
|
} else {
|
|
prev_selection -= 1;
|
|
}
|
|
|
|
let Some(&oid) = self.search_state.matches.get_index(prev_selection) else {
|
|
return;
|
|
};
|
|
|
|
self.search_state.selected_index = Some(prev_selection);
|
|
self.select_commit_by_sha(oid, cx);
|
|
}
|
|
|
|
fn select_next_match(&mut self, cx: &mut Context<Self>) {
|
|
if self.search_state.matches.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut next_selection = self
|
|
.search_state
|
|
.selected_index
|
|
.map(|index| index + 1)
|
|
.unwrap_or_default();
|
|
|
|
if next_selection >= self.search_state.matches.len() {
|
|
next_selection = 0;
|
|
}
|
|
|
|
let Some(&oid) = self.search_state.matches.get_index(next_selection) else {
|
|
return;
|
|
};
|
|
|
|
self.search_state.selected_index = Some(next_selection);
|
|
self.select_commit_by_sha(oid, cx);
|
|
}
|
|
|
|
pub fn set_repo_id(&mut self, repo_id: RepositoryId, cx: &mut Context<Self>) {
|
|
if repo_id != self.repo_id
|
|
&& self
|
|
.git_store
|
|
.read(cx)
|
|
.repositories()
|
|
.contains_key(&repo_id)
|
|
{
|
|
self.repo_id = repo_id;
|
|
self.invalidate_state(cx);
|
|
}
|
|
}
|
|
|
|
pub fn select_commit_by_sha(&mut self, sha: impl TryInto<Oid>, cx: &mut Context<Self>) {
|
|
fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
|
|
let Some(selected_repository) = this.get_repository(cx) else {
|
|
return;
|
|
};
|
|
|
|
let Some(index) = selected_repository
|
|
.read(cx)
|
|
.get_graph_data(this.log_source.clone(), this.log_order)
|
|
.and_then(|data| data.commit_oid_to_index.get(&oid))
|
|
.copied()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
this.select_entry(index, ScrollStrategy::Center, cx);
|
|
}
|
|
|
|
if let Ok(oid) = sha.try_into() {
|
|
inner(self, oid, cx);
|
|
}
|
|
}
|
|
|
|
fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(selected_entry_index) = self.selected_entry_idx else {
|
|
return;
|
|
};
|
|
|
|
self.open_commit_view(selected_entry_index, window, cx);
|
|
}
|
|
|
|
fn open_commit_view(
|
|
&mut self,
|
|
entry_index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(commit_entry) = self.graph_data.commits.get(entry_index) else {
|
|
return;
|
|
};
|
|
|
|
let Some(repository) = self.get_repository(cx) else {
|
|
return;
|
|
};
|
|
|
|
CommitView::open(
|
|
commit_entry.data.sha.to_string(),
|
|
repository.downgrade(),
|
|
self.workspace.clone(),
|
|
None,
|
|
None,
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn get_remote(
|
|
&self,
|
|
repository: &Repository,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Option<GitRemote> {
|
|
let remote_url = repository.default_remote_url()?;
|
|
let provider_registry = GitHostingProviderRegistry::default_global(cx);
|
|
let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
|
|
Some(GitRemote {
|
|
host: provider,
|
|
owner: parsed.owner.into(),
|
|
repo: parsed.repo.into(),
|
|
})
|
|
}
|
|
|
|
fn render_search_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let color = cx.theme().colors();
|
|
let query_focus_handle = self.search_state.editor.focus_handle(cx);
|
|
let search_options = {
|
|
let mut options = SearchOptions::NONE;
|
|
options.set(
|
|
SearchOptions::CASE_SENSITIVE,
|
|
self.search_state.case_sensitive,
|
|
);
|
|
options
|
|
};
|
|
|
|
h_flex()
|
|
.w_full()
|
|
.p_1p5()
|
|
.gap_1p5()
|
|
.border_b_1()
|
|
.border_color(color.border_variant)
|
|
.child(
|
|
h_flex()
|
|
.h_8()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.px_1p5()
|
|
.gap_1()
|
|
.border_1()
|
|
.border_color(color.border)
|
|
.rounded_md()
|
|
.bg(color.toolbar_background)
|
|
.on_action(cx.listener(Self::confirm_search))
|
|
.child(self.search_state.editor.clone())
|
|
.child(SearchOption::CaseSensitive.as_button(
|
|
search_options,
|
|
SearchSource::Buffer,
|
|
query_focus_handle,
|
|
)),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.min_w_64()
|
|
.gap_1()
|
|
.child({
|
|
let focus_handle = self.focus_handle.clone();
|
|
IconButton::new("git-graph-search-prev", IconName::ChevronLeft)
|
|
.shape(ui::IconButtonShape::Square)
|
|
.icon_size(IconSize::Small)
|
|
.tooltip(move |_, cx| {
|
|
Tooltip::for_action_in(
|
|
"Select Previous Match",
|
|
&SelectPreviousMatch,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
})
|
|
.map(|this| {
|
|
if self.search_state.matches.is_empty() {
|
|
this.disabled(true)
|
|
} else {
|
|
this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
|
|
this.select_previous_match(cx);
|
|
}))
|
|
}
|
|
})
|
|
})
|
|
.child({
|
|
let focus_handle = self.focus_handle.clone();
|
|
IconButton::new("git-graph-search-next", IconName::ChevronRight)
|
|
.shape(ui::IconButtonShape::Square)
|
|
.icon_size(IconSize::Small)
|
|
.tooltip(move |_, cx| {
|
|
Tooltip::for_action_in(
|
|
"Select Next Match",
|
|
&SelectNextMatch,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
})
|
|
.map(|this| {
|
|
if self.search_state.matches.is_empty() {
|
|
this.disabled(true)
|
|
} else {
|
|
this.disabled(false).on_click(cx.listener(|this, _, _, cx| {
|
|
this.select_next_match(cx);
|
|
}))
|
|
}
|
|
})
|
|
})
|
|
.child(
|
|
h_flex()
|
|
.gap_1p5()
|
|
.child(
|
|
Label::new(format!(
|
|
"{}/{}",
|
|
self.search_state
|
|
.selected_index
|
|
.map(|index| index + 1)
|
|
.unwrap_or(0),
|
|
self.search_state.matches.len()
|
|
))
|
|
.size(LabelSize::Small)
|
|
.when(self.search_state.matches.is_empty(), |this| {
|
|
this.color(Color::Disabled)
|
|
}),
|
|
)
|
|
.when(
|
|
matches!(
|
|
&self.search_state.state,
|
|
QueryState::Confirmed((_, task)) if !task.is_ready()
|
|
),
|
|
|this| {
|
|
this.child(
|
|
Icon::new(IconName::ArrowCircle)
|
|
.color(Color::Accent)
|
|
.size(IconSize::Small)
|
|
.with_rotate_animation(2)
|
|
.into_any_element(),
|
|
)
|
|
},
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn render_loading_spinner(&self, cx: &App) -> AnyElement {
|
|
let rems = TextSize::Large.rems(cx);
|
|
Icon::new(IconName::LoadCircle)
|
|
.size(IconSize::Custom(rems))
|
|
.color(Color::Accent)
|
|
.with_rotate_animation(3)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_commit_detail_panel(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let Some(selected_idx) = self.selected_entry_idx else {
|
|
return Empty.into_any_element();
|
|
};
|
|
|
|
let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
|
|
return Empty.into_any_element();
|
|
};
|
|
|
|
let Some(repository) = self.get_repository(cx) else {
|
|
return Empty.into_any_element();
|
|
};
|
|
|
|
let data = repository.update(cx, |repository, cx| {
|
|
repository
|
|
.fetch_commit_data(commit_entry.data.sha, cx)
|
|
.clone()
|
|
});
|
|
|
|
let full_sha: SharedString = commit_entry.data.sha.to_string().into();
|
|
let ref_names = commit_entry.data.ref_names.clone();
|
|
|
|
let accent_colors = cx.theme().accents();
|
|
let accent_color = accent_colors
|
|
.0
|
|
.get(commit_entry.color_idx)
|
|
.copied()
|
|
.unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
|
|
|
|
// todo(git graph): We should use the full commit message here
|
|
let (author_name, author_email, commit_timestamp, commit_message) = match &data {
|
|
CommitDataState::Loaded(data) => (
|
|
data.author_name.clone(),
|
|
data.author_email.clone(),
|
|
Some(data.commit_timestamp),
|
|
data.subject.clone(),
|
|
),
|
|
CommitDataState::Loading => ("Loading…".into(), "".into(), None, "Loading…".into()),
|
|
};
|
|
|
|
let date_string = commit_timestamp
|
|
.and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
|
|
.map(|datetime| {
|
|
let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
|
|
let local_datetime = datetime.to_offset(local_offset);
|
|
let format =
|
|
time::format_description::parse("[month repr:short] [day], [year]").ok();
|
|
format
|
|
.and_then(|f| local_datetime.format(&f).ok())
|
|
.unwrap_or_default()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
|
|
|
|
let avatar = {
|
|
let author_email_for_avatar = if author_email.is_empty() {
|
|
None
|
|
} else {
|
|
Some(author_email.clone())
|
|
};
|
|
|
|
CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
|
|
.size(px(40.))
|
|
.render(window, cx)
|
|
};
|
|
|
|
let changed_files_count = self
|
|
.selected_commit_diff
|
|
.as_ref()
|
|
.map(|diff| diff.files.len())
|
|
.unwrap_or(0);
|
|
|
|
let (total_lines_added, total_lines_removed) =
|
|
self.selected_commit_diff_stats.unwrap_or((0, 0));
|
|
|
|
let sorted_file_entries: Rc<Vec<ChangedFileEntry>> = Rc::new(
|
|
self.selected_commit_diff
|
|
.as_ref()
|
|
.map(|diff| {
|
|
let mut files: Vec<_> = diff.files.iter().collect();
|
|
files.sort_by_key(|file| file.status());
|
|
files
|
|
.into_iter()
|
|
.map(|file| ChangedFileEntry::from_commit_file(file, cx))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default(),
|
|
);
|
|
|
|
v_flex()
|
|
.min_w(px(300.))
|
|
.h_full()
|
|
.bg(cx.theme().colors().surface_background)
|
|
.flex_basis(DefiniteLength::Fraction(
|
|
self.commit_details_split_state.read(cx).right_ratio(),
|
|
))
|
|
.child(
|
|
v_flex()
|
|
.relative()
|
|
.w_full()
|
|
.p_2()
|
|
.gap_2()
|
|
.child(
|
|
div().absolute().top_2().right_2().child(
|
|
IconButton::new("close-detail", IconName::Close)
|
|
.icon_size(IconSize::Small)
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
this.selected_entry_idx = None;
|
|
this.selected_commit_diff = None;
|
|
this.selected_commit_diff_stats = None;
|
|
this._commit_diff_task = None;
|
|
cx.notify();
|
|
})),
|
|
),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.py_1()
|
|
.w_full()
|
|
.items_center()
|
|
.gap_1()
|
|
.child(avatar)
|
|
.child(
|
|
v_flex()
|
|
.items_center()
|
|
.child(Label::new(author_name))
|
|
.child(
|
|
Label::new(date_string)
|
|
.color(Color::Muted)
|
|
.size(LabelSize::Small),
|
|
),
|
|
),
|
|
)
|
|
.children((!ref_names.is_empty()).then(|| {
|
|
h_flex().gap_1().flex_wrap().justify_center().children(
|
|
ref_names
|
|
.iter()
|
|
.map(|name| self.render_chip(name, accent_color)),
|
|
)
|
|
}))
|
|
.child(
|
|
v_flex()
|
|
.ml_neg_1()
|
|
.gap_1p5()
|
|
.when(!author_email.is_empty(), |this| {
|
|
let copied_state: Entity<CopiedState> = window.use_keyed_state(
|
|
"author-email-copy",
|
|
cx,
|
|
CopiedState::new,
|
|
);
|
|
let is_copied = copied_state.read(cx).is_copied();
|
|
|
|
let (icon, icon_color, tooltip_label) = if is_copied {
|
|
(IconName::Check, Color::Success, "Email Copied!")
|
|
} else {
|
|
(IconName::Envelope, Color::Muted, "Copy Email")
|
|
};
|
|
|
|
let copy_email = author_email.clone();
|
|
let author_email_for_tooltip = author_email.clone();
|
|
|
|
this.child(
|
|
Button::new("author-email-copy", author_email.clone())
|
|
.start_icon(
|
|
Icon::new(icon).size(IconSize::Small).color(icon_color),
|
|
)
|
|
.label_size(LabelSize::Small)
|
|
.truncate(true)
|
|
.color(Color::Muted)
|
|
.tooltip(move |_, cx| {
|
|
Tooltip::with_meta(
|
|
tooltip_label,
|
|
None,
|
|
author_email_for_tooltip.clone(),
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(move |_, _, cx| {
|
|
copied_state.update(cx, |state, _cx| {
|
|
state.mark_copied();
|
|
});
|
|
cx.write_to_clipboard(ClipboardItem::new_string(
|
|
copy_email.to_string(),
|
|
));
|
|
let state_id = copied_state.entity_id();
|
|
cx.spawn(async move |cx| {
|
|
cx.background_executor()
|
|
.timer(COPIED_STATE_DURATION)
|
|
.await;
|
|
cx.update(|cx| {
|
|
cx.notify(state_id);
|
|
})
|
|
})
|
|
.detach();
|
|
}),
|
|
)
|
|
})
|
|
.child({
|
|
let copy_sha = full_sha.clone();
|
|
let copied_state: Entity<CopiedState> =
|
|
window.use_keyed_state("sha-copy", cx, CopiedState::new);
|
|
let is_copied = copied_state.read(cx).is_copied();
|
|
|
|
let (icon, icon_color, tooltip_label) = if is_copied {
|
|
(IconName::Check, Color::Success, "Commit SHA Copied!")
|
|
} else {
|
|
(IconName::Hash, Color::Muted, "Copy Commit SHA")
|
|
};
|
|
|
|
Button::new("sha-button", &full_sha)
|
|
.start_icon(
|
|
Icon::new(icon).size(IconSize::Small).color(icon_color),
|
|
)
|
|
.label_size(LabelSize::Small)
|
|
.truncate(true)
|
|
.color(Color::Muted)
|
|
.tooltip({
|
|
let full_sha = full_sha.clone();
|
|
move |_, cx| {
|
|
Tooltip::with_meta(
|
|
tooltip_label,
|
|
None,
|
|
full_sha.clone(),
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click(move |_, _, cx| {
|
|
copied_state.update(cx, |state, _cx| {
|
|
state.mark_copied();
|
|
});
|
|
cx.write_to_clipboard(ClipboardItem::new_string(
|
|
copy_sha.to_string(),
|
|
));
|
|
let state_id = copied_state.entity_id();
|
|
cx.spawn(async move |cx| {
|
|
cx.background_executor()
|
|
.timer(COPIED_STATE_DURATION)
|
|
.await;
|
|
cx.update(|cx| {
|
|
cx.notify(state_id);
|
|
})
|
|
})
|
|
.detach();
|
|
})
|
|
})
|
|
.when_some(remote.clone(), |this, remote| {
|
|
let provider_name = remote.host.name();
|
|
let icon = match provider_name.as_str() {
|
|
"GitHub" => IconName::Github,
|
|
_ => IconName::Link,
|
|
};
|
|
let parsed_remote = ParsedGitRemote {
|
|
owner: remote.owner.as_ref().into(),
|
|
repo: remote.repo.as_ref().into(),
|
|
};
|
|
let params = BuildCommitPermalinkParams {
|
|
sha: full_sha.as_ref(),
|
|
};
|
|
let url = remote
|
|
.host
|
|
.build_commit_permalink(&parsed_remote, params)
|
|
.to_string();
|
|
|
|
this.child(
|
|
Button::new(
|
|
"view-on-provider",
|
|
format!("View on {}", provider_name),
|
|
)
|
|
.start_icon(
|
|
Icon::new(icon).size(IconSize::Small).color(Color::Muted),
|
|
)
|
|
.label_size(LabelSize::Small)
|
|
.truncate(true)
|
|
.color(Color::Muted)
|
|
.on_click(
|
|
move |_, _, cx| {
|
|
cx.open_url(&url);
|
|
},
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
.child(Divider::horizontal())
|
|
.child(div().p_2().child(Label::new(commit_message)))
|
|
.child(Divider::horizontal())
|
|
.child(
|
|
v_flex()
|
|
.min_w_0()
|
|
.p_2()
|
|
.flex_1()
|
|
.gap_1()
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.child(
|
|
Label::new(format!("{} Changed Files", changed_files_count))
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.child(DiffStat::new(
|
|
"commit-diff-stat",
|
|
total_lines_added,
|
|
total_lines_removed,
|
|
)),
|
|
)
|
|
.child(
|
|
div()
|
|
.id("changed-files-container")
|
|
.flex_1()
|
|
.min_h_0()
|
|
.child({
|
|
let entries = sorted_file_entries;
|
|
let entry_count = entries.len();
|
|
let commit_sha = full_sha.clone();
|
|
let repository = repository.downgrade();
|
|
let workspace = self.workspace.clone();
|
|
uniform_list(
|
|
"changed-files-list",
|
|
entry_count,
|
|
move |range, _window, cx| {
|
|
range
|
|
.map(|ix| {
|
|
entries[ix].render(
|
|
ix,
|
|
commit_sha.clone(),
|
|
repository.clone(),
|
|
workspace.clone(),
|
|
cx,
|
|
)
|
|
})
|
|
.collect()
|
|
},
|
|
)
|
|
.size_full()
|
|
.ml_neg_1()
|
|
.track_scroll(&self.changed_files_scroll_handle)
|
|
})
|
|
.vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
|
|
),
|
|
)
|
|
.child(Divider::horizontal())
|
|
.child(
|
|
h_flex().p_1p5().w_full().child(
|
|
Button::new("view-commit", "View Commit")
|
|
.full_width()
|
|
.style(ButtonStyle::Outlined)
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_selected_commit_view(window, cx);
|
|
})),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
pub fn render_graph(&self, window: &Window, cx: &mut Context<GitGraph>) -> impl IntoElement {
|
|
let row_height = self.row_height;
|
|
let table_state = self.table_interaction_state.read(cx);
|
|
let viewport_height = table_state
|
|
.scroll_handle
|
|
.0
|
|
.borrow()
|
|
.last_item_size
|
|
.map(|size| size.item.height)
|
|
.unwrap_or(px(600.0));
|
|
let loaded_commit_count = self.graph_data.commits.len();
|
|
|
|
let content_height = row_height * loaded_commit_count;
|
|
let max_scroll = (content_height - viewport_height).max(px(0.));
|
|
let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
|
|
|
|
let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
|
|
let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
|
|
let horizontal_scroll_offset = self.horizontal_scroll_offset;
|
|
|
|
let graph_viewport_width = self.graph_viewport_width(window, cx);
|
|
let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
|
|
self.graph_canvas_content_width()
|
|
} else {
|
|
graph_viewport_width
|
|
};
|
|
let last_visible_row =
|
|
first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
|
|
|
|
let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
|
|
..(last_visible_row).min(loaded_commit_count);
|
|
let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
|
|
let commit_lines: Vec<_> = self
|
|
.graph_data
|
|
.lines
|
|
.iter()
|
|
.filter(|line| {
|
|
line.full_interval.start <= viewport_range.end
|
|
&& line.full_interval.end >= viewport_range.start
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
|
|
let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
|
|
|
|
let hovered_entry_idx = self.hovered_entry_idx;
|
|
let selected_entry_idx = self.selected_entry_idx;
|
|
let is_focused = self.focus_handle.is_focused(window);
|
|
let graph_canvas_bounds = self.graph_canvas_bounds.clone();
|
|
|
|
gpui::canvas(
|
|
move |_bounds, _window, _cx| {},
|
|
move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
|
|
graph_canvas_bounds.set(Some(bounds));
|
|
|
|
window.paint_layer(bounds, |window| {
|
|
let accent_colors = cx.theme().accents();
|
|
|
|
let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
|
|
let selected_bg = if is_focused {
|
|
cx.theme().colors().element_selected
|
|
} else {
|
|
cx.theme().colors().element_hover
|
|
};
|
|
|
|
for visible_row_idx in 0..rows.len() {
|
|
let absolute_row_idx = first_visible_row + visible_row_idx;
|
|
let is_hovered = hovered_entry_idx == Some(absolute_row_idx);
|
|
let is_selected = selected_entry_idx == Some(absolute_row_idx);
|
|
|
|
if is_hovered || is_selected {
|
|
let row_y = bounds.origin.y + visible_row_idx as f32 * row_height
|
|
- vertical_scroll_offset;
|
|
|
|
let row_bounds = Bounds::new(
|
|
point(bounds.origin.x, row_y),
|
|
gpui::Size {
|
|
width: bounds.size.width,
|
|
height: row_height,
|
|
},
|
|
);
|
|
|
|
let bg_color = if is_selected { selected_bg } else { hover_bg };
|
|
window.paint_quad(gpui::fill(row_bounds, bg_color));
|
|
}
|
|
}
|
|
|
|
for (row_idx, row) in rows.into_iter().enumerate() {
|
|
let row_color = accent_colors.color_for_index(row.color_idx as u32);
|
|
let row_y_center =
|
|
bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
|
|
- vertical_scroll_offset;
|
|
|
|
let commit_x =
|
|
lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
|
|
|
|
draw_commit_circle(commit_x, row_y_center, row_color, window);
|
|
}
|
|
|
|
for line in commit_lines {
|
|
let Some((start_segment_idx, start_column)) =
|
|
line.get_first_visible_segment_idx(first_visible_row)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let line_x =
|
|
lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
|
|
|
|
let start_row = line.full_interval.start as i32 - first_visible_row as i32;
|
|
|
|
let from_y =
|
|
bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
|
|
- vertical_scroll_offset
|
|
+ COMMIT_CIRCLE_RADIUS;
|
|
|
|
let mut current_row = from_y;
|
|
let mut current_column = line_x;
|
|
|
|
let mut builder = PathBuilder::stroke(LINE_WIDTH);
|
|
builder.move_to(point(line_x, from_y));
|
|
|
|
let segments = &line.segments[start_segment_idx..];
|
|
let desired_curve_height = row_height / 3.0;
|
|
let desired_curve_width = LANE_WIDTH / 3.0;
|
|
|
|
for (segment_idx, segment) in segments.iter().enumerate() {
|
|
let is_last = segment_idx + 1 == segments.len();
|
|
|
|
match segment {
|
|
CommitLineSegment::Straight { to_row } => {
|
|
let mut dest_row = to_row_center(
|
|
to_row - first_visible_row,
|
|
row_height,
|
|
vertical_scroll_offset,
|
|
bounds,
|
|
);
|
|
if is_last {
|
|
dest_row -= COMMIT_CIRCLE_RADIUS;
|
|
}
|
|
|
|
let dest_point = point(current_column, dest_row);
|
|
|
|
current_row = dest_point.y;
|
|
builder.line_to(dest_point);
|
|
builder.move_to(dest_point);
|
|
}
|
|
CommitLineSegment::Curve {
|
|
to_column,
|
|
on_row,
|
|
curve_kind,
|
|
} => {
|
|
let mut to_column = lane_center_x(
|
|
bounds,
|
|
*to_column as f32,
|
|
horizontal_scroll_offset,
|
|
);
|
|
|
|
let mut to_row = to_row_center(
|
|
*on_row - first_visible_row,
|
|
row_height,
|
|
vertical_scroll_offset,
|
|
bounds,
|
|
);
|
|
|
|
// This means that this branch was a checkout
|
|
let going_right = to_column > current_column;
|
|
let column_shift = if going_right {
|
|
COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
|
|
} else {
|
|
-COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
|
|
};
|
|
|
|
match curve_kind {
|
|
CurveKind::Checkout => {
|
|
if is_last {
|
|
to_column -= column_shift;
|
|
}
|
|
|
|
let available_curve_width =
|
|
(to_column - current_column).abs();
|
|
let available_curve_height =
|
|
(to_row - current_row).abs();
|
|
let curve_width =
|
|
desired_curve_width.min(available_curve_width);
|
|
let curve_height =
|
|
desired_curve_height.min(available_curve_height);
|
|
let signed_curve_width = if going_right {
|
|
curve_width
|
|
} else {
|
|
-curve_width
|
|
};
|
|
let curve_start =
|
|
point(current_column, to_row - curve_height);
|
|
let curve_end =
|
|
point(current_column + signed_curve_width, to_row);
|
|
let curve_control = point(current_column, to_row);
|
|
|
|
builder.move_to(point(current_column, current_row));
|
|
builder.line_to(curve_start);
|
|
builder.move_to(curve_start);
|
|
builder.curve_to(curve_end, curve_control);
|
|
builder.move_to(curve_end);
|
|
builder.line_to(point(to_column, to_row));
|
|
}
|
|
CurveKind::Merge => {
|
|
if is_last {
|
|
to_row -= COMMIT_CIRCLE_RADIUS;
|
|
}
|
|
|
|
let merge_start = point(
|
|
current_column + column_shift,
|
|
current_row - COMMIT_CIRCLE_RADIUS,
|
|
);
|
|
let available_curve_width =
|
|
(to_column - merge_start.x).abs();
|
|
let available_curve_height =
|
|
(to_row - merge_start.y).abs();
|
|
let curve_width =
|
|
desired_curve_width.min(available_curve_width);
|
|
let curve_height =
|
|
desired_curve_height.min(available_curve_height);
|
|
let signed_curve_width = if going_right {
|
|
curve_width
|
|
} else {
|
|
-curve_width
|
|
};
|
|
let curve_start = point(
|
|
to_column - signed_curve_width,
|
|
merge_start.y,
|
|
);
|
|
let curve_end =
|
|
point(to_column, merge_start.y + curve_height);
|
|
let curve_control = point(to_column, merge_start.y);
|
|
|
|
builder.move_to(merge_start);
|
|
builder.line_to(curve_start);
|
|
builder.move_to(curve_start);
|
|
builder.curve_to(curve_end, curve_control);
|
|
builder.move_to(curve_end);
|
|
builder.line_to(point(to_column, to_row));
|
|
}
|
|
}
|
|
current_row = to_row;
|
|
current_column = to_column;
|
|
builder.move_to(point(current_column, current_row));
|
|
}
|
|
}
|
|
}
|
|
|
|
builder.close();
|
|
lines.entry(line.color_idx).or_default().push(builder);
|
|
}
|
|
|
|
for (color_idx, builders) in lines {
|
|
let line_color = accent_colors.color_for_index(color_idx as u32);
|
|
|
|
for builder in builders {
|
|
if let Ok(path) = builder.build() {
|
|
// we paint each color on it's own layer to stop overlapping lines
|
|
// of different colors changing the color of a line
|
|
window.paint_layer(bounds, |window| {
|
|
window.paint_path(path, line_color);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
})
|
|
},
|
|
)
|
|
.w(graph_width)
|
|
.h_full()
|
|
}
|
|
|
|
fn row_at_position(&self, position_y: Pixels, cx: &Context<Self>) -> Option<usize> {
|
|
let canvas_bounds = self.graph_canvas_bounds.get()?;
|
|
let table_state = self.table_interaction_state.read(cx);
|
|
let scroll_offset_y = -table_state.scroll_offset().y;
|
|
|
|
let local_y = position_y - canvas_bounds.origin.y;
|
|
|
|
if local_y >= px(0.) && local_y < canvas_bounds.size.height {
|
|
let row_in_viewport = (local_y / self.row_height).floor() as usize;
|
|
let scroll_rows = (scroll_offset_y / self.row_height).floor() as usize;
|
|
let absolute_row = scroll_rows + row_in_viewport;
|
|
|
|
if absolute_row < self.graph_data.commits.len() {
|
|
return Some(absolute_row);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn handle_graph_mouse_move(
|
|
&mut self,
|
|
event: &gpui::MouseMoveEvent,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(row) = self.row_at_position(event.position.y, cx) {
|
|
if self.hovered_entry_idx != Some(row) {
|
|
self.hovered_entry_idx = Some(row);
|
|
cx.notify();
|
|
}
|
|
} else if self.hovered_entry_idx.is_some() {
|
|
self.hovered_entry_idx = None;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn handle_graph_click(
|
|
&mut self,
|
|
event: &ClickEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(row) = self.row_at_position(event.position().y, cx) {
|
|
self.select_entry(row, ScrollStrategy::Nearest, cx);
|
|
if event.click_count() >= 2 {
|
|
self.open_commit_view(row, window, cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_graph_scroll(
|
|
&mut self,
|
|
event: &ScrollWheelEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let line_height = window.line_height();
|
|
let delta = event.delta.pixel_delta(line_height);
|
|
|
|
let table_state = self.table_interaction_state.read(cx);
|
|
let current_offset = table_state.scroll_offset();
|
|
|
|
let viewport_height = table_state.scroll_handle.viewport().size.height;
|
|
|
|
let commit_count = match self.graph_data.max_commit_count {
|
|
AllCommitCount::Loaded(count) => count,
|
|
AllCommitCount::NotLoaded => self.graph_data.commits.len(),
|
|
};
|
|
let content_height = self.row_height * commit_count;
|
|
let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
|
|
|
|
let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
|
|
let new_offset = Point::new(current_offset.x, new_y);
|
|
|
|
let graph_viewport_width = self.graph_viewport_width(window, cx);
|
|
let max_horizontal_scroll =
|
|
(self.graph_canvas_content_width() - graph_viewport_width).max(px(0.));
|
|
|
|
let new_horizontal_offset =
|
|
(self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
|
|
|
|
let vertical_changed = new_offset != current_offset;
|
|
let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
|
|
|
|
if vertical_changed {
|
|
table_state.set_scroll_offset(new_offset);
|
|
}
|
|
|
|
if horizontal_changed {
|
|
self.horizontal_scroll_offset = new_horizontal_offset;
|
|
}
|
|
|
|
if vertical_changed || horizontal_changed {
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn render_commit_view_resize_handle(
|
|
&self,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> AnyElement {
|
|
div()
|
|
.id("commit-view-split-resize-container")
|
|
.relative()
|
|
.h_full()
|
|
.flex_shrink_0()
|
|
.w(px(1.))
|
|
.bg(cx.theme().colors().border_variant)
|
|
.child(
|
|
div()
|
|
.id("commit-view-split-resize-handle")
|
|
.absolute()
|
|
.left(px(-RESIZE_HANDLE_WIDTH / 2.0))
|
|
.w(px(RESIZE_HANDLE_WIDTH))
|
|
.h_full()
|
|
.cursor_col_resize()
|
|
.block_mouse_except_scroll()
|
|
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
|
|
if event.click_count() >= 2 {
|
|
this.commit_details_split_state.update(cx, |state, _| {
|
|
state.on_double_click();
|
|
});
|
|
}
|
|
cx.stop_propagation();
|
|
}))
|
|
.on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
impl Render for GitGraph {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
// This happens when we changed branches, we should refresh our search as well
|
|
if let QueryState::Pending(query) = &mut self.search_state.state {
|
|
let query = std::mem::take(query);
|
|
self.search_state.state = QueryState::Empty;
|
|
self.search(query, cx);
|
|
}
|
|
let (commit_count, is_loading) = match self.graph_data.max_commit_count {
|
|
AllCommitCount::Loaded(count) => (count, true),
|
|
AllCommitCount::NotLoaded => {
|
|
let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) {
|
|
repository.update(cx, |repository, cx| {
|
|
// Start loading the graph data if we haven't started already
|
|
let GraphDataResponse {
|
|
commits,
|
|
is_loading,
|
|
error: _,
|
|
} = repository.graph_data(
|
|
self.log_source.clone(),
|
|
self.log_order,
|
|
0..usize::MAX,
|
|
cx,
|
|
);
|
|
self.graph_data.add_commits(&commits);
|
|
let graph_viewport_width = self.graph_viewport_width(window, cx);
|
|
self.clamp_horizontal_scroll_offset(graph_viewport_width);
|
|
(commits.len(), is_loading)
|
|
})
|
|
} else {
|
|
(0, false)
|
|
};
|
|
|
|
(commit_count, is_loading)
|
|
}
|
|
};
|
|
|
|
let content = if commit_count == 0 {
|
|
let message = if is_loading {
|
|
"Loading"
|
|
} else {
|
|
"No commits found"
|
|
};
|
|
let label = Label::new(message)
|
|
.color(Color::Muted)
|
|
.size(LabelSize::Large);
|
|
div()
|
|
.size_full()
|
|
.h_flex()
|
|
.gap_1()
|
|
.items_center()
|
|
.justify_center()
|
|
.child(label)
|
|
.when(is_loading, |this| {
|
|
this.child(self.render_loading_spinner(cx))
|
|
})
|
|
} else {
|
|
let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx);
|
|
let header_context = TableRenderContext::for_column_widths(
|
|
Some(self.column_widths.read(cx).widths_to_render()),
|
|
true,
|
|
);
|
|
let [
|
|
graph_fraction,
|
|
description_fraction,
|
|
date_fraction,
|
|
author_fraction,
|
|
commit_fraction,
|
|
] = self.preview_column_fractions(window, cx);
|
|
let table_fraction =
|
|
description_fraction + date_fraction + author_fraction + commit_fraction;
|
|
let table_width_config = self.table_column_width_config(window, cx);
|
|
let graph_viewport_width = self.graph_viewport_width(window, cx);
|
|
self.clamp_horizontal_scroll_offset(graph_viewport_width);
|
|
|
|
h_flex()
|
|
.size_full()
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.size_full()
|
|
.flex()
|
|
.flex_col()
|
|
.child(render_table_header(
|
|
TableRow::from_vec(
|
|
vec![
|
|
Label::new("Graph")
|
|
.color(Color::Muted)
|
|
.truncate()
|
|
.into_any_element(),
|
|
Label::new("Description")
|
|
.color(Color::Muted)
|
|
.into_any_element(),
|
|
Label::new("Date").color(Color::Muted).into_any_element(),
|
|
Label::new("Author").color(Color::Muted).into_any_element(),
|
|
Label::new("Commit").color(Color::Muted).into_any_element(),
|
|
],
|
|
5,
|
|
),
|
|
header_context,
|
|
Some(header_resize_info),
|
|
Some(self.column_widths.entity_id()),
|
|
cx,
|
|
))
|
|
.child({
|
|
let row_height = self.row_height;
|
|
let selected_entry_idx = self.selected_entry_idx;
|
|
let hovered_entry_idx = self.hovered_entry_idx;
|
|
let weak_self = cx.weak_entity();
|
|
let focus_handle = self.focus_handle.clone();
|
|
|
|
bind_redistributable_columns(
|
|
div()
|
|
.relative()
|
|
.flex_1()
|
|
.w_full()
|
|
.overflow_hidden()
|
|
.child(
|
|
h_flex()
|
|
.size_full()
|
|
.child(
|
|
div()
|
|
.w(DefiniteLength::Fraction(graph_fraction))
|
|
.h_full()
|
|
.min_w_0()
|
|
.overflow_hidden()
|
|
.child(
|
|
div()
|
|
.id("graph-canvas")
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.child(
|
|
div()
|
|
.size_full()
|
|
.child(self.render_graph(window, cx)),
|
|
)
|
|
.on_scroll_wheel(
|
|
cx.listener(Self::handle_graph_scroll),
|
|
)
|
|
.on_mouse_move(
|
|
cx.listener(Self::handle_graph_mouse_move),
|
|
)
|
|
.on_click(cx.listener(Self::handle_graph_click))
|
|
.on_hover(cx.listener(
|
|
|this, &is_hovered: &bool, _, cx| {
|
|
if !is_hovered
|
|
&& this.hovered_entry_idx.is_some()
|
|
{
|
|
this.hovered_entry_idx = None;
|
|
cx.notify();
|
|
}
|
|
},
|
|
)),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.w(DefiniteLength::Fraction(table_fraction))
|
|
.h_full()
|
|
.min_w_0()
|
|
.child(
|
|
Table::new(4)
|
|
.interactable(&self.table_interaction_state)
|
|
.hide_row_borders()
|
|
.hide_row_hover()
|
|
.width_config(table_width_config)
|
|
.map_row(move |(index, row), window, cx| {
|
|
let is_selected =
|
|
selected_entry_idx == Some(index);
|
|
let is_hovered =
|
|
hovered_entry_idx == Some(index);
|
|
let is_focused =
|
|
focus_handle.is_focused(window);
|
|
let weak = weak_self.clone();
|
|
let weak_for_hover = weak.clone();
|
|
|
|
let hover_bg = cx
|
|
.theme()
|
|
.colors()
|
|
.element_hover
|
|
.opacity(0.6);
|
|
let selected_bg = if is_focused {
|
|
cx.theme().colors().element_selected
|
|
} else {
|
|
cx.theme().colors().element_hover
|
|
};
|
|
|
|
row.h(row_height)
|
|
.when(is_selected, |row| row.bg(selected_bg))
|
|
.when(
|
|
is_hovered && !is_selected,
|
|
|row| row.bg(hover_bg),
|
|
)
|
|
.on_hover(move |&is_hovered, _, cx| {
|
|
weak_for_hover
|
|
.update(cx, |this, cx| {
|
|
if is_hovered {
|
|
if this.hovered_entry_idx
|
|
!= Some(index)
|
|
{
|
|
this.hovered_entry_idx =
|
|
Some(index);
|
|
cx.notify();
|
|
}
|
|
} else if this
|
|
.hovered_entry_idx
|
|
== Some(index)
|
|
{
|
|
this.hovered_entry_idx =
|
|
None;
|
|
cx.notify();
|
|
}
|
|
})
|
|
.ok();
|
|
})
|
|
.on_click(move |event, window, cx| {
|
|
let click_count = event.click_count();
|
|
weak.update(cx, |this, cx| {
|
|
this.select_entry(
|
|
index,
|
|
ScrollStrategy::Center,
|
|
cx,
|
|
);
|
|
if click_count >= 2 {
|
|
this.open_commit_view(
|
|
index,
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
})
|
|
.ok();
|
|
})
|
|
.into_any_element()
|
|
})
|
|
.uniform_list(
|
|
"git-graph-commits",
|
|
commit_count,
|
|
cx.processor(Self::render_table_rows),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.child(render_redistributable_columns_resize_handles(
|
|
&self.column_widths,
|
|
window,
|
|
cx,
|
|
)),
|
|
self.column_widths.clone(),
|
|
)
|
|
}),
|
|
)
|
|
.on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
|
|
this.commit_details_split_state.update(cx, |state, cx| {
|
|
state.on_drag_move(event, window, cx);
|
|
});
|
|
}))
|
|
.on_drop::<DraggedSplitHandle>(cx.listener(|this, _event, _window, cx| {
|
|
this.commit_details_split_state.update(cx, |state, _cx| {
|
|
state.commit_ratio();
|
|
});
|
|
}))
|
|
.when(self.selected_entry_idx.is_some(), |this| {
|
|
this.child(self.render_commit_view_resize_handle(window, cx))
|
|
.child(self.render_commit_detail_panel(window, cx))
|
|
})
|
|
};
|
|
|
|
div()
|
|
.key_context("GitGraph")
|
|
.track_focus(&self.focus_handle)
|
|
.size_full()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.on_action(cx.listener(|this, _: &OpenCommitView, window, cx| {
|
|
this.open_selected_commit_view(window, cx);
|
|
}))
|
|
.on_action(cx.listener(Self::cancel))
|
|
.on_action(cx.listener(Self::select_first))
|
|
.on_action(cx.listener(Self::select_prev))
|
|
.on_action(cx.listener(Self::select_next))
|
|
.on_action(cx.listener(Self::select_last))
|
|
.on_action(cx.listener(Self::confirm))
|
|
.on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| {
|
|
this.select_next_match(cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| {
|
|
this.select_previous_match(cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| {
|
|
this.search_state.case_sensitive = !this.search_state.case_sensitive;
|
|
this.search_state.state.next_state();
|
|
cx.notify();
|
|
}))
|
|
.child(
|
|
v_flex()
|
|
.size_full()
|
|
.child(self.render_search_bar(cx))
|
|
.child(div().flex_1().child(content)),
|
|
)
|
|
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
|
|
deferred(
|
|
anchored()
|
|
.position(*position)
|
|
.anchor(Corner::TopLeft)
|
|
.child(menu.clone()),
|
|
)
|
|
.with_priority(1)
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<ItemEvent> for GitGraph {}
|
|
|
|
impl Focusable for GitGraph {
|
|
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl Item for GitGraph {
|
|
type Event = ItemEvent;
|
|
|
|
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
|
|
Some(Icon::new(IconName::GitGraph))
|
|
}
|
|
|
|
fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
|
|
let repo_name = self.get_repository(cx).and_then(|repo| {
|
|
repo.read(cx)
|
|
.work_directory_abs_path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
});
|
|
|
|
Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
|
|
move |_, _| {
|
|
v_flex()
|
|
.child(Label::new("Git Graph"))
|
|
.when_some(repo_name.clone(), |this, name| {
|
|
this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small))
|
|
})
|
|
.into_any_element()
|
|
}
|
|
}))))
|
|
}
|
|
|
|
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
|
|
self.get_repository(cx)
|
|
.and_then(|repo| {
|
|
repo.read(cx)
|
|
.work_directory_abs_path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
})
|
|
.map_or_else(|| "Git Graph".into(), |name| SharedString::from(name))
|
|
}
|
|
|
|
fn show_toolbar(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
|
|
f(*event)
|
|
}
|
|
}
|
|
|
|
impl workspace::SerializableItem for GitGraph {
|
|
fn serialized_item_kind() -> &'static str {
|
|
"GitGraph"
|
|
}
|
|
|
|
fn cleanup(
|
|
workspace_id: workspace::WorkspaceId,
|
|
alive_items: Vec<workspace::ItemId>,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Task<gpui::Result<()>> {
|
|
workspace::delete_unloaded_items(
|
|
alive_items,
|
|
workspace_id,
|
|
"git_graphs",
|
|
&persistence::GitGraphsDb::global(cx),
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn deserialize(
|
|
project: Entity<project::Project>,
|
|
workspace: WeakEntity<Workspace>,
|
|
workspace_id: workspace::WorkspaceId,
|
|
item_id: workspace::ItemId,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Task<gpui::Result<Entity<Self>>> {
|
|
let db = persistence::GitGraphsDb::global(cx);
|
|
let Some(repo_work_path) = db.get_git_graph(item_id, workspace_id).ok().flatten() else {
|
|
return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")));
|
|
};
|
|
|
|
let window_handle = window.window_handle();
|
|
let project = project.read(cx);
|
|
let git_store = project.git_store().clone();
|
|
let wait = project.wait_for_initial_scan(cx);
|
|
|
|
cx.spawn(async move |cx| {
|
|
wait.await;
|
|
|
|
cx.update_window(window_handle, |_, window, cx| {
|
|
let path = repo_work_path.as_path();
|
|
|
|
let repositories = git_store.read(cx).repositories();
|
|
let repo_id = repositories.iter().find_map(|(&repo_id, repo)| {
|
|
if repo.read(cx).snapshot().work_directory_abs_path.as_ref() == path {
|
|
Some(repo_id)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let Some(repo_id) = repo_id else {
|
|
return Err(anyhow::anyhow!("Repository not found for path: {:?}", path));
|
|
};
|
|
|
|
Ok(cx.new(|cx| GitGraph::new(repo_id, git_store, workspace, window, cx)))
|
|
})?
|
|
})
|
|
}
|
|
|
|
fn serialize(
|
|
&mut self,
|
|
workspace: &mut Workspace,
|
|
item_id: workspace::ItemId,
|
|
_closing: bool,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<gpui::Result<()>>> {
|
|
let workspace_id = workspace.database_id()?;
|
|
let repo = self.get_repository(cx)?;
|
|
let repo_working_path = repo
|
|
.read(cx)
|
|
.snapshot()
|
|
.work_directory_abs_path
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
let db = persistence::GitGraphsDb::global(cx);
|
|
Some(cx.background_spawn(async move {
|
|
db.save_git_graph(item_id, workspace_id, repo_working_path)
|
|
.await
|
|
}))
|
|
}
|
|
|
|
fn should_serialize(&self, event: &Self::Event) -> bool {
|
|
event == &ItemEvent::UpdateTab
|
|
}
|
|
}
|
|
|
|
mod persistence {
|
|
use std::path::PathBuf;
|
|
|
|
use db::{
|
|
query,
|
|
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
|
|
sqlez_macros::sql,
|
|
};
|
|
use workspace::WorkspaceDb;
|
|
|
|
pub struct GitGraphsDb(ThreadSafeConnection);
|
|
|
|
impl Domain for GitGraphsDb {
|
|
const NAME: &str = stringify!(GitGraphsDb);
|
|
|
|
const MIGRATIONS: &[&str] = &[
|
|
sql!(
|
|
CREATE TABLE git_graphs (
|
|
workspace_id INTEGER,
|
|
item_id INTEGER UNIQUE,
|
|
is_open INTEGER DEFAULT FALSE,
|
|
|
|
PRIMARY KEY(workspace_id, item_id),
|
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
|
|
ON DELETE CASCADE
|
|
) STRICT;
|
|
),
|
|
sql!(
|
|
ALTER TABLE git_graphs ADD COLUMN repo_working_path TEXT;
|
|
),
|
|
];
|
|
}
|
|
|
|
db::static_connection!(GitGraphsDb, [WorkspaceDb]);
|
|
|
|
impl GitGraphsDb {
|
|
query! {
|
|
pub async fn save_git_graph(
|
|
item_id: workspace::ItemId,
|
|
workspace_id: workspace::WorkspaceId,
|
|
repo_working_path: String
|
|
) -> Result<()> {
|
|
INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, repo_working_path)
|
|
VALUES (?, ?, ?)
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub fn get_git_graph(
|
|
item_id: workspace::ItemId,
|
|
workspace_id: workspace::WorkspaceId
|
|
) -> Result<Option<PathBuf>> {
|
|
SELECT repo_working_path
|
|
FROM git_graphs
|
|
WHERE item_id = ? AND workspace_id = ?
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use anyhow::{Context, Result, bail};
|
|
use collections::{HashMap, HashSet};
|
|
use fs::FakeFs;
|
|
use git::Oid;
|
|
use git::repository::InitialGraphCommitData;
|
|
use gpui::TestAppContext;
|
|
use project::Project;
|
|
use project::git_store::{GitStoreEvent, RepositoryEvent};
|
|
use rand::prelude::*;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use smallvec::{SmallVec, smallvec};
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
|
});
|
|
}
|
|
|
|
/// Generates a random commit DAG suitable for testing git graph rendering.
|
|
///
|
|
/// The commits are ordered newest-first (like git log output), so:
|
|
/// - Index 0 = most recent commit (HEAD)
|
|
/// - Last index = oldest commit (root, has no parents)
|
|
/// - Parents of commit at index I must have index > I
|
|
///
|
|
/// When `adversarial` is true, generates complex topologies with many branches
|
|
/// and octopus merges. Otherwise generates more realistic linear histories
|
|
/// with occasional branches.
|
|
fn generate_random_commit_dag(
|
|
rng: &mut StdRng,
|
|
num_commits: usize,
|
|
adversarial: bool,
|
|
) -> Vec<Arc<InitialGraphCommitData>> {
|
|
if num_commits == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
|
|
let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
|
|
|
|
for i in 0..num_commits {
|
|
let sha = oids[i];
|
|
|
|
let parents = if i == num_commits - 1 {
|
|
smallvec![]
|
|
} else {
|
|
generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
|
|
};
|
|
|
|
let ref_names = if i == 0 {
|
|
vec!["HEAD".into(), "main".into()]
|
|
} else if adversarial && rng.random_bool(0.1) {
|
|
vec![format!("branch-{}", i).into()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
commits.push(Arc::new(InitialGraphCommitData {
|
|
sha,
|
|
parents,
|
|
ref_names,
|
|
}));
|
|
}
|
|
|
|
commits
|
|
}
|
|
|
|
fn generate_parents_from_oids(
|
|
rng: &mut StdRng,
|
|
oids: &[Oid],
|
|
current_idx: usize,
|
|
num_commits: usize,
|
|
adversarial: bool,
|
|
) -> SmallVec<[Oid; 1]> {
|
|
let remaining = num_commits - current_idx - 1;
|
|
if remaining == 0 {
|
|
return smallvec![];
|
|
}
|
|
|
|
if adversarial {
|
|
let merge_chance = 0.4;
|
|
let octopus_chance = 0.15;
|
|
|
|
if remaining >= 3 && rng.random_bool(octopus_chance) {
|
|
let num_parents = rng.random_range(3..=remaining.min(5));
|
|
let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
|
|
parent_indices.shuffle(rng);
|
|
parent_indices
|
|
.into_iter()
|
|
.take(num_parents)
|
|
.map(|idx| oids[idx])
|
|
.collect()
|
|
} else if remaining >= 2 && rng.random_bool(merge_chance) {
|
|
let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
|
|
parent_indices.shuffle(rng);
|
|
parent_indices
|
|
.into_iter()
|
|
.take(2)
|
|
.map(|idx| oids[idx])
|
|
.collect()
|
|
} else {
|
|
let parent_idx = rng.random_range(current_idx + 1..num_commits);
|
|
smallvec![oids[parent_idx]]
|
|
}
|
|
} else {
|
|
let merge_chance = 0.15;
|
|
let skip_chance = 0.1;
|
|
|
|
if remaining >= 2 && rng.random_bool(merge_chance) {
|
|
let first_parent = current_idx + 1;
|
|
let second_parent = rng.random_range(current_idx + 2..num_commits);
|
|
smallvec![oids[first_parent], oids[second_parent]]
|
|
} else if rng.random_bool(skip_chance) && remaining >= 2 {
|
|
let skip = rng.random_range(1..remaining.min(3));
|
|
smallvec![oids[current_idx + 1 + skip]]
|
|
} else {
|
|
smallvec![oids[current_idx + 1]]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
|
|
graph
|
|
.commits
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, entry)| (entry.data.sha, idx))
|
|
.collect()
|
|
}
|
|
|
|
fn verify_commit_order(
|
|
graph: &GraphData,
|
|
commits: &[Arc<InitialGraphCommitData>],
|
|
) -> Result<()> {
|
|
if graph.commits.len() != commits.len() {
|
|
bail!(
|
|
"Commit count mismatch: graph has {} commits, expected {}",
|
|
graph.commits.len(),
|
|
commits.len()
|
|
);
|
|
}
|
|
|
|
for (idx, (graph_commit, expected_commit)) in
|
|
graph.commits.iter().zip(commits.iter()).enumerate()
|
|
{
|
|
if graph_commit.data.sha != expected_commit.sha {
|
|
bail!(
|
|
"Commit order mismatch at index {}: graph has {:?}, expected {:?}",
|
|
idx,
|
|
graph_commit.data.sha,
|
|
expected_commit.sha
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
|
|
for line in &graph.lines {
|
|
let child_row = *oid_to_row
|
|
.get(&line.child)
|
|
.context("Line references non-existent child commit")?;
|
|
|
|
let parent_row = *oid_to_row
|
|
.get(&line.parent)
|
|
.context("Line references non-existent parent commit")?;
|
|
|
|
if child_row >= parent_row {
|
|
bail!(
|
|
"child_row ({}) must be < parent_row ({})",
|
|
child_row,
|
|
parent_row
|
|
);
|
|
}
|
|
|
|
if line.full_interval.start != child_row {
|
|
bail!(
|
|
"full_interval.start ({}) != child_row ({})",
|
|
line.full_interval.start,
|
|
child_row
|
|
);
|
|
}
|
|
|
|
if line.full_interval.end != parent_row {
|
|
bail!(
|
|
"full_interval.end ({}) != parent_row ({})",
|
|
line.full_interval.end,
|
|
parent_row
|
|
);
|
|
}
|
|
|
|
if let Some(last_segment) = line.segments.last() {
|
|
let segment_end_row = match last_segment {
|
|
CommitLineSegment::Straight { to_row } => *to_row,
|
|
CommitLineSegment::Curve { on_row, .. } => *on_row,
|
|
};
|
|
|
|
if segment_end_row != line.full_interval.end {
|
|
bail!(
|
|
"last segment ends at row {} but full_interval.end is {}",
|
|
segment_end_row,
|
|
line.full_interval.end
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_column_correctness(
|
|
graph: &GraphData,
|
|
oid_to_row: &HashMap<Oid, usize>,
|
|
) -> Result<()> {
|
|
for line in &graph.lines {
|
|
let child_row = *oid_to_row
|
|
.get(&line.child)
|
|
.context("Line references non-existent child commit")?;
|
|
|
|
let parent_row = *oid_to_row
|
|
.get(&line.parent)
|
|
.context("Line references non-existent parent commit")?;
|
|
|
|
let child_lane = graph.commits[child_row].lane;
|
|
if line.child_column != child_lane {
|
|
bail!(
|
|
"child_column ({}) != child's lane ({})",
|
|
line.child_column,
|
|
child_lane
|
|
);
|
|
}
|
|
|
|
let mut current_column = line.child_column;
|
|
for segment in &line.segments {
|
|
if let CommitLineSegment::Curve { to_column, .. } = segment {
|
|
current_column = *to_column;
|
|
}
|
|
}
|
|
|
|
let parent_lane = graph.commits[parent_row].lane;
|
|
if current_column != parent_lane {
|
|
bail!(
|
|
"ending column ({}) != parent's lane ({})",
|
|
current_column,
|
|
parent_lane
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
|
|
for line in &graph.lines {
|
|
if line.segments.is_empty() {
|
|
bail!("Line has no segments");
|
|
}
|
|
|
|
let mut current_row = line.full_interval.start;
|
|
|
|
for (idx, segment) in line.segments.iter().enumerate() {
|
|
let segment_end_row = match segment {
|
|
CommitLineSegment::Straight { to_row } => *to_row,
|
|
CommitLineSegment::Curve { on_row, .. } => *on_row,
|
|
};
|
|
|
|
if segment_end_row < current_row {
|
|
bail!(
|
|
"segment {} ends at row {} which is before current row {}",
|
|
idx,
|
|
segment_end_row,
|
|
current_row
|
|
);
|
|
}
|
|
|
|
current_row = segment_end_row;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
|
|
for line in &graph.lines {
|
|
let child_row = line.full_interval.start;
|
|
|
|
let mut current_column = line.child_column;
|
|
let mut current_row = child_row;
|
|
|
|
for segment in &line.segments {
|
|
match segment {
|
|
CommitLineSegment::Straight { to_row } => {
|
|
for row in (current_row + 1)..*to_row {
|
|
if row < graph.commits.len() {
|
|
let commit_at_row = &graph.commits[row];
|
|
if commit_at_row.lane == current_column {
|
|
bail!(
|
|
"straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
|
|
current_row,
|
|
to_row,
|
|
current_column,
|
|
commit_at_row.data.sha,
|
|
row
|
|
);
|
|
}
|
|
}
|
|
}
|
|
current_row = *to_row;
|
|
}
|
|
CommitLineSegment::Curve {
|
|
to_column, on_row, ..
|
|
} => {
|
|
current_column = *to_column;
|
|
current_row = *on_row;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_coverage(graph: &GraphData) -> Result<()> {
|
|
let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
|
|
for entry in &graph.commits {
|
|
for parent in &entry.data.parents {
|
|
expected_edges.insert((entry.data.sha, *parent));
|
|
}
|
|
}
|
|
|
|
let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
|
|
for line in &graph.lines {
|
|
let edge = (line.child, line.parent);
|
|
|
|
if !found_edges.insert(edge) {
|
|
bail!(
|
|
"Duplicate line found for edge {:?} -> {:?}",
|
|
line.child,
|
|
line.parent
|
|
);
|
|
}
|
|
|
|
if !expected_edges.contains(&edge) {
|
|
bail!(
|
|
"Orphan line found: {:?} -> {:?} is not in the commit graph",
|
|
line.child,
|
|
line.parent
|
|
);
|
|
}
|
|
}
|
|
|
|
for (child, parent) in &expected_edges {
|
|
if !found_edges.contains(&(*child, *parent)) {
|
|
bail!("Missing line for edge {:?} -> {:?}", child, parent);
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
expected_edges.symmetric_difference(&found_edges).count(),
|
|
0,
|
|
"The symmetric difference should be zero"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_merge_line_optimality(
|
|
graph: &GraphData,
|
|
oid_to_row: &HashMap<Oid, usize>,
|
|
) -> Result<()> {
|
|
for line in &graph.lines {
|
|
let first_segment = line.segments.first();
|
|
let is_merge_line = matches!(
|
|
first_segment,
|
|
Some(CommitLineSegment::Curve {
|
|
curve_kind: CurveKind::Merge,
|
|
..
|
|
})
|
|
);
|
|
|
|
if !is_merge_line {
|
|
continue;
|
|
}
|
|
|
|
let child_row = *oid_to_row
|
|
.get(&line.child)
|
|
.context("Line references non-existent child commit")?;
|
|
|
|
let parent_row = *oid_to_row
|
|
.get(&line.parent)
|
|
.context("Line references non-existent parent commit")?;
|
|
|
|
let parent_lane = graph.commits[parent_row].lane;
|
|
|
|
let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
|
|
continue;
|
|
};
|
|
|
|
let curves_directly_to_parent = *to_column == parent_lane;
|
|
|
|
if !curves_directly_to_parent {
|
|
continue;
|
|
}
|
|
|
|
let curve_row = child_row + 1;
|
|
let has_commits_in_path = graph.commits[curve_row..parent_row]
|
|
.iter()
|
|
.any(|c| c.lane == parent_lane);
|
|
|
|
if has_commits_in_path {
|
|
bail!(
|
|
"Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
|
|
line.child,
|
|
line.parent,
|
|
parent_lane,
|
|
curve_row,
|
|
parent_row
|
|
);
|
|
}
|
|
|
|
let curve_ends_at_parent = curve_row == parent_row;
|
|
|
|
if curve_ends_at_parent {
|
|
if line.segments.len() != 1 {
|
|
bail!(
|
|
"Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
|
|
line.child,
|
|
line.parent,
|
|
line.segments.len()
|
|
);
|
|
}
|
|
} else {
|
|
if line.segments.len() != 2 {
|
|
bail!(
|
|
"Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
|
|
line.child,
|
|
line.parent,
|
|
line.segments.len()
|
|
);
|
|
}
|
|
|
|
let is_straight_segment = matches!(
|
|
line.segments.get(1),
|
|
Some(CommitLineSegment::Straight { .. })
|
|
);
|
|
|
|
if !is_straight_segment {
|
|
bail!(
|
|
"Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
|
|
line.child,
|
|
line.parent
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_all_invariants(
|
|
graph: &GraphData,
|
|
commits: &[Arc<InitialGraphCommitData>],
|
|
) -> Result<()> {
|
|
let oid_to_row = build_oid_to_row_map(graph);
|
|
|
|
verify_commit_order(graph, commits).context("commit order")?;
|
|
verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
|
|
verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
|
|
verify_segment_continuity(graph).context("segment continuity")?;
|
|
verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
|
|
verify_coverage(graph).context("coverage")?;
|
|
verify_line_overlaps(graph).context("line overlaps")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_graph_merge_commits() {
|
|
let mut rng = StdRng::seed_from_u64(42);
|
|
|
|
let oid1 = Oid::random(&mut rng);
|
|
let oid2 = Oid::random(&mut rng);
|
|
let oid3 = Oid::random(&mut rng);
|
|
let oid4 = Oid::random(&mut rng);
|
|
|
|
let commits = vec![
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid1,
|
|
parents: smallvec![oid2, oid3],
|
|
ref_names: vec!["HEAD".into()],
|
|
}),
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid2,
|
|
parents: smallvec![oid4],
|
|
ref_names: vec![],
|
|
}),
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid3,
|
|
parents: smallvec![oid4],
|
|
ref_names: vec![],
|
|
}),
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid4,
|
|
parents: smallvec![],
|
|
ref_names: vec![],
|
|
}),
|
|
];
|
|
|
|
let mut graph_data = GraphData::new(8);
|
|
graph_data.add_commits(&commits);
|
|
|
|
if let Err(error) = verify_all_invariants(&graph_data, &commits) {
|
|
panic!("Graph invariant violation for merge commits:\n{}", error);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_graph_linear_commits() {
|
|
let mut rng = StdRng::seed_from_u64(42);
|
|
|
|
let oid1 = Oid::random(&mut rng);
|
|
let oid2 = Oid::random(&mut rng);
|
|
let oid3 = Oid::random(&mut rng);
|
|
|
|
let commits = vec![
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid1,
|
|
parents: smallvec![oid2],
|
|
ref_names: vec!["HEAD".into()],
|
|
}),
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid2,
|
|
parents: smallvec![oid3],
|
|
ref_names: vec![],
|
|
}),
|
|
Arc::new(InitialGraphCommitData {
|
|
sha: oid3,
|
|
parents: smallvec![],
|
|
ref_names: vec![],
|
|
}),
|
|
];
|
|
|
|
let mut graph_data = GraphData::new(8);
|
|
graph_data.add_commits(&commits);
|
|
|
|
if let Err(error) = verify_all_invariants(&graph_data, &commits) {
|
|
panic!("Graph invariant violation for linear commits:\n{}", error);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_graph_random_commits() {
|
|
for seed in 0..100 {
|
|
let mut rng = StdRng::seed_from_u64(seed);
|
|
|
|
let adversarial = rng.random_bool(0.2);
|
|
let num_commits = if adversarial {
|
|
rng.random_range(10..100)
|
|
} else {
|
|
rng.random_range(5..50)
|
|
};
|
|
|
|
let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
|
|
|
|
assert_eq!(
|
|
num_commits,
|
|
commits.len(),
|
|
"seed={}: Generate random commit dag didn't generate the correct amount of commits",
|
|
seed
|
|
);
|
|
|
|
let mut graph_data = GraphData::new(8);
|
|
graph_data.add_commits(&commits);
|
|
|
|
if let Err(error) = verify_all_invariants(&graph_data, &commits) {
|
|
panic!(
|
|
"Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
|
|
seed, adversarial, num_commits, error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The full integration test has less iterations because it's significantly slower
|
|
// than the random commit test
|
|
#[gpui::test(iterations = 10)]
|
|
async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let adversarial = rng.random_bool(0.2);
|
|
let num_commits = if adversarial {
|
|
rng.random_range(10..100)
|
|
} else {
|
|
rng.random_range(5..50)
|
|
};
|
|
|
|
let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
Path::new("/project"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
|
|
|
|
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
|
|
cx.run_until_parked();
|
|
|
|
let repository = project.read_with(cx, |project, cx| {
|
|
project
|
|
.active_repository(cx)
|
|
.expect("should have a repository")
|
|
});
|
|
|
|
repository.update(cx, |repo, cx| {
|
|
repo.graph_data(
|
|
crate::LogSource::default(),
|
|
crate::LogOrder::default(),
|
|
0..usize::MAX,
|
|
cx,
|
|
);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
|
|
repo.graph_data(
|
|
crate::LogSource::default(),
|
|
crate::LogOrder::default(),
|
|
0..usize::MAX,
|
|
cx,
|
|
)
|
|
.commits
|
|
.to_vec()
|
|
});
|
|
|
|
let mut graph_data = GraphData::new(8);
|
|
graph_data.add_commits(&graph_commits);
|
|
|
|
if let Err(error) = verify_all_invariants(&graph_data, &commits) {
|
|
panic!(
|
|
"Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
|
|
adversarial, num_commits, error
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
Path::new("/project"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let mut rng = StdRng::seed_from_u64(42);
|
|
let commits = generate_random_commit_dag(&mut rng, 10, false);
|
|
fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
|
|
|
|
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
|
|
let observed_repository_events = Arc::new(Mutex::new(Vec::new()));
|
|
project.update(cx, |project, cx| {
|
|
let observed_repository_events = observed_repository_events.clone();
|
|
cx.subscribe(project.git_store(), move |_, _, event, _| {
|
|
if let GitStoreEvent::RepositoryUpdated(_, repository_event, true) = event {
|
|
observed_repository_events
|
|
.lock()
|
|
.expect("repository event mutex should be available")
|
|
.push(repository_event.clone());
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
let repository = project.read_with(cx, |project, cx| {
|
|
project
|
|
.active_repository(cx)
|
|
.expect("should have a repository")
|
|
});
|
|
|
|
repository.update(cx, |repo, cx| {
|
|
repo.graph_data(
|
|
crate::LogSource::default(),
|
|
crate::LogOrder::default(),
|
|
0..usize::MAX,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
project
|
|
.update(cx, |project, cx| project.git_scans_complete(cx))
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let observed_repository_events = observed_repository_events
|
|
.lock()
|
|
.expect("repository event mutex should be available");
|
|
assert!(
|
|
observed_repository_events
|
|
.iter()
|
|
.any(|event| matches!(event, RepositoryEvent::BranchChanged)),
|
|
"initial repository scan should emit BranchChanged"
|
|
);
|
|
let commit_count_after = repository.read_with(cx, |repo, _| {
|
|
repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default())
|
|
.map(|data| data.commit_data.len())
|
|
.unwrap()
|
|
});
|
|
assert_eq!(
|
|
commits.len(),
|
|
commit_count_after,
|
|
"initial_graph_data should remain populated after events emitted by initial repository scan"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_graph_data_repopulated_from_cache_after_repo_switch(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
Path::new("/project_a"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
fs.insert_tree(
|
|
Path::new("/project_b"),
|
|
json!({
|
|
".git": {},
|
|
"other.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let mut rng = StdRng::seed_from_u64(42);
|
|
let commits = generate_random_commit_dag(&mut rng, 10, false);
|
|
fs.set_graph_commits(Path::new("/project_a/.git"), commits.clone());
|
|
|
|
let project = Project::test(
|
|
fs.clone(),
|
|
[Path::new("/project_a"), Path::new("/project_b")],
|
|
cx,
|
|
)
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let (first_repository, second_repository) = project.read_with(cx, |project, cx| {
|
|
let mut first_repository = None;
|
|
let mut second_repository = None;
|
|
|
|
for repository in project.repositories(cx).values() {
|
|
let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
|
|
if work_directory_abs_path.as_ref() == Path::new("/project_a") {
|
|
first_repository = Some(repository.clone());
|
|
} else if work_directory_abs_path.as_ref() == Path::new("/project_b") {
|
|
second_repository = Some(repository.clone());
|
|
}
|
|
}
|
|
|
|
(
|
|
first_repository.expect("should have repository for /project_a"),
|
|
second_repository.expect("should have repository for /project_b"),
|
|
)
|
|
});
|
|
first_repository.update(cx, |repository, cx| repository.set_as_active_repository(cx));
|
|
cx.run_until_parked();
|
|
|
|
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
|
|
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
|
|
});
|
|
|
|
let workspace_weak =
|
|
multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
|
|
let git_graph = cx.new_window_entity(|window, cx| {
|
|
GitGraph::new(
|
|
first_repository.read(cx).id,
|
|
project.read(cx).git_store().clone(),
|
|
workspace_weak,
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// Verify initial graph data is loaded
|
|
let initial_commit_count =
|
|
git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
|
|
assert!(
|
|
initial_commit_count > 0,
|
|
"graph data should have been loaded, got 0 commits"
|
|
);
|
|
|
|
git_graph.update(cx, |graph, cx| {
|
|
graph.set_repo_id(second_repository.read(cx).id, cx)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let commit_count_after_clear =
|
|
git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
|
|
assert_eq!(
|
|
commit_count_after_clear, 0,
|
|
"graph_data should be cleared after switching away"
|
|
);
|
|
|
|
git_graph.update(cx, |graph, cx| {
|
|
graph.set_repo_id(first_repository.read(cx).id, cx)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
cx.draw(
|
|
point(px(0.), px(0.)),
|
|
gpui::size(px(1200.), px(800.)),
|
|
|_, _| git_graph.clone().into_any_element(),
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let commit_count_after_switch_back =
|
|
git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len());
|
|
assert_eq!(
|
|
initial_commit_count, commit_count_after_switch_back,
|
|
"graph_data should be repopulated from cache after switching back to the same repo"
|
|
);
|
|
}
|
|
}
|