From 61da34c69fbe28428765b700bb34f6691962fa36 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Tue, 12 May 2026 12:52:52 -0300
Subject: [PATCH] git_panel: Add commit history view (#56500)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR adds a history view to the git panel. It draws from similar
implementations of commit listing in other places like the file history
view and the git graph.
| Changes View | History View |
|--------|--------|
|
|
|
Release Notes:
- Git: Added a history view to the Git panel that allows to quickly see
in a list all the commits for a given branch.
---------
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
---
crates/git_ui/src/commit_tooltip.rs | 2 +-
crates/git_ui/src/git_panel.rs | 563 +++++++++++++++++++++++++---
crates/panel/src/panel.rs | 17 +-
3 files changed, 516 insertions(+), 66 deletions(-)
diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs
index b22fcee7e2d..c2b7f21fad2 100644
--- a/crates/git_ui/src/commit_tooltip.rs
+++ b/crates/git_ui/src/commit_tooltip.rs
@@ -84,7 +84,7 @@ impl<'a> CommitAvatar<'a> {
.child(
Icon::new(IconName::Person)
.color(Color::Muted)
- .size(IconSize::Small),
+ .size(IconSize::XSmall),
)
.into_any_element()
}
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index d34c68384ed..bf5a20ff150 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -1,6 +1,6 @@
use crate::askpass_modal::AskPassModal;
use crate::commit_modal::CommitModal;
-use crate::commit_tooltip::CommitTooltip;
+use crate::commit_tooltip::{CommitAvatar, CommitTooltip};
use crate::commit_view::CommitView;
use crate::git_panel_settings::GitPanelScrollbarAccessor;
use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff};
@@ -23,18 +23,20 @@ use editor::{EditorStyle, RewrapOptions};
use file_icons::FileIcons;
use futures::StreamExt as _;
use futures::channel::oneshot::Canceled;
+use git::Oid;
use git::commit::ParsedCommitMessage;
use git::repository::{
- Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitTemplate,
- GitCommitter, PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
- UpstreamTrackingStatus, get_git_committer,
+ Branch, CommitData, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions,
+ GitCommitTemplate, GitCommitter, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput,
+ ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, get_git_committer,
};
use git::stash::GitStash;
use git::status::{DiffStat, StageStatus};
use git::{Amend, Commit, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
use git::{
- ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
- StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll,
+ ExpandCommitEditor, GitHostingProviderRegistry, GitRemote, RestoreTrackedFiles, StageAll,
+ StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll,
+ parse_git_remote_url,
};
use gpui::{
AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent,
@@ -55,7 +57,9 @@ use panel::PanelHeader;
use project::git_store::GitAccess;
use project::{
Fs, Project, ProjectPath,
- git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
+ git_store::{
+ CommitDataState, GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op,
+ },
project_settings::{GitPathStyle, ProjectSettings},
};
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
@@ -71,9 +75,9 @@ use strum::{IntoEnumIterator, VariantNames};
use theme_settings::ThemeSettings;
use time::OffsetDateTime;
use ui::{
- ButtonLike, Checkbox, ContextMenu, ElevationIndex, IndentGuideColors, PopoverMenu,
- RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, TintColor, Tooltip, WithScrollbar,
- prelude::*,
+ ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, PopoverMenu,
+ RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip,
+ WithScrollbar, prelude::*,
};
use util::paths::PathStyle;
use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
@@ -289,6 +293,12 @@ struct SerializedGitPanel {
signoff_enabled: bool,
}
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum GitPanelTab {
+ Changes,
+ History,
+}
+
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
enum Section {
Conflict,
@@ -680,6 +690,11 @@ pub struct GitPanel {
commit_template: Option,
bulk_staging: Option,
stash_entries: GitStash,
+ active_tab: GitPanelTab,
+ commit_history_scroll_handle: UniformListScrollHandle,
+ commit_history_shas: Vec,
+ focused_history_entry: Option,
+ _repo_subscriptions: Vec,
_settings_subscription: Subscription,
git_access: GitAccess,
@@ -871,6 +886,11 @@ impl GitPanel {
entry_count: 0,
bulk_staging: None,
stash_entries: Default::default(),
+ active_tab: GitPanelTab::Changes,
+ commit_history_scroll_handle: UniformListScrollHandle::new(),
+ commit_history_shas: Vec::new(),
+ focused_history_entry: None,
+ _repo_subscriptions: Vec::new(),
_settings_subscription,
git_access: GitAccess::Yes,
};
@@ -1119,6 +1139,11 @@ impl GitPanel {
_window: &mut Window,
cx: &mut Context,
) {
+ if self.active_tab == GitPanelTab::History {
+ self.select_previous_history_entry(cx);
+ return;
+ }
+
let item_count = self.entries.len();
if item_count == 0 {
return;
@@ -1180,6 +1205,11 @@ impl GitPanel {
}
fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) {
+ if self.active_tab == GitPanelTab::History {
+ self.select_next_history_entry(cx);
+ return;
+ }
+
let item_count = self.entries.len();
if item_count == 0 {
return;
@@ -1303,6 +1333,10 @@ impl GitPanel {
}
fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
+ if self.active_tab == GitPanelTab::History {
+ self.open_selected_history_commit(window, cx);
+ return;
+ }
if let Some(GitListEntry::Directory(dir_entry)) = self
.selected_entry
.and_then(|i| self.entries.get(i))
@@ -3473,6 +3507,10 @@ impl GitPanel {
fn schedule_update(&mut self, window: &mut Window, cx: &mut Context) {
let handle = cx.entity().downgrade();
self.reopen_commit_buffer(window, cx);
+ self.preload_commit_history(cx);
+ if self.active_tab == GitPanelTab::History {
+ self.load_commit_history(cx);
+ }
self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
cx.background_executor().timer(UPDATE_DEBOUNCE).await;
if let Some(git_panel) = handle.upgrade() {
@@ -4100,7 +4138,7 @@ impl GitPanel {
path + file_name + depth * 2
}
- fn render_overflow_menu(&self, id: impl Into) -> impl IntoElement {
+ fn render_ellipsis_menu(&self, id: impl Into) -> impl IntoElement {
let focus_handle = self.focus_handle.clone();
let has_tracked_changes = self.has_tracked_changes();
let has_staged_changes = self.has_staged_changes();
@@ -4111,8 +4149,7 @@ impl GitPanel {
PopoverMenu::new(id.into())
.trigger(
IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted),
+ .icon_size(IconSize::Small),
)
.menu(move |window, cx| {
Some(git_panel_context_menu(
@@ -4383,9 +4420,9 @@ impl GitPanel {
})
}
- fn render_panel_header(
+ fn render_changes_header(
&self,
- window: &mut Window,
+ _window: &mut Window,
cx: &mut Context,
) -> Option {
if matches!(self.git_access, GitAccess::No) {
@@ -4401,24 +4438,24 @@ impl GitPanel {
("Stage All", StageAll.boxed_clone(), true, "git add --all")
};
- let change_string = match self.changes_count {
- 0 => "No Changes".to_string(),
- 1 => "1 Change".to_string(),
- count => format!("{} Changes", count),
- };
-
Some(
- self.panel_header_container(window, cx)
- .px_2()
+ h_flex()
+ .h(Tab::container_height(cx))
+ .w_full()
+ .px_1()
+ .flex_none()
.justify_between()
.child(
- Button::new("changes", change_string)
+ Button::new("changes", "View Diff")
.label_size(LabelSize::Small)
- .layer(ElevationIndex::ModalSurface)
- .size(ButtonSize::Compact)
.color(Color::Muted)
+ .start_icon(
+ Icon::new(IconName::Diff)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
.tooltip(Tooltip::for_action_title_in(
- "Open Diff",
+ "View Diff",
&Diff,
&self.focus_handle,
))
@@ -4431,12 +4468,11 @@ impl GitPanel {
.child(
h_flex()
.gap_1()
- .child(self.render_overflow_menu("overflow_menu"))
+ .child(self.render_ellipsis_menu("overflow_menu"))
.child(
Button::new("stage_unstage_all", text)
.label_size(LabelSize::Small)
.layer(ElevationIndex::ModalSurface)
- .size(ButtonSize::Compact)
.style(ButtonStyle::Filled)
.tooltip(Tooltip::for_action_title_in(
tooltip,
@@ -4888,6 +4924,426 @@ impl GitPanel {
)
}
+ fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement {
+ let active_tab = self.active_tab;
+
+ let tab = |id: ElementId,
+ active: bool,
+ show_changes: bool,
+ label: SharedString,
+ set_active_tab: GitPanelTab| {
+ h_flex()
+ .cursor_pointer()
+ .id(id)
+ .h_full()
+ .py_1()
+ .gap_1()
+ .flex_1()
+ .justify_center()
+ .hover(|s| s.bg(cx.theme().colors().element_hover))
+ .border_b_1()
+ .when(!active, |s| {
+ s.bg(cx.theme().colors().editor_background.opacity(0.6))
+ .border_color(cx.theme().colors().border.opacity(0.6))
+ })
+ .child(Label::new(label).when(!active, |this| this.color(Color::Muted)))
+ .when(show_changes && self.changes_count > 0, |this| {
+ this.child(
+ Label::new(format!("({})", self.changes_count))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ })
+ .on_click(
+ cx.listener(move |this, _, _, cx| this.set_active_tab(set_active_tab, cx)),
+ )
+ };
+
+ h_flex()
+ .relative()
+ .h(Tab::container_height(cx))
+ .w_full()
+ .child(tab(
+ ElementId::Name("changes-tab".into()),
+ active_tab == GitPanelTab::Changes,
+ true,
+ "Changes".into(),
+ GitPanelTab::Changes,
+ ))
+ .child(Divider::vertical().color(ui::DividerColor::BorderFaded))
+ .child(tab(
+ ElementId::Name("history-tab".into()),
+ active_tab != GitPanelTab::Changes,
+ false,
+ "History".into(),
+ GitPanelTab::History,
+ ))
+ }
+
+ fn render_history_tab(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex().flex_1().size_full().overflow_hidden().map(|this| {
+ if let Some(history) = self.render_commit_history(window, cx) {
+ this.child(history)
+ } else {
+ this.child(
+ h_flex()
+ .flex_1()
+ .justify_center()
+ .child(Label::new("Loading Commit History…").color(Color::Muted)),
+ )
+ }
+ })
+ }
+
+ fn select_next_history_entry(&mut self, cx: &mut Context) {
+ let count = self.commit_history_shas.len();
+ if count == 0 {
+ return;
+ }
+ let new_index = match self.focused_history_entry {
+ None => 0,
+ Some(i) => (i + 1).min(count - 1),
+ };
+ self.focused_history_entry = Some(new_index);
+ self.commit_history_scroll_handle
+ .scroll_to_item(new_index, ScrollStrategy::Top);
+ cx.notify();
+ }
+
+ fn select_previous_history_entry(&mut self, cx: &mut Context) {
+ let count = self.commit_history_shas.len();
+ if count == 0 {
+ return;
+ }
+ let new_index = match self.focused_history_entry {
+ None => 0,
+ Some(i) => i.saturating_sub(1),
+ };
+ self.focused_history_entry = Some(new_index);
+ self.commit_history_scroll_handle
+ .scroll_to_item(new_index, ScrollStrategy::Top);
+ cx.notify();
+ }
+
+ fn open_selected_history_commit(&self, window: &mut Window, cx: &mut App) {
+ let Some(index) = self.focused_history_entry else {
+ return;
+ };
+ let Some(sha) = self.commit_history_shas.get(index) else {
+ return;
+ };
+ let Some(active_repository) = self.active_repository.as_ref() else {
+ return;
+ };
+ CommitView::open(
+ sha.to_string(),
+ active_repository.downgrade(),
+ self.workspace.clone(),
+ None,
+ None,
+ window,
+ cx,
+ );
+ }
+
+ fn set_active_tab(&mut self, tab: GitPanelTab, cx: &mut Context) {
+ if self.active_tab == tab {
+ return;
+ }
+ self.active_tab = tab;
+ match tab {
+ GitPanelTab::History => {
+ self.load_commit_history(cx);
+ self.focused_history_entry = Some(0);
+ }
+ GitPanelTab::Changes => {
+ self.commit_history_shas.clear();
+ self.focused_history_entry = None;
+ self._repo_subscriptions.clear();
+ }
+ }
+ cx.notify();
+ }
+
+ fn preload_commit_history(&mut self, cx: &mut Context) {
+ let Some(active_repository) = self.active_repository.as_ref() else {
+ return;
+ };
+
+ let Some(branch) = active_repository.read(cx).branch.as_ref() else {
+ return;
+ };
+
+ let branch_name = branch.name().to_string();
+ let log_source = LogSource::Branch(branch_name.into());
+ let log_order = LogOrder::DateOrder;
+
+ // Kick off the git log fetch so data is ready when the user switches to History.
+ // graph_data() is idempotent — if already loading/loaded, this is a no-op.
+ active_repository.update(cx, |repository, cx| {
+ repository.graph_data(log_source, log_order, 0..0, cx);
+ });
+ }
+
+ fn load_commit_history(&mut self, cx: &mut Context) {
+ let Some(active_repository) = self.active_repository.as_ref() else {
+ return;
+ };
+
+ if self._repo_subscriptions.is_empty() {
+ self._repo_subscriptions.push(cx.subscribe(
+ active_repository,
+ |this, _repo, event, cx| {
+ if let RepositoryEvent::GraphEvent(_, _) = event {
+ if this.active_tab == GitPanelTab::History {
+ this.fetch_commit_history_shas(cx);
+ }
+ }
+ },
+ ));
+ self._repo_subscriptions
+ .push(cx.observe(active_repository, |_this, _repo, cx| {
+ cx.notify();
+ }));
+ }
+
+ self.fetch_commit_history_shas(cx);
+ }
+
+ fn fetch_commit_history_shas(&mut self, cx: &mut Context) {
+ let Some(active_repository) = self.active_repository.as_ref() else {
+ return;
+ };
+
+ let Some(branch) = active_repository.read(cx).branch.as_ref() else {
+ return;
+ };
+
+ let branch_name = branch.name().to_string();
+ let log_source = LogSource::Branch(branch_name.into());
+ let log_order = LogOrder::DateOrder;
+
+ self.commit_history_shas = active_repository.update(cx, |repository, cx| {
+ let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx);
+ response.commits.iter().map(|commit| commit.sha).collect()
+ });
+ }
+
+ fn git_remote(&self, cx: &mut App) -> Option {
+ let repo = self.active_repository.as_ref()?;
+ let remote_url = repo.read(cx).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_commit_history(
+ &self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ if self.commit_history_shas.is_empty() {
+ return None;
+ }
+
+ let active_repository = self.active_repository.as_ref()?;
+ let workspace = self.workspace.clone();
+ let repo_weak = active_repository.downgrade();
+ let shas = self.commit_history_shas.clone();
+ let item_count = shas.len();
+ let commit_history_scroll_handle = self.commit_history_scroll_handle.clone();
+ let remote = self.git_remote(cx);
+
+ let focused_history_entry = self.focused_history_entry;
+ let is_panel_focused = self.focus_handle.is_focused(window);
+
+ let ahead_count = active_repository
+ .read(cx)
+ .branch
+ .as_ref()
+ .and_then(|b| b.upstream.as_ref())
+ .and_then(|u| u.tracking.status())
+ .map(|s| s.ahead as usize)
+ .unwrap_or(0);
+
+ Some(
+ v_flex()
+ .flex_1()
+ .size_full()
+ .overflow_hidden()
+ .child(
+ uniform_list("commit_history_list", item_count, {
+ let workspace = workspace;
+ let repo_weak = repo_weak;
+ move |range, window, cx| {
+ let local_offset = time::UtcOffset::current_local_offset()
+ .unwrap_or(time::UtcOffset::UTC);
+ let now = time::OffsetDateTime::now_utc();
+
+ let visible_data: Vec