feat: git history list initial prototype

This commit is contained in:
Jacobtread 2025-08-09 23:42:05 +12:00
parent 4e97968bcb
commit dabab27e2c
10 changed files with 416 additions and 2 deletions

View file

@ -480,6 +480,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

@ -153,6 +153,15 @@ impl GitRepository for FakeGitRepository {
.boxed()
}
fn git_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

@ -361,6 +361,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 git_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>>;
@ -616,6 +620,57 @@ impl GitRepository for RealGitRepository {
.boxed()
}
fn git_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,288 @@
use crate::{commit_view::CommitView, git_panel::GitPanel};
use futures::Future;
use git::{GitRemote, blame::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
App, Asset, Element, Entity, ListHorizontalSizingBehavior, ListSizingBehavior, ParentElement,
Render, Stateful, Task, UniformListScrollHandle, WeakEntity, prelude::*, uniform_list,
};
use project::{
Project,
git_store::{GitStoreEvent, Repository},
};
use std::hash::Hash;
use time::OffsetDateTime;
use ui::{Avatar, ListItem, Scrollbar, ScrollbarState, prelude::*};
use workspace::Workspace;
#[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>,
expanded: bool,
history: Vec<CommitDetails>,
scroll_handle: UniformListScrollHandle,
vertical_scrollbar_state: ScrollbarState,
horizontal_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);
cx.new(|cx| {
cx.spawn_in(window, async move |this, cx| {
let details = this.update(cx, |list: &mut GitCommitList, cx| {
list.load_commit_history(cx, 0, 50)
})?;
println!("Request history");
let details = details.await?;
let commit_details: Vec<crate::git_commit_list::CommitDetails> = details
.into_iter()
.map(|commit| 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)
// TODO: Handle properly
.unwrap(),
message: Some(ParsedCommitMessage {
message: commit.message.clone(),
..Default::default()
}),
})
.collect();
println!("Got history : {}", commit_details.len());
this.update(cx, |this: &mut GitCommitList, cx| {
println!("Updating history : {}", commit_details.len());
this.history = commit_details;
cx.notify();
})
})
.detach();
cx.subscribe_in(
&git_store,
window,
move |this, _git_store, event, window, cx| match event {
GitStoreEvent::ActiveRepositoryChanged(_) => {
this.active_repository = this.project.read(cx).active_repository(cx);
}
_ => {}
},
)
.detach();
let scroll_handle = UniformListScrollHandle::new();
Self {
history: Vec::new(),
scroll_handle: scroll_handle.clone(),
vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
.parent_entity(&cx.entity()),
horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
.parent_entity(&cx.entity()),
expanded: false,
active_repository,
project,
workspace: workspace.weak_handle(),
}
})
}
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.git_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_sm()
.text_color(Color::Default.color(cx))
.child(commit_summary.subject.clone()),
)
.child(
h_flex()
.items_center()
.h_8()
.text_sm()
.text_color(Color::Hidden.color(cx))
.child(commit.author_name.clone())
.ml_1(),
),
)
.on_click({
let commit = commit_summary.clone();
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.clone(),
repo.clone(),
workspace.clone().clone(),
window,
cx,
);
}
})
}
fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
Some(
div()
.occlude()
.id("project-panel-vertical-scroll")
.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_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.vertical_scrollbar_state.clone())),
)
}
fn render_horizontal_scrollbar(
&self,
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<Stateful<Div>> {
Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| {
div()
.occlude()
.id("project-panel-horizontal-scroll")
.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_scroll_wheel(cx.listener(|_, _, _, cx| {
cx.notify();
}))
.w_full()
.absolute()
.right_1()
.left_1()
.bottom_0()
.h(px(12.))
.cursor_default()
.child(scrollbar)
})
}
}
impl Render for GitCommitList {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let list_contents = uniform_list(
"git_history",
self.history.len(),
cx.processor(move |panel, range, window, cx| {
let history = panel.history.get(range);
history
.map(|entries: &[CommitDetails]| entries.to_vec())
.unwrap_or_default()
.iter()
.map(|item| {
panel
.render_element(ElementId::Name(item.sha.clone()), item, window, cx)
.into_any_element()
})
.collect()
}),
)
.with_sizing_behavior(ListSizingBehavior::Infer)
.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
.track_scroll(self.scroll_handle.clone());
v_flex()
.flex_shrink()
.h_48()
.w_full()
.child(list_contents)
.children(self.render_vertical_scrollbar(cx))
.when_some(
self.render_horizontal_scrollbar(window, cx),
|this, scrollbar| this.pb_4().child(scrollbar),
)
}
}

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};
@ -362,7 +363,7 @@ pub struct GitPanel {
tracked_staged_count: usize,
update_visible_entries_task: Task<()>,
width: Option<Pixels>,
workspace: WeakEntity<Workspace>,
pub(crate) workspace: WeakEntity<Workspace>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
modal_open: bool,
show_placeholders: bool,
@ -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);
@ -4470,6 +4475,7 @@ impl Render for GitPanel {
}
})
.children(self.render_footer(window, cx))
.child(self.history.clone())
.when(self.amend_pending, |this| {
this.child(self.render_pending_amend(cx))
})

View file

@ -26,6 +26,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

@ -3401,6 +3401,41 @@ impl Repository {
})
}
pub fn git_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.git_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

@ -219,6 +219,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

@ -399,7 +399,10 @@ message Envelope {
GetDefaultBranchResponse get_default_branch_response = 360;
GetCrashFiles get_crash_files = 361;
GetCrashFilesResponse get_crash_files_response = 362; // current max
GetCrashFilesResponse get_crash_files_response = 362;
GitLog git_log = 363;
GitLogResponse git_log_response = 364; // current max
}
reserved 87 to 88;

View file

@ -289,6 +289,8 @@ messages!(
(GitReset, Background),
(GitCheckoutFiles, Background),
(GitShow, Background),
(GitLog, Background),
(GitLogResponse, Background),
(GitCommitDetails, Background),
(SetIndexText, Background),
(Push, Background),
@ -465,6 +467,7 @@ request_messages!(
(InstallExtension, Ack),
(RegisterBufferWithLanguageServers, Ack),
(GitShow, GitCommitDetails),
(GitLog, GitLogResponse),
(GitReset, Ack),
(GitCheckoutFiles, Ack),
(SetIndexText, Ack),
@ -594,6 +597,7 @@ entity_messages!(
CancelLanguageServerWork,
RegisterBufferWithLanguageServers,
GitShow,
GitLog,
GitReset,
GitCheckoutFiles,
SetIndexText,