diff --git a/Cargo.lock b/Cargo.lock
index ef6fd4e2c22cf53a5aa145600435983beae86437..dae0fef9c224c0dda72996dc2c58dc75768569fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7217,6 +7217,7 @@ dependencies = [
"ctor",
"db",
"editor",
+ "feature_flags",
"futures 0.3.31",
"fuzzy",
"git",
diff --git a/assets/icons/git_commit.svg b/assets/icons/git_commit.svg
new file mode 100644
index 0000000000000000000000000000000000000000..38b36ec7efb72275e5e6efbbe761deb54050cfe7
--- /dev/null
+++ b/assets/icons/git_commit.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/git_graph.svg b/assets/icons/git_graph.svg
index 8f372a305d3fddf2901756108c83d09b31fb657e..7ae33e365d40bfccd9c48e4f7e94b10d3687f8dc 100644
--- a/assets/icons/git_graph.svg
+++ b/assets/icons/git_graph.svg
@@ -1,4 +1,7 @@
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index 8f96de0e7b6d9b385fcda533a31ecc34b5afdbcc..087e76c4129254d3b6f488259bc8fa19aa91370d 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -57,6 +57,12 @@ impl FeatureFlag for DiffReviewFeatureFlag {
}
}
+pub struct GitGraphFeatureFlag;
+
+impl FeatureFlag for GitGraphFeatureFlag {
+ const NAME: &'static str = "git-graph";
+}
+
pub struct StreamingEditFileToolFeatureFlag;
impl FeatureFlag for StreamingEditFileToolFeatureFlag {
diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs
index 37f170ada5ecd23daf5ee58ee1011af95bfc6b8d..3bdb2b0d717ca4cae181fee9dd690755e29075d0 100644
--- a/crates/git_graph/src/git_graph.rs
+++ b/crates/git_graph/src/git_graph.rs
@@ -1,5 +1,5 @@
use collections::{BTreeMap, HashMap};
-use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
use git::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
parse_git_remote_url,
@@ -39,7 +39,7 @@ use ui::{
};
use workspace::{
Workspace,
- item::{Item, ItemEvent, SerializableItem},
+ item::{Item, ItemEvent, SerializableItem, TabTooltipContent},
};
const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);
@@ -48,6 +48,7 @@ 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 PENDING_SELECT_MAX_RETRIES: usize = 5;
const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
struct CopiedState {
@@ -246,12 +247,6 @@ actions!(
]
);
-pub struct GitGraphFeatureFlag;
-
-impl FeatureFlag for GitGraphFeatureFlag {
- const NAME: &'static str = "git-graph";
-}
-
fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
static FORMAT: OnceLock>> = OnceLock::new();
FORMAT.get_or_init(|| {
@@ -710,29 +705,66 @@ pub fn init(cx: &mut App) {
|div| {
let workspace = workspace.weak_handle();
- div.on_action(move |_: &git_ui::git_panel::Open, window, cx| {
- workspace
- .update(cx, |workspace, cx| {
- let existing = workspace.items_of_type::(cx).next();
- if let Some(existing) = existing {
- workspace.activate_item(&existing, true, true, window, cx);
- return;
- }
+ div.on_action({
+ let workspace = workspace.clone();
+ move |_: &git_ui::git_panel::Open, window, cx| {
+ workspace
+ .update(cx, |workspace, cx| {
+ let existing = workspace.items_of_type::(cx).next();
+ if let Some(existing) = existing {
+ workspace.activate_item(&existing, true, true, window, cx);
+ return;
+ }
- let project = workspace.project().clone();
- let workspace_handle = workspace.weak_handle();
- let git_graph = cx
- .new(|cx| GitGraph::new(project, workspace_handle, window, cx));
- workspace.add_item_to_active_pane(
- Box::new(git_graph),
- None,
- true,
- window,
- cx,
- );
- })
- .ok();
+ let project = workspace.project().clone();
+ let workspace_handle = workspace.weak_handle();
+ let git_graph = cx.new(|cx| {
+ GitGraph::new(project, 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 existing = workspace.items_of_type::(cx).next();
+ if let Some(existing) = existing {
+ existing.update(cx, |graph, cx| {
+ graph.select_commit_by_sha(&sha, cx);
+ });
+ workspace.activate_item(&existing, true, true, window, cx);
+ return;
+ }
+
+ let project = workspace.project().clone();
+ let workspace_handle = workspace.weak_handle();
+ let git_graph = cx.new(|cx| {
+ let mut graph =
+ GitGraph::new(project, workspace_handle, window, cx);
+ graph.select_commit_by_sha(&sha, cx);
+ graph
+ });
+ workspace.add_item_to_active_pane(
+ Box::new(git_graph),
+ None,
+ true,
+ window,
+ cx,
+ );
+ })
+ .ok();
+ },
+ )
},
)
});
@@ -821,6 +853,7 @@ pub struct GitGraph {
commit_details_split_state: Entity,
selected_repo_id: Option,
changed_files_scroll_handle: UniformListScrollHandle,
+ pending_select_sha: Option<(String, usize)>,
}
impl GitGraph {
@@ -918,6 +951,7 @@ impl GitGraph {
commit_details_split_state: cx.new(|_cx| SplitState::new()),
selected_repo_id: active_repository,
changed_files_scroll_handle: UniformListScrollHandle::new(),
+ pending_select_sha: None,
};
this.fetch_initial_graph_data(cx);
@@ -944,8 +978,10 @@ impl GitGraph {
self.graph_data.add_commits(commits);
});
cx.notify();
+ self.retry_pending_select(cx);
}
RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => {
+ 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
@@ -1153,6 +1189,37 @@ impl GitGraph {
cx.notify();
}
+ pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) {
+ let Ok(oid) = sha.parse::() else {
+ return;
+ };
+ for (idx, commit) in self.graph_data.commits.iter().enumerate() {
+ if commit.data.sha == oid {
+ self.pending_select_sha = None;
+ self.select_entry(idx, cx);
+ return;
+ }
+ }
+ self.pending_select_sha = Some((sha.to_string(), PENDING_SELECT_MAX_RETRIES));
+ }
+
+ fn retry_pending_select(&mut self, cx: &mut Context) {
+ let Some((sha, retries_remaining)) = self.pending_select_sha.take() else {
+ return;
+ };
+ if let Ok(oid) = sha.parse::() {
+ for (idx, commit) in self.graph_data.commits.iter().enumerate() {
+ if commit.data.sha == oid {
+ self.select_entry(idx, cx);
+ return;
+ }
+ }
+ }
+ if retries_remaining > 0 {
+ self.pending_select_sha = Some((sha, retries_remaining - 1));
+ }
+ }
+
fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) {
let Some(selected_entry_index) = self.selected_entry_idx else {
return;
@@ -2179,6 +2246,30 @@ impl Focusable for GitGraph {
impl Item for GitGraph {
type Event = ItemEvent;
+ fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
+ Some(Icon::new(IconName::GitGraph))
+ }
+
+ fn tab_tooltip_content(&self, cx: &App) -> Option {
+ let repo_name = self.get_selected_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 {
"Git Graph".into()
}
diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml
index f779570be471fd1a097e350d59ef2fb1d4003d2b..28fac0f849a487c6654e2ac5976191cd3e1a733f 100644
--- a/crates/git_ui/Cargo.toml
+++ b/crates/git_ui/Cargo.toml
@@ -27,6 +27,7 @@ component.workspace = true
db.workspace = true
editor.workspace = true
futures.workspace = true
+feature_flags.workspace = true
fuzzy.workspace = true
git.workspace = true
gpui.workspace = true
diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs
index f5ed23a6a84e7649ddf7f1e7b6b3651a323ee3c6..8f2a019fddf0513c100a53956c81012d11c2ca30 100644
--- a/crates/git_ui/src/commit_view.rs
+++ b/crates/git_ui/src/commit_view.rs
@@ -3,6 +3,7 @@ use buffer_diff::BufferDiff;
use collections::HashMap;
use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
+use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
use git::status::{FileStatus, StatusCode, TrackedStatus};
use git::{
@@ -27,7 +28,7 @@ use std::{
sync::Arc,
};
use theme::ActiveTheme;
-use ui::{ButtonLike, DiffStat, Tooltip, prelude::*};
+use ui::{DiffStat, Divider, Tooltip, prelude::*};
use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
use workspace::item::TabTooltipContent;
use workspace::{
@@ -450,6 +451,7 @@ impl CommitView {
fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
let commit = &self.commit;
let author_name = commit.author_name.clone();
+ let author_email = commit.author_email.clone();
let commit_sha = commit.sha.clone();
let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
@@ -461,36 +463,6 @@ impl CommitView {
time_format::TimestampFormat::MediumAbsolute,
);
- let remote_info = self
- .remote
- .as_ref()
- .filter(|_| self.stash.is_none())
- .map(|remote| {
- let provider = remote.host.name();
- let parsed_remote = ParsedGitRemote {
- owner: remote.owner.as_ref().into(),
- repo: remote.repo.as_ref().into(),
- };
- let params = BuildCommitPermalinkParams { sha: &commit.sha };
- let url = remote
- .host
- .build_commit_permalink(&parsed_remote, params)
- .to_string();
- (provider, url)
- });
-
- let (additions, deletions) = self.calculate_changed_lines(cx);
-
- let commit_diff_stat = if additions > 0 || deletions > 0 {
- Some(DiffStat::new(
- "commit-diff-stat",
- additions as usize,
- deletions as usize,
- ))
- } else {
- None
- };
-
let gutter_width = self.editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(window, cx);
let style = editor.style(cx);
@@ -501,116 +473,75 @@ impl CommitView {
.full_width()
});
- let clipboard_has_link = cx
+ let clipboard_has_sha = cx
.read_from_clipboard()
.and_then(|entry| entry.text())
.map_or(false, |clipboard_text| {
clipboard_text.trim() == commit_sha.as_ref()
});
- let (copy_icon, copy_icon_color) = if clipboard_has_link {
+ let (copy_icon, copy_icon_color) = if clipboard_has_sha {
(IconName::Check, Color::Success)
} else {
(IconName::Copy, Color::Muted)
};
h_flex()
+ .py_2()
+ .pr_2p5()
+ .w_full()
+ .justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
- .w_full()
- .child(
- h_flex()
- .w(gutter_width)
- .justify_center()
- .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)),
- )
.child(
h_flex()
- .py_4()
- .pl_1()
- .pr_4()
- .w_full()
- .items_start()
- .justify_between()
- .flex_wrap()
+ .child(h_flex().w(gutter_width).justify_center().child(
+ self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx),
+ ))
.child(
- v_flex()
- .child(
- h_flex()
- .gap_1()
- .child(Label::new(author_name).color(Color::Default))
- .child({
- ButtonLike::new("sha")
- .child(
- h_flex()
- .group("sha_btn")
- .size_full()
- .max_w_32()
- .gap_0p5()
- .child(
- Label::new(commit_sha.clone())
- .color(Color::Muted)
- .size(LabelSize::Small)
- .truncate()
- .buffer_font(cx),
- )
- .child(
- div().visible_on_hover("sha_btn").child(
- Icon::new(copy_icon)
- .color(copy_icon_color)
- .size(IconSize::Small),
- ),
- ),
- )
- .tooltip({
- let commit_sha = commit_sha.clone();
- move |_, cx| {
- Tooltip::with_meta(
- "Copy Commit SHA",
- None,
- commit_sha.clone(),
- cx,
- )
- }
- })
- .on_click(move |_, _, cx| {
- cx.stop_propagation();
- cx.write_to_clipboard(ClipboardItem::new_string(
- commit_sha.to_string(),
- ));
- })
- }),
- )
- .child(
- h_flex()
- .gap_1p5()
- .child(
- Label::new(date_string)
- .color(Color::Muted)
- .size(LabelSize::Small),
- )
- .child(
- Label::new("•")
- .color(Color::Ignored)
- .size(LabelSize::Small),
- )
- .children(commit_diff_stat),
- ),
- )
- .children(remote_info.map(|(provider_name, url)| {
- let icon = match provider_name.as_str() {
- "GitHub" => IconName::Github,
- _ => IconName::Link,
- };
-
- Button::new("view_on_provider", format!("View on {}", provider_name))
- .icon(icon)
- .icon_color(Color::Muted)
- .icon_size(IconSize::Small)
- .icon_position(IconPosition::Start)
- .on_click(move |_, _, cx| cx.open_url(&url))
- })),
+ v_flex().child(Label::new(author_name)).child(
+ h_flex()
+ .gap_1p5()
+ .child(
+ Label::new(date_string)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ .child(
+ Label::new("•")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .alpha(0.5),
+ )
+ .child(
+ Label::new(author_email)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ ),
+ ),
+ ),
)
+ .when(self.stash.is_none(), |this| {
+ this.child(
+ Button::new("sha", "Commit SHA")
+ .icon(copy_icon)
+ .icon_color(copy_icon_color)
+ .icon_position(IconPosition::Start)
+ .icon_size(IconSize::Small)
+ .tooltip({
+ let commit_sha = commit_sha.clone();
+ move |_, cx| {
+ Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx)
+ }
+ })
+ .on_click(move |_, _, cx| {
+ cx.stop_propagation();
+ cx.write_to_clipboard(ClipboardItem::new_string(
+ commit_sha.to_string(),
+ ));
+ }),
+ )
+ })
}
fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
@@ -898,7 +829,7 @@ impl Item for CommitView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
- Some(Icon::new(IconName::GitBranch).color(Color::Muted))
+ Some(Icon::new(IconName::GitCommit).color(Color::Muted))
}
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
@@ -1081,8 +1012,93 @@ impl CommitViewToolbar {
impl EventEmitter for CommitViewToolbar {}
impl Render for CommitViewToolbar {
- fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
- div().hidden()
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else {
+ return div();
+ };
+
+ let commit_view_ref = commit_view.read(cx);
+ let is_stash = commit_view_ref.stash.is_some();
+
+ let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx);
+
+ let commit_sha = commit_view_ref.commit.sha.clone();
+
+ let remote_info = commit_view_ref.remote.as_ref().map(|remote| {
+ let provider = remote.host.name();
+ let parsed_remote = ParsedGitRemote {
+ owner: remote.owner.as_ref().into(),
+ repo: remote.repo.as_ref().into(),
+ };
+ let params = BuildCommitPermalinkParams { sha: &commit_sha };
+ let url = remote
+ .host
+ .build_commit_permalink(&parsed_remote, params)
+ .to_string();
+ (provider, url)
+ });
+
+ let sha_for_graph = commit_sha.to_string();
+
+ h_flex()
+ .gap_1()
+ .when(additions > 0 || deletions > 0, |this| {
+ this.child(
+ h_flex()
+ .gap_2()
+ .child(DiffStat::new(
+ "toolbar-diff-stat",
+ additions as usize,
+ deletions as usize,
+ ))
+ .child(Divider::vertical()),
+ )
+ })
+ .child(
+ IconButton::new("buffer-search", IconName::MagnifyingGlass)
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| {
+ Tooltip::for_action(
+ "Buffer Search",
+ &zed_actions::buffer_search::Deploy::find(),
+ cx,
+ )
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(
+ Box::new(zed_actions::buffer_search::Deploy::find()),
+ cx,
+ );
+ }),
+ )
+ .when(!is_stash, |this| {
+ this.when(cx.has_flag::(), |this| {
+ this.child(
+ IconButton::new("show-in-git-graph", IconName::GitGraph)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Show in Git Graph"))
+ .on_click(move |_, window, cx| {
+ window.dispatch_action(
+ Box::new(crate::git_panel::OpenAtCommit {
+ sha: sha_for_graph.clone(),
+ }),
+ cx,
+ );
+ }),
+ )
+ })
+ .children(remote_info.map(|(provider_name, url)| {
+ let icon = match provider_name.as_str() {
+ "GitHub" => IconName::Github,
+ _ => IconName::Link,
+ };
+
+ IconButton::new("view_on_provider", icon)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text(format!("View on {}", provider_name)))
+ .on_click(move |_, _, cx| cx.open_url(&url))
+ }))
+ })
}
}
@@ -1093,12 +1109,11 @@ impl ToolbarItemView for CommitViewToolbar {
_: &mut Window,
cx: &mut Context,
) -> ToolbarItemLocation {
- if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx))
- && entity.read(cx).stash.is_some()
- {
+ if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) {
self.commit_view = Some(entity.downgrade());
return ToolbarItemLocation::PrimaryRight;
}
+ self.commit_view = None;
ToolbarItemLocation::Hidden
}
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index fe7d8975010ecf1055bb45e6986ecca363314e2e..b86fa0196ae786db7a981427628295c4f9d81061 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -123,6 +123,13 @@ actions!(
]
);
+/// Opens the Git Graph Tab at a specific commit.
+#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)]
+#[action(namespace = git_graph)]
+pub struct OpenAtCommit {
+ pub sha: String,
+}
+
fn prompt(
msg: &str,
detail: Option<&str>,
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 9ed9a8b658cc8bbf89c9d14d131fc8faefbc80ed..d6356f831ea9bbbaec5313da1a5b56f101471411 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -142,6 +142,7 @@ pub enum IconName {
GitBranch,
GitBranchAlt,
GitBranchPlus,
+ GitCommit,
GitGraph,
Github,
Hash,