This commit is contained in:
Jacob 2025-08-27 00:39:16 +08:00 committed by GitHub
commit 1f6ba60412
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 425 additions and 2 deletions

View file

@ -760,7 +760,11 @@
// Choices: always, auto, never, system
// Default: inherits editor scrollbar settings
// "show": null
}
},
//// Whether to show a list of previous commits in the git panel.
///
/// Default: false
"commit_history": false
},
"message_editor": {
// Whether to automatically replace emoji shortcodes with emoji characters.

View file

@ -464,6 +464,7 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GitInit>)
.add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)
.add_request_handler(forward_read_only_project_request::<proto::GitShow>)
.add_request_handler(forward_read_only_project_request::<proto::GitLog>)
.add_request_handler(forward_read_only_project_request::<proto::LoadCommitDiff>)
.add_request_handler(forward_read_only_project_request::<proto::GitReset>)
.add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)

View file

@ -156,6 +156,15 @@ impl GitRepository for FakeGitRepository {
.boxed()
}
fn log(&self, _skip: u64, _max_count: u64) -> BoxFuture<'_, Result<Vec<CommitDetails>>> {
async {
Ok(vec![CommitDetails {
..Default::default()
}])
}
.boxed()
}
fn reset(
&self,
_commit: String,

View file

@ -359,6 +359,10 @@ pub trait GitRepository: Send + Sync {
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
/// Returns the git commit log returning at most `max_count` commits from
/// the current branch, skipping `skip` results
fn log(&self, skip: u64, max_count: u64) -> BoxFuture<'_, Result<Vec<CommitDetails>>>;
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
@ -614,6 +618,57 @@ impl GitRepository for RealGitRepository {
.boxed()
}
fn log(&self, skip: u64, max_size: u64) -> BoxFuture<'_, Result<Vec<CommitDetails>>> {
let working_directory = self.working_directory();
self.executor
.spawn(async move {
let working_directory = working_directory?;
let output = new_std_command("git")
.current_dir(&working_directory)
.args([
"--no-pager",
"--no-optional-locks",
"log",
"--skip",
&skip.to_string(),
"--max-count",
&max_size.to_string(),
"--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
])
.output()?;
let output = std::str::from_utf8(&output.stdout)?
.trim()
.trim_end_matches('\0');
let parts: Vec<&str> = output.split('\0').map(|value| value.trim()).collect();
if parts.len() % 5 != 0 {
bail!("unexpected git log output length: {}", parts.len());
}
let mut commits = Vec::with_capacity(parts.len() / 5);
for chunk in parts.chunks(5) {
let sha = chunk[0].to_string().into();
let message = chunk[1].to_string().into();
let commit_timestamp = chunk[2].parse()?;
let author_email = chunk[3].to_string().into();
let author_name = chunk[4].to_string().into();
commits.push(CommitDetails {
sha,
message,
commit_timestamp,
author_email,
author_name,
});
}
Ok(commits)
})
.boxed()
}
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
else {

View file

@ -0,0 +1,285 @@
use crate::commit_view::CommitView;
use git::{blame::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
App, Entity, ListScrollEvent, ListState, MouseButton, ParentElement, Render, Stateful, Task,
WeakEntity, list, prelude::*,
};
use itertools::Itertools;
use project::{
Project,
git_store::{GitStoreEvent, Repository},
};
use time::OffsetDateTime;
use ui::{ListItem, Scrollbar, ScrollbarState, prelude::*};
use workspace::Workspace;
const COMMITS_PER_PAGE: u64 = 20;
#[derive(Clone, Debug)]
pub struct CommitDetails {
pub sha: SharedString,
pub author_name: SharedString,
pub author_email: SharedString,
pub commit_time: OffsetDateTime,
pub message: Option<ParsedCommitMessage>,
}
pub struct GitCommitList {
pub(crate) active_repository: Option<Entity<Repository>>,
pub(crate) project: Entity<Project>,
pub(crate) workspace: WeakEntity<Workspace>,
commits: Vec<CommitDetails>,
commits_loading: bool,
commits_list: ListState,
scrollbar_state: ScrollbarState,
}
impl GitCommitList {
pub fn new(workspace: &mut Workspace, window: &mut Window, cx: &mut App) -> Entity<Self> {
let project = workspace.project().clone();
let git_store = project.read(cx).git_store().clone();
let active_repository = project.read(cx).active_repository(cx);
let workspace = workspace.weak_handle();
cx.new(|cx| {
cx.subscribe_in(
&git_store,
window,
move |this: &mut GitCommitList, _git_store, event, window, cx| match event {
GitStoreEvent::ActiveRepositoryChanged(_) => {
this.active_repository = this.project.read(cx).active_repository(cx);
this.reload_history(window, cx)
}
// Reload the git history on repository changes to the current repo
GitStoreEvent::RepositoryUpdated(_, _, true) => this.reload_history(window, cx),
_ => {}
},
)
.detach();
let commits_list = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
commits_list.set_scroll_handler(cx.listener(
|this: &mut Self, event: &ListScrollEvent, window, cx| {
if this.commits.len() > 5
&& event.visible_range.end >= this.commits.len() - 5
&& this.has_next_page()
{
this.load_next_history_page(window, cx);
}
},
));
let scrollbar_state =
ScrollbarState::new(commits_list.clone()).parent_entity(&cx.entity());
let this = Self {
active_repository,
project,
workspace,
commits: Vec::new(),
commits_loading: false,
commits_list,
scrollbar_state,
};
this.load_next_history_page(window, cx);
this
})
}
fn reload_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.commits.clear();
self.commits_loading = false;
self.commits_list.reset(0);
self.load_next_history_page(window, cx);
}
// We probabbly have a next page if the length of all pages matches the per page amount
fn has_next_page(&self) -> bool {
self.commits.len() % (COMMITS_PER_PAGE as usize) == 0
}
fn load_next_history_page(&self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if already loading a page
if self.commits_loading {
return;
}
let skip = self.commits.len() as u64;
cx.spawn_in(window, async move |this, cx| {
let details = this.update(cx, |list: &mut GitCommitList, cx| {
list.commits_loading = true;
list.load_commit_history(cx, skip, COMMITS_PER_PAGE)
})?;
let details = details.await?;
let commits: Vec<CommitDetails> = details
.into_iter()
.map(|commit| {
anyhow::Ok(CommitDetails {
sha: commit.sha.clone(),
author_name: commit.author_name.clone(),
author_email: commit.author_email.clone(),
commit_time: OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)?,
message: Some(ParsedCommitMessage {
message: commit.message,
..Default::default()
}),
})
})
.try_collect()?;
this.update(cx, |this: &mut GitCommitList, cx| {
let count = commits.len();
let current_count = this.commits_list.item_count();
this.commits_loading = false;
this.commits.extend(commits);
this.commits_list
.splice(current_count..current_count, count);
cx.notify();
})
})
.detach();
}
fn load_commit_history(
&self,
cx: &mut Context<Self>,
skip: u64,
max_count: u64,
) -> Task<anyhow::Result<Vec<git::repository::CommitDetails>>> {
let Some(repo) = self.active_repository.clone() else {
return Task::ready(Err(anyhow::anyhow!("no active repo")));
};
repo.update(cx, |repo, cx| {
let git_log = repo.log(skip, max_count);
cx.spawn(async move |_, _| git_log.await?)
})
}
fn render_element(
&self,
item_id: ElementId,
commit: &CommitDetails,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let commit_summary = CommitSummary {
sha: commit.sha.clone(),
subject: commit
.message
.as_ref()
.map_or(Default::default(), |message| {
message
.message
.split('\n')
.next()
.unwrap()
.trim_end()
.to_string()
.into()
}),
commit_timestamp: commit.commit_time.unix_timestamp(),
has_parent: false,
};
ListItem::new(item_id)
.child(
h_flex()
.child(
h_flex()
.items_center()
.h_8()
.text_xs()
.text_color(Color::Default.color(cx))
.child(commit_summary.subject.clone()),
)
.child(
h_flex()
.items_center()
.h_8()
.text_xs()
.text_color(Color::Hidden.color(cx))
.child(commit.author_name.clone())
.ml_1(),
),
)
.on_click({
let workspace = self.workspace.clone();
let repo = self.active_repository.as_ref().map(|repo| repo.downgrade());
move |_, window, cx| {
let repo = match repo.clone() {
Some(repo) => repo,
None => return,
};
CommitView::open(commit_summary.clone(), repo, workspace.clone(), window, cx);
}
})
}
fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
div()
.occlude()
.id("git-history-scrollbar")
.on_mouse_move(cx.listener(|_, _, _, cx| {
cx.notify();
cx.stop_propagation()
}))
.on_hover(|_, _, cx| {
cx.stop_propagation();
})
.on_any_mouse_down(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_up(
MouseButton::Left,
cx.listener(|_, _, _, cx| {
cx.stop_propagation();
}),
)
.on_scroll_wheel(cx.listener(|_, _, _, cx| {
cx.notify();
}))
.h_full()
.absolute()
.right_1()
.top_1()
.bottom_0()
.w(px(12.))
.cursor_default()
.children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
}
}
impl Render for GitCommitList {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.border_t_1()
.border_color(cx.theme().colors().border.opacity(0.8))
.flex_shrink()
.h_48()
.w_full()
.child(
list(
self.commits_list.clone(),
cx.processor(move |list, index, window, cx| {
let item: &CommitDetails = &list.commits[index];
list.render_element(ElementId::Name(item.sha.clone()), item, window, cx)
.into_any_element()
}),
)
.size_full(),
)
.child(self.render_vertical_scrollbar(cx))
}
}

View file

@ -2,6 +2,7 @@ use crate::askpass_modal::AskPassModal;
use crate::commit_modal::CommitModal;
use crate::commit_tooltip::CommitTooltip;
use crate::commit_view::CommitView;
use crate::git_commit_list::GitCommitList;
use crate::git_panel_settings::StatusStyle;
use crate::project_diff::{self, Diff, ProjectDiff};
use crate::remote_output::{self, RemoteAction, SuccessMessage};
@ -370,6 +371,7 @@ pub struct GitPanel {
local_committer_task: Option<Task<()>>,
bulk_staging: Option<BulkStaging>,
_settings_subscription: Subscription,
history: Entity<GitCommitList>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -520,6 +522,8 @@ impl GitPanel {
)
.detach();
let history = GitCommitList::new(workspace, window, cx);
let mut this = Self {
active_repository,
commit_editor,
@ -559,6 +563,7 @@ impl GitPanel {
vertical_scrollbar,
bulk_staging: None,
_settings_subscription,
history,
};
this.schedule_update(false, window, cx);
@ -4556,6 +4561,9 @@ impl Render for GitPanel {
.when(!self.amend_pending, |this| {
this.children(self.render_previous_commit(cx))
})
.when(GitPanelSettings::get_global(cx).commit_history, |this| {
this.child(self.history.clone())
})
.into_any_element(),
)
.children(self.context_menu.as_ref().map(|(menu, position, _)| {

View file

@ -75,6 +75,11 @@ pub struct GitPanelSettingsContent {
///
/// Default: false
pub collapse_untracked_diff: Option<bool>,
//// Whether to show a list of previous commits in the git panel.
///
/// Default: false
pub commit_history: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
@ -87,6 +92,7 @@ pub struct GitPanelSettings {
pub fallback_branch_name: String,
pub sort_by_path: bool,
pub collapse_untracked_diff: bool,
pub commit_history: bool,
}
impl Settings for GitPanelSettings {

View file

@ -29,6 +29,7 @@ pub mod commit_tooltip;
mod commit_view;
mod conflict_view;
pub mod file_diff_view;
pub mod git_commit_list;
pub mod git_panel;
mod git_panel_settings;
pub mod onboarding;

View file

@ -3469,6 +3469,41 @@ impl Repository {
})
}
pub fn log(
&mut self,
skip: u64,
max_count: u64,
) -> oneshot::Receiver<Result<Vec<CommitDetails>>> {
let id = self.id;
self.send_job(None, move |git_repo, _cx| async move {
match git_repo {
RepositoryState::Local { backend, .. } => backend.log(skip, max_count).await,
RepositoryState::Remote { project_id, client } => {
let resp = client
.request(proto::GitLog {
project_id: project_id.0,
repository_id: id.to_proto(),
skip,
max_count,
})
.await?;
Ok(resp
.commits
.into_iter()
.map(|commit| CommitDetails {
sha: commit.sha.into(),
message: commit.message.into(),
commit_timestamp: commit.commit_timestamp,
author_email: commit.author_email.into(),
author_name: commit.author_name.into(),
})
.collect())
}
}
})
}
pub fn load_commit_diff(&mut self, commit: String) -> oneshot::Receiver<Result<CommitDiff>> {
let id = self.id;
self.send_job(None, move |git_repo, cx| async move {

View file

@ -230,6 +230,18 @@ message GitShow {
string commit = 4;
}
message GitLog {
uint64 project_id = 1;
reserved 2;
uint64 repository_id = 3;
uint64 skip = 4;
uint64 max_count = 5;
}
message GitLogResponse {
repeated GitCommitDetails commits = 1;
}
message GitCommitDetails {
string sha = 1;
string message = 2;

View file

@ -396,7 +396,10 @@ message Envelope {
GitCloneResponse git_clone_response = 364;
LspQuery lsp_query = 365;
LspQueryResponse lsp_query_response = 366; // current max
LspQueryResponse lsp_query_response = 366;
GitLog git_log = 367;
GitLogResponse git_log_response = 368; // current max
}
reserved 87 to 88;

View file

@ -284,6 +284,8 @@ messages!(
(GitReset, Background),
(GitCheckoutFiles, Background),
(GitShow, Background),
(GitLog, Background),
(GitLogResponse, Background),
(GitCommitDetails, Background),
(SetIndexText, Background),
(Push, Background),
@ -462,6 +464,7 @@ request_messages!(
(InstallExtension, Ack),
(RegisterBufferWithLanguageServers, Ack),
(GitShow, GitCommitDetails),
(GitLog, GitLogResponse),
(GitReset, Ack),
(GitCheckoutFiles, Ack),
(SetIndexText, Ack),
@ -609,6 +612,7 @@ entity_messages!(
CancelLanguageServerWork,
RegisterBufferWithLanguageServers,
GitShow,
GitLog,
GitReset,
GitCheckoutFiles,
SetIndexText,