Cargo.lock 🔗
@@ -7109,6 +7109,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"git",
+ "git_hosting_providers",
"gpui",
"indoc",
"itertools 0.14.0",
ozzy and cameron created
Closes #16827
Release Notes:
- Added: File history view accessible via right-click context menu on
files in the editor or project panel. Shows commit history for the
selected file with author, timestamp, and commit message. Clicking a
commit opens a diff view filtered to show only changes for that specific
file.
<img width="1293" height="834" alt="Screenshot 2025-11-11 at 16 31 32"
src="https://github.com/user-attachments/assets/3780d21b-a719-40b3-955c-d928c45a47cc"
/>
<img width="1283" height="836" alt="Screenshot 2025-11-11 at 16 31 24"
src="https://github.com/user-attachments/assets/1dc4e56b-b225-4ffa-a2af-c5dcfb2efaa0"
/>
---------
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Cargo.lock | 1
crates/editor/src/mouse_context_menu.rs | 3
crates/fs/src/fake_git_repo.rs | 19
crates/git/src/git.rs | 2
crates/git/src/repository.rs | 111 +++
crates/git_ui/Cargo.toml | 1
crates/git_ui/src/blame_ui.rs | 3
crates/git_ui/src/commit_tooltip.rs | 1
crates/git_ui/src/commit_view.rs | 847 ++++++++++++++++--------
crates/git_ui/src/file_history_view.rs | 629 ++++++++++++++++++
crates/git_ui/src/git_panel.rs | 1
crates/git_ui/src/git_ui.rs | 38 +
crates/git_ui/src/stash_picker.rs | 1
crates/project/src/git_store.rs | 108 +++
crates/project_panel/src/project_panel.rs | 84 ++
crates/proto/proto/git.proto | 23
crates/proto/proto/zed.proto | 11
crates/proto/src/proto.rs | 4
crates/zed/src/zed.rs | 2
19 files changed, 1,579 insertions(+), 310 deletions(-)
@@ -7109,6 +7109,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"git",
+ "git_hosting_providers",
"gpui",
"indoc",
"itertools 0.14.0",
@@ -276,7 +276,8 @@ pub fn deploy_context_menu(
!has_git_repo,
"Copy Permalink",
Box::new(CopyPermalinkToLine),
- );
+ )
+ .action_disabled_when(!has_git_repo, "File History", Box::new(git::FileHistory));
match focus {
Some(focus) => builder.context(focus),
None => builder,
@@ -446,6 +446,25 @@ impl GitRepository for FakeGitRepository {
})
}
+ fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
+ self.file_history_paginated(path, 0, None)
+ }
+
+ fn file_history_paginated(
+ &self,
+ path: RepoPath,
+ _skip: usize,
+ _limit: Option<usize>,
+ ) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
+ async move {
+ Ok(git::repository::FileHistory {
+ entries: Vec::new(),
+ path,
+ })
+ }
+ .boxed()
+ }
+
fn stage_paths(
&self,
paths: Vec<RepoPath>,
@@ -43,6 +43,8 @@ actions!(
/// Shows git blame information for the current file.
#[action(deprecated_aliases = ["editor::ToggleGitBlame"])]
Blame,
+ /// Shows the git history for the current file.
+ FileHistory,
/// Stages the current file.
StageFile,
/// Unstages the current file.
@@ -207,6 +207,22 @@ pub struct CommitDetails {
pub author_name: SharedString,
}
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct FileHistoryEntry {
+ pub sha: SharedString,
+ pub subject: SharedString,
+ pub message: SharedString,
+ pub commit_timestamp: i64,
+ pub author_name: SharedString,
+ pub author_email: SharedString,
+}
+
+#[derive(Debug, Clone)]
+pub struct FileHistory {
+ pub entries: Vec<FileHistoryEntry>,
+ pub path: RepoPath,
+}
+
#[derive(Debug)]
pub struct CommitDiff {
pub files: Vec<CommitFile>,
@@ -464,6 +480,13 @@ pub trait GitRepository: Send + Sync {
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
+ fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
+ fn file_history_paginated(
+ &self,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ ) -> BoxFuture<'_, Result<FileHistory>>;
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
/// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
@@ -1452,6 +1475,94 @@ impl GitRepository for RealGitRepository {
.boxed()
}
+ fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
+ self.file_history_paginated(path, 0, None)
+ }
+
+ fn file_history_paginated(
+ &self,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ ) -> BoxFuture<'_, Result<FileHistory>> {
+ let working_directory = self.working_directory();
+ let git_binary_path = self.any_git_binary_path.clone();
+ self.executor
+ .spawn(async move {
+ let working_directory = working_directory?;
+ // Use a unique delimiter with a hardcoded UUID to separate commits
+ // This essentially eliminates any chance of encountering the delimiter in actual commit data
+ let commit_delimiter =
+ concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
+
+ let format_string = format!(
+ "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
+ commit_delimiter
+ );
+
+ let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
+
+ let skip_str;
+ let limit_str;
+ if skip > 0 {
+ skip_str = skip.to_string();
+ args.push("--skip");
+ args.push(&skip_str);
+ }
+ if let Some(n) = limit {
+ limit_str = n.to_string();
+ args.push("-n");
+ args.push(&limit_str);
+ }
+
+ args.push("--");
+
+ let output = new_smol_command(&git_binary_path)
+ .current_dir(&working_directory)
+ .args(&args)
+ .arg(path.as_unix_str())
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ bail!("git log failed: {stderr}");
+ }
+
+ let stdout = std::str::from_utf8(&output.stdout)?;
+ let mut entries = Vec::new();
+
+ for commit_block in stdout.split(commit_delimiter) {
+ let commit_block = commit_block.trim();
+ if commit_block.is_empty() {
+ continue;
+ }
+
+ let fields: Vec<&str> = commit_block.split('\0').collect();
+ if fields.len() >= 6 {
+ let sha = fields[0].trim().to_string().into();
+ let subject = fields[1].trim().to_string().into();
+ let message = fields[2].trim().to_string().into();
+ let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
+ let author_name = fields[4].trim().to_string().into();
+ let author_email = fields[5].trim().to_string().into();
+
+ entries.push(FileHistoryEntry {
+ sha,
+ subject,
+ message,
+ commit_timestamp,
+ author_name,
+ author_email,
+ });
+ }
+ }
+
+ Ok(FileHistory { entries, path })
+ })
+ .boxed()
+ }
+
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
let working_directory = self.working_directory();
let git_binary_path = self.any_git_binary_path.clone();
@@ -69,6 +69,7 @@ windows.workspace = true
[dev-dependencies]
ctor.workspace = true
editor = { workspace = true, features = ["test-support"] }
+git_hosting_providers.workspace = true
gpui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
pretty_assertions.workspace = true
@@ -101,6 +101,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
)
@@ -325,6 +326,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -365,6 +367,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace,
None,
+ None,
window,
cx,
)
@@ -323,6 +323,7 @@ impl Render for CommitTooltip {
repo.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -1,29 +1,31 @@
use anyhow::{Context as _, Result};
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
-use editor::{
- Editor, EditorEvent, MultiBuffer, MultiBufferOffset, SelectionEffects,
- multibuffer_context_lines,
-};
+use editor::{Addon, Editor, EditorEvent, MultiBuffer};
use git::repository::{CommitDetails, CommitDiff, RepoPath};
+use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
use gpui::{
- Action, AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Entity,
- EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, Task, WeakEntity,
- Window, actions,
+ AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element,
+ Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
+ PromptLevel, Render, Styled, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
+ actions, px,
};
use language::{
- Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
- Point, ReplicaId, Rope, TextBuffer,
+ Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer,
+ ToPoint,
};
+use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use multi_buffer::ExcerptInfo;
use multi_buffer::PathKey;
use project::{Project, WorktreeId, git_store::Repository};
use std::{
any::{Any, TypeId},
- fmt::Write as _,
path::PathBuf,
sync::Arc,
};
+use theme::ActiveTheme;
use ui::{
- Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*,
+ Avatar, Button, ButtonCommon, Clickable, Color, Icon, IconName, IconSize, Label,
+ LabelCommon as _, LabelSize, SharedString, div, h_flex, v_flex,
};
use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
use workspace::{
@@ -41,14 +43,14 @@ actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
- register_workspace_action(workspace, |toolbar, _: &ApplyCurrentStash, window, cx| {
- toolbar.apply_stash(window, cx);
+ workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| {
+ CommitView::apply_stash(workspace, window, cx);
});
- register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| {
- toolbar.remove_stash(window, cx);
+ workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| {
+ CommitView::remove_stash(workspace, window, cx);
});
- register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| {
- toolbar.pop_stash(window, cx);
+ workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| {
+ CommitView::pop_stash(workspace, window, cx);
});
})
.detach();
@@ -59,6 +61,9 @@ pub struct CommitView {
editor: Entity<Editor>,
stash: Option<usize>,
multibuffer: Entity<MultiBuffer>,
+ repository: Entity<Repository>,
+ remote: Option<GitRemote>,
+ markdown: Entity<Markdown>,
}
struct GitBlob {
@@ -67,12 +72,6 @@ struct GitBlob {
is_deleted: bool,
}
-struct CommitMetadataFile {
- title: Arc<RelPath>,
- worktree_id: WorktreeId,
-}
-
-const COMMIT_METADATA_SORT_PREFIX: u64 = 0;
const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
impl CommitView {
@@ -81,6 +80,7 @@ impl CommitView {
repo: WeakEntity<Repository>,
workspace: WeakEntity<Workspace>,
stash: Option<usize>,
+ file_filter: Option<RepoPath>,
window: &mut Window,
cx: &mut App,
) {
@@ -94,8 +94,14 @@ impl CommitView {
window
.spawn(cx, async move |cx| {
let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?);
- let commit_diff = commit_diff.log_err()?.log_err()?;
+ let mut commit_diff = commit_diff.log_err()?.log_err()?;
let commit_details = commit_details.log_err()?.log_err()?;
+
+ // Filter to specific file if requested
+ if let Some(ref filter_path) = file_filter {
+ commit_diff.files.retain(|f| &f.path == filter_path);
+ }
+
let repo = repo.upgrade()?;
workspace
@@ -148,6 +154,9 @@ impl CommitView {
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
editor.disable_inline_diagnostics();
editor.set_expand_all_diff_hunks(cx);
+ editor.register_addon(CommitViewAddon {
+ multibuffer: multibuffer.downgrade(),
+ });
editor
});
@@ -157,50 +166,13 @@ impl CommitView {
.next()
.map(|worktree| worktree.read(cx).id());
- let mut metadata_buffer_id = None;
- if let Some(worktree_id) = first_worktree_id {
- let title = if let Some(stash) = stash {
- format!("stash@{{{}}}", stash)
- } else {
- format!("commit {}", commit.sha)
- };
- let file = Arc::new(CommitMetadataFile {
- title: RelPath::unix(&title).unwrap().into(),
- worktree_id,
- });
- let buffer = cx.new(|cx| {
- let buffer = TextBuffer::new_normalized(
- ReplicaId::LOCAL,
- cx.entity_id().as_non_zero_u64().into(),
- LineEnding::default(),
- format_commit(&commit, stash.is_some()).into(),
- );
- metadata_buffer_id = Some(buffer.remote_id());
- Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite)
- });
- multibuffer.update(cx, |multibuffer, cx| {
- multibuffer.set_excerpts_for_path(
- PathKey::with_sort_prefix(COMMIT_METADATA_SORT_PREFIX, file.title.clone()),
- buffer.clone(),
- vec![Point::zero()..buffer.read(cx).max_point()],
- 0,
- cx,
- );
- });
- editor.update(cx, |editor, cx| {
- editor.disable_header_for_buffer(metadata_buffer_id.unwrap(), cx);
- editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
- selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(0)]);
- });
- });
- }
-
+ let repository_clone = repository.clone();
cx.spawn(async move |this, cx| {
for file in commit_diff.files {
let is_deleted = file.new_text.is_none();
let new_text = file.new_text.unwrap_or_default();
let old_text = file.old_text;
- let worktree_id = repository
+ let worktree_id = repository_clone
.update(cx, |repository, cx| {
repository
.repo_path_to_project_path(&file.path, cx)
@@ -221,21 +193,34 @@ impl CommitView {
this.update(cx, |this, cx| {
this.multibuffer.update(cx, |multibuffer, cx| {
let snapshot = buffer.read(cx).snapshot();
- let diff = buffer_diff.read(cx);
- let diff_hunk_ranges = diff
- .hunks_intersecting_range(
- Anchor::min_max_range_for_buffer(diff.buffer_id),
- &snapshot,
- cx,
- )
- .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
- .collect::<Vec<_>>();
let path = snapshot.file().unwrap().path().clone();
+
+ let hunks: Vec<_> = buffer_diff.read(cx).hunks(&snapshot, cx).collect();
+
+ let excerpt_ranges = if hunks.is_empty() {
+ vec![language::Point::zero()..snapshot.max_point()]
+ } else {
+ hunks
+ .into_iter()
+ .map(|hunk| {
+ let start = hunk.range.start.max(language::Point::new(
+ hunk.range.start.row.saturating_sub(3),
+ 0,
+ ));
+ let end_row =
+ (hunk.range.end.row + 3).min(snapshot.max_point().row);
+ let end =
+ language::Point::new(end_row, snapshot.line_len(end_row));
+ start..end
+ })
+ .collect()
+ };
+
let _is_newly_added = multibuffer.set_excerpts_for_path(
PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
buffer,
- diff_hunk_ranges,
- multibuffer_context_lines(cx),
+ excerpt_ranges,
+ 0,
cx,
);
multibuffer.add_diff(buffer_diff, cx);
@@ -246,64 +231,409 @@ impl CommitView {
})
.detach();
+ let snapshot = repository.read(cx).snapshot();
+ let remote_url = snapshot
+ .remote_upstream_url
+ .as_ref()
+ .or(snapshot.remote_origin_url.as_ref());
+
+ let remote = remote_url.and_then(|url| {
+ let provider_registry = GitHostingProviderRegistry::default_global(cx);
+ parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
+ host,
+ owner: parsed.owner.into(),
+ repo: parsed.repo.into(),
+ })
+ });
+
+ let processed_message = if let Some(ref remote) = remote {
+ Self::process_github_issues(&commit.message, remote)
+ } else {
+ commit.message.to_string()
+ };
+
+ let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx));
+
Self {
commit,
editor,
multibuffer,
stash,
+ repository,
+ remote,
+ markdown,
}
}
-}
-impl language::File for GitBlob {
- fn as_local(&self) -> Option<&dyn language::LocalFile> {
- None
+ fn fallback_commit_avatar() -> AnyElement {
+ Icon::new(IconName::Person)
+ .color(Color::Muted)
+ .size(IconSize::Medium)
+ .into_element()
+ .into_any()
}
- fn disk_state(&self) -> DiskState {
- if self.is_deleted {
- DiskState::Deleted
+ fn render_commit_avatar(
+ &self,
+ sha: &SharedString,
+ size: impl Into<gpui::AbsoluteLength>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
+
+ if let Some(remote) = remote {
+ let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());
+ if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
+ Avatar::new(url.to_string())
+ .size(size)
+ .into_element()
+ .into_any()
+ } else {
+ Self::fallback_commit_avatar()
+ }
} else {
- DiskState::New
+ Self::fallback_commit_avatar()
}
}
- fn path_style(&self, _: &App) -> PathStyle {
- PathStyle::Posix
+ fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let commit = &self.commit;
+ let author_name = commit.author_name.clone();
+ let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
+ .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
+ let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
+ let date_string = time_format::format_localized_timestamp(
+ commit_date,
+ time::OffsetDateTime::now_utc(),
+ local_offset,
+ time_format::TimestampFormat::MediumAbsolute,
+ );
+
+ let github_url = self.remote.as_ref().map(|remote| {
+ format!(
+ "{}/{}/{}/commit/{}",
+ remote.host.base_url(),
+ remote.owner,
+ remote.repo,
+ commit.sha
+ )
+ });
+
+ v_flex()
+ .p_4()
+ .gap_4()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ h_flex()
+ .items_start()
+ .gap_3()
+ .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx))
+ .child(
+ v_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_3()
+ .items_baseline()
+ .child(Label::new(author_name).color(Color::Default))
+ .child(
+ Label::new(format!("commit {}", commit.sha))
+ .color(Color::Muted),
+ ),
+ )
+ .child(Label::new(date_string).color(Color::Muted)),
+ )
+ .child(div().flex_grow())
+ .children(github_url.map(|url| {
+ Button::new("view_on_github", "View on GitHub")
+ .icon(IconName::Github)
+ .style(ui::ButtonStyle::Subtle)
+ .on_click(move |_, _, cx| cx.open_url(&url))
+ })),
+ )
+ .child(self.render_commit_message(window, cx))
}
- fn path(&self) -> &Arc<RelPath> {
- self.path.as_ref()
+ fn process_github_issues(message: &str, remote: &GitRemote) -> String {
+ let mut result = String::new();
+ let chars: Vec<char> = message.chars().collect();
+ let mut i = 0;
+
+ while i < chars.len() {
+ if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
+ let mut j = i + 1;
+ while j < chars.len() && chars[j].is_ascii_digit() {
+ j += 1;
+ }
+ let issue_number = &message[i + 1..i + (j - i)];
+ let url = format!(
+ "{}/{}/{}/issues/{}",
+ remote.host.base_url().as_str().trim_end_matches('/'),
+ remote.owner,
+ remote.repo,
+ issue_number
+ );
+ result.push_str(&format!("[#{}]({})", issue_number, url));
+ i = j;
+ } else if i + 3 < chars.len()
+ && chars[i] == 'G'
+ && chars[i + 1] == 'H'
+ && chars[i + 2] == '-'
+ && chars[i + 3].is_ascii_digit()
+ {
+ let mut j = i + 3;
+ while j < chars.len() && chars[j].is_ascii_digit() {
+ j += 1;
+ }
+ let issue_number = &message[i + 3..i + (j - i)];
+ let url = format!(
+ "{}/{}/{}/issues/{}",
+ remote.host.base_url().as_str().trim_end_matches('/'),
+ remote.owner,
+ remote.repo,
+ issue_number
+ );
+ result.push_str(&format!("[GH-{}]({})", issue_number, url));
+ i = j;
+ } else {
+ result.push(chars[i]);
+ i += 1;
+ }
+ }
+
+ result
}
- fn full_path(&self, _: &App) -> PathBuf {
- self.path.as_std_path().to_path_buf()
+ fn render_commit_message(
+ &self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
+ let style = hover_markdown_style(window, cx);
+ MarkdownElement::new(self.markdown.clone(), style)
}
- fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
- self.path.file_name().unwrap()
+ fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
+ Self::stash_action(
+ workspace,
+ "Apply",
+ window,
+ cx,
+ async move |repository, sha, stash, commit_view, workspace, cx| {
+ let result = repository.update(cx, |repo, cx| {
+ if !stash_matches_index(&sha, stash, repo) {
+ return Err(anyhow::anyhow!("Stash has changed, not applying"));
+ }
+ Ok(repo.stash_apply(Some(stash), cx))
+ })?;
+
+ match result {
+ Ok(task) => task.await?,
+ Err(err) => {
+ Self::close_commit_view(commit_view, workspace, cx).await?;
+ return Err(err);
+ }
+ };
+ Self::close_commit_view(commit_view, workspace, cx).await?;
+ anyhow::Ok(())
+ },
+ );
}
- fn worktree_id(&self, _: &App) -> WorktreeId {
- self.worktree_id
+ fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
+ Self::stash_action(
+ workspace,
+ "Pop",
+ window,
+ cx,
+ async move |repository, sha, stash, commit_view, workspace, cx| {
+ let result = repository.update(cx, |repo, cx| {
+ if !stash_matches_index(&sha, stash, repo) {
+ return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
+ }
+ Ok(repo.stash_pop(Some(stash), cx))
+ })?;
+
+ match result {
+ Ok(task) => task.await?,
+ Err(err) => {
+ Self::close_commit_view(commit_view, workspace, cx).await?;
+ return Err(err);
+ }
+ };
+ Self::close_commit_view(commit_view, workspace, cx).await?;
+ anyhow::Ok(())
+ },
+ );
}
- fn to_proto(&self, _cx: &App) -> language::proto::File {
- unimplemented!()
+ fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
+ Self::stash_action(
+ workspace,
+ "Drop",
+ window,
+ cx,
+ async move |repository, sha, stash, commit_view, workspace, cx| {
+ let result = repository.update(cx, |repo, cx| {
+ if !stash_matches_index(&sha, stash, repo) {
+ return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
+ }
+ Ok(repo.stash_drop(Some(stash), cx))
+ })?;
+
+ match result {
+ Ok(task) => task.await??,
+ Err(err) => {
+ Self::close_commit_view(commit_view, workspace, cx).await?;
+ return Err(err);
+ }
+ };
+ Self::close_commit_view(commit_view, workspace, cx).await?;
+ anyhow::Ok(())
+ },
+ );
}
- fn is_private(&self) -> bool {
- false
+ fn stash_action<AsyncFn>(
+ workspace: &mut Workspace,
+ str_action: &str,
+ window: &mut Window,
+ cx: &mut App,
+ callback: AsyncFn,
+ ) where
+ AsyncFn: AsyncFnOnce(
+ Entity<Repository>,
+ &SharedString,
+ usize,
+ Entity<CommitView>,
+ WeakEntity<Workspace>,
+ &mut AsyncWindowContext,
+ ) -> anyhow::Result<()>
+ + 'static,
+ {
+ let Some(commit_view) = workspace.active_item_as::<CommitView>(cx) else {
+ return;
+ };
+ let Some(stash) = commit_view.read(cx).stash else {
+ return;
+ };
+ let sha = commit_view.read(cx).commit.sha.clone();
+ let answer = window.prompt(
+ PromptLevel::Info,
+ &format!("{} stash@{{{}}}?", str_action, stash),
+ None,
+ &[str_action, "Cancel"],
+ cx,
+ );
+
+ let workspace_weak = workspace.weak_handle();
+ let commit_view_entity = commit_view;
+
+ window
+ .spawn(cx, async move |cx| {
+ if answer.await != Ok(0) {
+ return anyhow::Ok(());
+ }
+
+ let Some(workspace) = workspace_weak.upgrade() else {
+ return Ok(());
+ };
+
+ let repo = workspace.update(cx, |workspace, cx| {
+ workspace
+ .panel::<GitPanel>(cx)
+ .and_then(|p| p.read(cx).active_repository.clone())
+ })?;
+
+ let Some(repo) = repo else {
+ return Ok(());
+ };
+
+ callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
+ anyhow::Ok(())
+ })
+ .detach_and_notify_err(window, cx);
+ }
+
+ async fn close_commit_view(
+ commit_view: Entity<CommitView>,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut AsyncWindowContext,
+ ) -> anyhow::Result<()> {
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ let active_pane = workspace.active_pane();
+ let commit_view_id = commit_view.entity_id();
+ active_pane.update(cx, |pane, cx| {
+ pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
+ })
+ })?
+ .await?;
+ anyhow::Ok(())
+ }
+}
+
+#[derive(Clone, Debug)]
+struct CommitAvatarAsset {
+ sha: SharedString,
+ remote: GitRemote,
+}
+
+impl std::hash::Hash for CommitAvatarAsset {
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+ self.sha.hash(state);
+ self.remote.host.name().hash(state);
+ }
+}
+
+impl CommitAvatarAsset {
+ fn new(remote: GitRemote, sha: SharedString) -> Self {
+ Self { remote, sha }
+ }
+}
+
+impl Asset for CommitAvatarAsset {
+ type Source = Self;
+ type Output = Option<SharedString>;
+
+ fn load(
+ source: Self::Source,
+ cx: &mut App,
+ ) -> impl Future<Output = Self::Output> + Send + 'static {
+ let client = cx.http_client();
+ async move {
+ match source
+ .remote
+ .host
+ .commit_author_avatar_url(
+ &source.remote.owner,
+ &source.remote.repo,
+ source.sha.clone(),
+ client,
+ )
+ .await
+ {
+ Ok(Some(url)) => Some(SharedString::from(url.to_string())),
+ Ok(None) => None,
+ Err(_) => None,
+ }
+ }
}
}
-impl language::File for CommitMetadataFile {
+impl language::File for GitBlob {
fn as_local(&self) -> Option<&dyn language::LocalFile> {
None
}
fn disk_state(&self) -> DiskState {
- DiskState::New
+ if self.is_deleted {
+ DiskState::Deleted
+ } else {
+ DiskState::New
+ }
}
fn path_style(&self, _: &App) -> PathStyle {
@@ -311,22 +641,22 @@ impl language::File for CommitMetadataFile {
}
fn path(&self) -> &Arc<RelPath> {
- &self.title
+ self.path.as_ref()
}
fn full_path(&self, _: &App) -> PathBuf {
- PathBuf::from(self.title.as_unix_str().to_owned())
+ self.path.as_std_path().to_path_buf()
}
fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
- self.title.file_name().unwrap()
+ self.path.file_name().unwrap()
}
fn worktree_id(&self, _: &App) -> WorktreeId {
self.worktree_id
}
- fn to_proto(&self, _: &App) -> language::proto::File {
+ fn to_proto(&self, _cx: &App) -> language::proto::File {
unimplemented!()
}
@@ -335,6 +665,94 @@ impl language::File for CommitMetadataFile {
}
}
+// No longer needed since metadata buffer is not created
+// impl language::File for CommitMetadataFile {
+// fn as_local(&self) -> Option<&dyn language::LocalFile> {
+// None
+// }
+//
+// fn disk_state(&self) -> DiskState {
+// DiskState::New
+// }
+//
+// fn path_style(&self, _: &App) -> PathStyle {
+// PathStyle::Posix
+// }
+//
+// fn path(&self) -> &Arc<RelPath> {
+// &self.title
+// }
+//
+// fn full_path(&self, _: &App) -> PathBuf {
+// self.title.as_std_path().to_path_buf()
+// }
+//
+// fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
+// self.title.file_name().unwrap_or("commit")
+// }
+//
+// fn worktree_id(&self, _: &App) -> WorktreeId {
+// self.worktree_id
+// }
+//
+// fn to_proto(&self, _cx: &App) -> language::proto::File {
+// unimplemented!()
+// }
+//
+// fn is_private(&self) -> bool {
+// false
+// }
+// }
+
+struct CommitViewAddon {
+ multibuffer: WeakEntity<MultiBuffer>,
+}
+
+impl Addon for CommitViewAddon {
+ fn render_buffer_header_controls(
+ &self,
+ excerpt: &ExcerptInfo,
+ _window: &Window,
+ cx: &App,
+ ) -> Option<AnyElement> {
+ let multibuffer = self.multibuffer.upgrade()?;
+ let snapshot = multibuffer.read(cx).snapshot(cx);
+ let excerpts = snapshot.excerpts().collect::<Vec<_>>();
+ let current_idx = excerpts.iter().position(|(id, _, _)| *id == excerpt.id)?;
+ let (_, _, current_range) = &excerpts[current_idx];
+
+ let start_row = current_range.context.start.to_point(&excerpt.buffer).row;
+
+ let prev_end_row = if current_idx > 0 {
+ let (_, prev_buffer, prev_range) = &excerpts[current_idx - 1];
+ if prev_buffer.remote_id() == excerpt.buffer_id {
+ prev_range.context.end.to_point(&excerpt.buffer).row
+ } else {
+ 0
+ }
+ } else {
+ 0
+ };
+
+ let skipped_lines = start_row.saturating_sub(prev_end_row);
+
+ if skipped_lines > 0 {
+ Some(
+ Label::new(format!("{} unchanged lines", skipped_lines))
+ .color(Color::Muted)
+ .size(LabelSize::Small)
+ .into_any_element(),
+ )
+ } else {
+ None
+ }
+ }
+
+ fn to_any(&self) -> &dyn Any {
+ self
+ }
+}
+
async fn build_buffer(
mut text: String,
blob: Arc<dyn File>,
@@ -409,45 +827,6 @@ async fn build_buffer_diff(
})
}
-fn format_commit(commit: &CommitDetails, is_stash: bool) -> String {
- let mut result = String::new();
- if is_stash {
- writeln!(&mut result, "stash commit {}", commit.sha).unwrap();
- } else {
- writeln!(&mut result, "commit {}", commit.sha).unwrap();
- }
- writeln!(
- &mut result,
- "Author: {} <{}>",
- commit.author_name, commit.author_email
- )
- .unwrap();
- let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
- writeln!(
- &mut result,
- "Date: {}",
- time_format::format_localized_timestamp(
- time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(),
- time::OffsetDateTime::now_utc(),
- local_offset,
- time_format::TimestampFormat::MediumAbsolute,
- ),
- )
- .unwrap();
- result.push('\n');
- for line in commit.message.split('\n') {
- if line.is_empty() {
- result.push('\n');
- } else {
- writeln!(&mut result, " {}", line).unwrap();
- }
- }
- if result.ends_with("\n\n") {
- result.pop();
- }
- result
-}
-
impl EventEmitter<EditorEvent> for CommitView {}
impl Focusable for CommitView {
@@ -547,11 +926,11 @@ impl Item for CommitView {
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
- ToolbarItemLocation::PrimaryLeft
+ ToolbarItemLocation::Hidden
}
- fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
- self.editor.breadcrumbs(theme, cx)
+ fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option<Vec<BreadcrumbText>> {
+ None
}
fn added_to_workspace(
@@ -584,197 +963,57 @@ impl Item for CommitView {
.update(cx, |editor, cx| editor.clone(window, cx))
});
let multibuffer = editor.read(cx).buffer().clone();
+ let processed_message = if let Some(ref remote) = self.remote {
+ Self::process_github_issues(&self.commit.message, remote)
+ } else {
+ self.commit.message.to_string()
+ };
+ let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx));
Self {
editor,
multibuffer,
commit: self.commit.clone(),
stash: self.stash,
+ repository: self.repository.clone(),
+ remote: self.remote.clone(),
+ markdown,
}
})))
}
}
impl Render for CommitView {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_stash = self.stash.is_some();
div()
.key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
.bg(cx.theme().colors().editor_background)
.flex()
- .items_center()
- .justify_center()
+ .flex_col()
.size_full()
- .child(self.editor.clone())
+ .child(self.render_header(window, cx))
+ .child(div().flex_grow().child(self.editor.clone()))
}
}
pub struct CommitViewToolbar {
commit_view: Option<WeakEntity<CommitView>>,
- workspace: WeakEntity<Workspace>,
}
impl CommitViewToolbar {
- pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
- Self {
- commit_view: None,
- workspace: workspace.weak_handle(),
- }
- }
-
- fn commit_view(&self, _: &App) -> Option<Entity<CommitView>> {
- self.commit_view.as_ref()?.upgrade()
- }
-
- async fn close_commit_view(
- commit_view: Entity<CommitView>,
- workspace: WeakEntity<Workspace>,
- cx: &mut AsyncWindowContext,
- ) -> anyhow::Result<()> {
- workspace
- .update_in(cx, |workspace, window, cx| {
- let active_pane = workspace.active_pane();
- let commit_view_id = commit_view.entity_id();
- active_pane.update(cx, |pane, cx| {
- pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
- })
- })?
- .await?;
- anyhow::Ok(())
- }
-
- fn apply_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.stash_action(
- "Apply",
- window,
- cx,
- async move |repository, sha, stash, commit_view, workspace, cx| {
- let result = repository.update(cx, |repo, cx| {
- if !stash_matches_index(&sha, stash, repo) {
- return Err(anyhow::anyhow!("Stash has changed, not applying"));
- }
- Ok(repo.stash_apply(Some(stash), cx))
- })?;
-
- match result {
- Ok(task) => task.await?,
- Err(err) => {
- Self::close_commit_view(commit_view, workspace, cx).await?;
- return Err(err);
- }
- };
- Self::close_commit_view(commit_view, workspace, cx).await?;
- anyhow::Ok(())
- },
- );
- }
-
- fn pop_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.stash_action(
- "Pop",
- window,
- cx,
- async move |repository, sha, stash, commit_view, workspace, cx| {
- let result = repository.update(cx, |repo, cx| {
- if !stash_matches_index(&sha, stash, repo) {
- return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
- }
- Ok(repo.stash_pop(Some(stash), cx))
- })?;
-
- match result {
- Ok(task) => task.await?,
- Err(err) => {
- Self::close_commit_view(commit_view, workspace, cx).await?;
- return Err(err);
- }
- };
- Self::close_commit_view(commit_view, workspace, cx).await?;
- anyhow::Ok(())
- },
- );
+ pub fn new() -> Self {
+ Self { commit_view: None }
}
+}
- fn remove_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.stash_action(
- "Drop",
- window,
- cx,
- async move |repository, sha, stash, commit_view, workspace, cx| {
- let result = repository.update(cx, |repo, cx| {
- if !stash_matches_index(&sha, stash, repo) {
- return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
- }
- Ok(repo.stash_drop(Some(stash), cx))
- })?;
-
- match result {
- Ok(task) => task.await??,
- Err(err) => {
- Self::close_commit_view(commit_view, workspace, cx).await?;
- return Err(err);
- }
- };
- Self::close_commit_view(commit_view, workspace, cx).await?;
- anyhow::Ok(())
- },
- );
- }
-
- fn stash_action<AsyncFn>(
- &mut self,
- str_action: &str,
- window: &mut Window,
- cx: &mut Context<Self>,
- callback: AsyncFn,
- ) where
- AsyncFn: AsyncFnOnce(
- Entity<Repository>,
- &SharedString,
- usize,
- Entity<CommitView>,
- WeakEntity<Workspace>,
- &mut AsyncWindowContext,
- ) -> anyhow::Result<()>
- + 'static,
- {
- let Some(commit_view) = self.commit_view(cx) else {
- return;
- };
- let Some(stash) = commit_view.read(cx).stash else {
- return;
- };
- let sha = commit_view.read(cx).commit.sha.clone();
- let answer = window.prompt(
- PromptLevel::Info,
- &format!("{} stash@{{{}}}?", str_action, stash),
- None,
- &[str_action, "Cancel"],
- cx,
- );
-
- let workspace = self.workspace.clone();
- cx.spawn_in(window, async move |_, cx| {
- if answer.await != Ok(0) {
- return anyhow::Ok(());
- }
- let repo = workspace.update(cx, |workspace, cx| {
- workspace
- .panel::<GitPanel>(cx)
- .and_then(|p| p.read(cx).active_repository.clone())
- })?;
+impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
- let Some(repo) = repo else {
- return Ok(());
- };
- callback(repo, &sha, stash, commit_view, workspace, cx).await?;
- anyhow::Ok(())
- })
- .detach_and_notify_err(window, cx);
+impl Render for CommitViewToolbar {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+ div()
}
}
-impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
-
impl ToolbarItemView for CommitViewToolbar {
fn set_active_pane_item(
&mut self,
@@ -0,0 +1,629 @@
+use anyhow::Result;
+use futures::Future;
+use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
+use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
+use gpui::{
+ AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable,
+ IntoElement, ListSizingBehavior, Render, Task, UniformListScrollHandle, WeakEntity, Window,
+ actions, rems, uniform_list,
+};
+use project::{
+ Project, ProjectPath,
+ git_store::{GitStore, Repository},
+};
+use std::any::{Any, TypeId};
+
+use time::OffsetDateTime;
+use ui::{
+ Avatar, Button, ButtonStyle, Color, Icon, IconName, IconSize, Label, LabelCommon as _,
+ LabelSize, SharedString, prelude::*,
+};
+use util::{ResultExt, truncate_and_trailoff};
+use workspace::{
+ Item, Workspace,
+ item::{ItemEvent, SaveOptions},
+};
+
+use crate::commit_view::CommitView;
+
+actions!(git, [ViewCommitFromHistory, LoadMoreHistory]);
+
+pub fn init(cx: &mut App) {
+ cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+ workspace.register_action(|_workspace, _: &ViewCommitFromHistory, _window, _cx| {});
+ workspace.register_action(|_workspace, _: &LoadMoreHistory, _window, _cx| {});
+ })
+ .detach();
+}
+
+const PAGE_SIZE: usize = 50;
+
+pub struct FileHistoryView {
+ history: FileHistory,
+ repository: WeakEntity<Repository>,
+ git_store: WeakEntity<GitStore>,
+ workspace: WeakEntity<Workspace>,
+ remote: Option<GitRemote>,
+ selected_entry: Option<usize>,
+ scroll_handle: UniformListScrollHandle,
+ focus_handle: FocusHandle,
+ loading_more: bool,
+ has_more: bool,
+}
+
+impl FileHistoryView {
+ pub fn open(
+ path: RepoPath,
+ git_store: WeakEntity<GitStore>,
+ repo: WeakEntity<Repository>,
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let file_history_task = git_store
+ .update(cx, |git_store, cx| {
+ repo.upgrade().map(|repo| {
+ git_store.file_history_paginated(&repo, path.clone(), 0, Some(PAGE_SIZE), cx)
+ })
+ })
+ .ok()
+ .flatten();
+
+ window
+ .spawn(cx, async move |cx| {
+ let file_history = file_history_task?.await.log_err()?;
+ let repo = repo.upgrade()?;
+
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ let project = workspace.project();
+ let view = cx.new(|cx| {
+ FileHistoryView::new(
+ file_history,
+ git_store.clone(),
+ repo.clone(),
+ workspace.weak_handle(),
+ project.clone(),
+ window,
+ cx,
+ )
+ });
+
+ let pane = workspace.active_pane();
+ pane.update(cx, |pane, cx| {
+ let ix = pane.items().position(|item| {
+ let view = item.downcast::<FileHistoryView>();
+ view.is_some_and(|v| v.read(cx).history.path == path)
+ });
+ if let Some(ix) = ix {
+ pane.activate_item(ix, true, true, window, cx);
+ } else {
+ pane.add_item(Box::new(view), true, true, None, window, cx);
+ }
+ })
+ })
+ .log_err()
+ })
+ .detach();
+ }
+
+ fn new(
+ history: FileHistory,
+ git_store: WeakEntity<GitStore>,
+ repository: Entity<Repository>,
+ workspace: WeakEntity<Workspace>,
+ _project: Entity<Project>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let focus_handle = cx.focus_handle();
+ let scroll_handle = UniformListScrollHandle::new();
+ let has_more = history.entries.len() >= PAGE_SIZE;
+
+ let snapshot = repository.read(cx).snapshot();
+ let remote_url = snapshot
+ .remote_upstream_url
+ .as_ref()
+ .or(snapshot.remote_origin_url.as_ref());
+
+ let remote = remote_url.and_then(|url| {
+ let provider_registry = GitHostingProviderRegistry::default_global(cx);
+ parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
+ host,
+ owner: parsed.owner.into(),
+ repo: parsed.repo.into(),
+ })
+ });
+
+ Self {
+ history,
+ git_store,
+ repository: repository.downgrade(),
+ workspace,
+ remote,
+ selected_entry: None,
+ scroll_handle,
+ focus_handle,
+ loading_more: false,
+ has_more,
+ }
+ }
+
+ fn load_more(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if self.loading_more || !self.has_more {
+ return;
+ }
+
+ self.loading_more = true;
+ cx.notify();
+
+ let current_count = self.history.entries.len();
+ let path = self.history.path.clone();
+ let git_store = self.git_store.clone();
+ let repo = self.repository.clone();
+
+ let this = cx.weak_entity();
+ let task = window.spawn(cx, async move |cx| {
+ let file_history_task = git_store
+ .update(cx, |git_store, cx| {
+ repo.upgrade().map(|repo| {
+ git_store.file_history_paginated(
+ &repo,
+ path,
+ current_count,
+ Some(PAGE_SIZE),
+ cx,
+ )
+ })
+ })
+ .ok()
+ .flatten();
+
+ if let Some(task) = file_history_task {
+ if let Ok(more_history) = task.await {
+ this.update(cx, |this, cx| {
+ this.loading_more = false;
+ this.has_more = more_history.entries.len() >= PAGE_SIZE;
+ this.history.entries.extend(more_history.entries);
+ cx.notify();
+ })
+ .ok();
+ }
+ }
+ });
+
+ task.detach();
+ }
+
+ fn list_item_height(&self) -> Rems {
+ rems(2.0)
+ }
+
+ fn fallback_commit_avatar() -> AnyElement {
+ Icon::new(IconName::Person)
+ .color(Color::Muted)
+ .size(IconSize::Small)
+ .into_element()
+ .into_any()
+ }
+
+ fn render_commit_avatar(
+ &self,
+ sha: &SharedString,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> AnyElement {
+ let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
+
+ if let Some(remote) = remote {
+ let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());
+ if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
+ Avatar::new(url.to_string())
+ .size(rems(1.25))
+ .into_element()
+ .into_any()
+ } else {
+ Self::fallback_commit_avatar()
+ }
+ } else {
+ Self::fallback_commit_avatar()
+ }
+ }
+
+ fn render_commit_entry(
+ &self,
+ ix: usize,
+ entry: &FileHistoryEntry,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let pr_number = entry
+ .subject
+ .rfind("(#")
+ .and_then(|start| {
+ let rest = &entry.subject[start + 2..];
+ rest.find(')')
+ .and_then(|end| rest[..end].parse::<u32>().ok())
+ })
+ .map(|num| format!("#{}", num))
+ .unwrap_or_else(|| {
+ if entry.sha.len() >= 7 {
+ entry.sha[..7].to_string()
+ } else {
+ entry.sha.to_string()
+ }
+ });
+
+ let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp)
+ .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH);
+ let relative_timestamp = time_format::format_localized_timestamp(
+ commit_time,
+ OffsetDateTime::now_utc(),
+ time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
+ time_format::TimestampFormat::Relative,
+ );
+
+ let selected = self.selected_entry == Some(ix);
+ let sha = entry.sha.clone();
+ let repo = self.repository.clone();
+ let workspace = self.workspace.clone();
+ let file_path = self.history.path.clone();
+
+ let base_bg = if selected {
+ cx.theme().status().info.alpha(0.1)
+ } else {
+ cx.theme().colors().editor_background
+ };
+
+ let hover_bg = if selected {
+ cx.theme().status().info.alpha(0.15)
+ } else {
+ cx.theme().colors().element_hover
+ };
+
+ h_flex()
+ .id(("commit", ix))
+ .h(self.list_item_height())
+ .w_full()
+ .items_center()
+ .px(rems(0.75))
+ .gap_2()
+ .bg(base_bg)
+ .hover(|style| style.bg(hover_bg))
+ .cursor_pointer()
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.selected_entry = Some(ix);
+ cx.notify();
+
+ if let Some(repo) = repo.upgrade() {
+ let sha_str = sha.to_string();
+ CommitView::open(
+ sha_str,
+ repo.downgrade(),
+ workspace.clone(),
+ None,
+ Some(file_path.clone()),
+ window,
+ cx,
+ );
+ }
+ }))
+ .child(
+ div().flex_none().min_w(rems(4.0)).child(
+ div()
+ .px(rems(0.5))
+ .py(rems(0.25))
+ .rounded_md()
+ .bg(cx.theme().colors().element_background)
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Label::new(pr_number)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .single_line(),
+ ),
+ ),
+ )
+ .child(
+ div()
+ .flex_none()
+ .w(rems(1.75))
+ .child(self.render_commit_avatar(&entry.sha, window, cx)),
+ )
+ .child(
+ div().flex_1().overflow_hidden().child(
+ h_flex()
+ .gap_3()
+ .items_center()
+ .child(
+ Label::new(entry.author_name.clone())
+ .size(LabelSize::Small)
+ .color(Color::Default)
+ .single_line(),
+ )
+ .child(
+ Label::new(truncate_and_trailoff(&entry.subject, 100))
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .single_line(),
+ ),
+ ),
+ )
+ .child(
+ div().flex_none().child(
+ Label::new(relative_timestamp)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .single_line(),
+ ),
+ )
+ .into_any_element()
+ }
+}
+
+#[derive(Clone, Debug)]
+struct CommitAvatarAsset {
+ sha: SharedString,
+ remote: GitRemote,
+}
+
+impl std::hash::Hash for CommitAvatarAsset {
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+ self.sha.hash(state);
+ self.remote.host.name().hash(state);
+ }
+}
+
+impl CommitAvatarAsset {
+ fn new(remote: GitRemote, sha: SharedString) -> Self {
+ Self { remote, sha }
+ }
+}
+
+impl Asset for CommitAvatarAsset {
+ type Source = Self;
+ type Output = Option<SharedString>;
+
+ fn load(
+ source: Self::Source,
+ cx: &mut App,
+ ) -> impl Future<Output = Self::Output> + Send + 'static {
+ let client = cx.http_client();
+ async move {
+ match source
+ .remote
+ .host
+ .commit_author_avatar_url(
+ &source.remote.owner,
+ &source.remote.repo,
+ source.sha.clone(),
+ client,
+ )
+ .await
+ {
+ Ok(Some(url)) => Some(SharedString::from(url.to_string())),
+ Ok(None) => None,
+ Err(_) => None,
+ }
+ }
+ }
+}
+
+impl EventEmitter<ItemEvent> for FileHistoryView {}
+
+impl Focusable for FileHistoryView {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for FileHistoryView {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let _file_name = self.history.path.file_name().unwrap_or("File");
+ let entry_count = self.history.entries.len();
+
+ v_flex()
+ .size_full()
+ .child(
+ h_flex()
+ .px(rems(0.75))
+ .py(rems(0.5))
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .bg(cx.theme().colors().title_bar_background)
+ .items_center()
+ .justify_between()
+ .child(
+ h_flex().gap_2().items_center().child(
+ Label::new(format!("History: {}", self.history.path.as_unix_str()))
+ .size(LabelSize::Default),
+ ),
+ )
+ .child(
+ Label::new(format!("{} commits", entry_count))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child(
+ v_flex()
+ .flex_1()
+ .size_full()
+ .child({
+ let view = cx.weak_entity();
+ uniform_list(
+ "file-history-list",
+ entry_count,
+ move |range, window, cx| {
+ let Some(view) = view.upgrade() else {
+ return Vec::new();
+ };
+ view.update(cx, |this, cx| {
+ let mut items = Vec::with_capacity(range.end - range.start);
+ for ix in range {
+ if let Some(entry) = this.history.entries.get(ix) {
+ items.push(
+ this.render_commit_entry(ix, entry, window, cx),
+ );
+ }
+ }
+ items
+ })
+ },
+ )
+ .flex_1()
+ .size_full()
+ .with_sizing_behavior(ListSizingBehavior::Auto)
+ .track_scroll(&self.scroll_handle)
+ })
+ .when(self.has_more, |this| {
+ this.child(
+ div().p(rems(0.75)).flex().justify_start().child(
+ Button::new("load-more", "Load more")
+ .style(ButtonStyle::Subtle)
+ .disabled(self.loading_more)
+ .label_size(LabelSize::Small)
+ .icon(IconName::ArrowCircle)
+ .icon_position(IconPosition::Start)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.load_more(window, cx);
+ })),
+ ),
+ )
+ }),
+ )
+ }
+}
+
+impl Item for FileHistoryView {
+ type Event = ItemEvent;
+
+ fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
+ f(*event)
+ }
+
+ fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+ let file_name = self
+ .history
+ .path
+ .file_name()
+ .map(|name| name.to_string())
+ .unwrap_or_else(|| "File".to_string());
+ format!("History: {}", file_name).into()
+ }
+
+ fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
+ Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
+ }
+
+ fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+ Some(Icon::new(IconName::FileGit))
+ }
+
+ fn telemetry_event_text(&self) -> Option<&'static str> {
+ Some("file history")
+ }
+
+ fn clone_on_split(
+ &self,
+ _workspace_id: Option<workspace::WorkspaceId>,
+ _window: &mut Window,
+ _cx: &mut Context<Self>,
+ ) -> Task<Option<Entity<Self>>> {
+ Task::ready(None)
+ }
+
+ fn navigate(&mut self, _: Box<dyn Any>, _window: &mut Window, _: &mut Context<Self>) -> bool {
+ false
+ }
+
+ fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
+
+ fn can_save(&self, _: &App) -> bool {
+ false
+ }
+
+ fn save(
+ &mut self,
+ _options: SaveOptions,
+ _project: Entity<Project>,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn save_as(
+ &mut self,
+ _project: Entity<Project>,
+ _path: ProjectPath,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn reload(
+ &mut self,
+ _project: Entity<Project>,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn is_dirty(&self, _: &App) -> bool {
+ false
+ }
+
+ fn has_conflict(&self, _: &App) -> bool {
+ false
+ }
+
+ fn breadcrumbs(
+ &self,
+ _theme: &theme::Theme,
+ _cx: &App,
+ ) -> Option<Vec<workspace::item::BreadcrumbText>> {
+ None
+ }
+
+ fn added_to_workspace(
+ &mut self,
+ _workspace: &mut Workspace,
+ window: &mut Window,
+ _cx: &mut Context<Self>,
+ ) {
+ window.focus(&self.focus_handle);
+ }
+
+ fn show_toolbar(&self) -> bool {
+ true
+ }
+
+ fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
+ None
+ }
+
+ fn set_nav_history(
+ &mut self,
+ _: workspace::ItemNavHistory,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) {
+ }
+
+ fn act_as_type<'a>(
+ &'a self,
+ type_id: TypeId,
+ self_handle: &'a Entity<Self>,
+ _: &'a App,
+ ) -> Option<AnyEntity> {
+ if type_id == TypeId::of::<Self>() {
+ Some(self_handle.clone().into())
+ } else {
+ None
+ }
+ }
+}
@@ -3698,6 +3698,7 @@ impl GitPanel {
repo.clone(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -3,6 +3,7 @@ use std::any::Any;
use command_palette_hooks::CommandPaletteFilter;
use commit_modal::CommitModal;
use editor::{Editor, actions::DiffClipboardWithSelectionData};
+use project::ProjectPath;
use ui::{
Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled,
StyledExt, div, h_flex, rems, v_flex,
@@ -35,6 +36,7 @@ pub mod commit_tooltip;
pub mod commit_view;
mod conflict_view;
pub mod file_diff_view;
+pub mod file_history_view;
pub mod git_panel;
mod git_panel_settings;
pub mod onboarding;
@@ -57,6 +59,7 @@ actions!(
pub fn init(cx: &mut App) {
editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
commit_view::init(cx);
+ file_history_view::init(cx);
cx.observe_new(|editor: &mut Editor, _, cx| {
conflict_view::register_editor(editor, editor.buffer().clone(), cx);
@@ -227,6 +230,41 @@ pub fn init(cx: &mut App) {
};
},
);
+ workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
+ let Some(active_item) = workspace.active_item(cx) else {
+ return;
+ };
+ let Some(editor) = active_item.downcast::<Editor>() else {
+ return;
+ };
+ let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
+ return;
+ };
+ let Some(file) = buffer.read(cx).file() else {
+ return;
+ };
+ let worktree_id = file.worktree_id(cx);
+ let project_path = ProjectPath {
+ worktree_id,
+ path: file.path().clone(),
+ };
+ let project = workspace.project();
+ let git_store = project.read(cx).git_store();
+ let Some((repo, repo_path)) = git_store
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ else {
+ return;
+ };
+ file_history_view::FileHistoryView::open(
+ repo_path,
+ git_store.downgrade(),
+ repo.downgrade(),
+ workspace.weak_handle(),
+ window,
+ cx,
+ );
+ });
})
.detach();
}
@@ -269,6 +269,7 @@ impl StashListDelegate {
repo.downgrade(),
self.workspace.clone(),
Some(stash_index),
+ None,
window,
cx,
);
@@ -488,6 +488,7 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_reset);
client.add_entity_request_handler(Self::handle_show);
client.add_entity_request_handler(Self::handle_load_commit_diff);
+ client.add_entity_request_handler(Self::handle_file_history);
client.add_entity_request_handler(Self::handle_checkout_files);
client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
client.add_entity_request_handler(Self::handle_set_index_text);
@@ -1057,6 +1058,30 @@ impl GitStore {
})
}
+ pub fn file_history(
+ &self,
+ repo: &Entity<Repository>,
+ path: RepoPath,
+ cx: &mut App,
+ ) -> Task<Result<git::repository::FileHistory>> {
+ let rx = repo.update(cx, |repo, _| repo.file_history(path));
+
+ cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
+ }
+
+ pub fn file_history_paginated(
+ &self,
+ repo: &Entity<Repository>,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ cx: &mut App,
+ ) -> Task<Result<git::repository::FileHistory>> {
+ let rx = repo.update(cx, |repo, _| repo.file_history_paginated(path, skip, limit));
+
+ cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
+ }
+
pub fn get_permalink_to_line(
&self,
buffer: &Entity<Buffer>,
@@ -2314,6 +2339,40 @@ impl GitStore {
})
}
+ async fn handle_file_history(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitFileHistory>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::GitFileHistoryResponse> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let path = RepoPath::from_proto(&envelope.payload.path)?;
+ let skip = envelope.payload.skip as usize;
+ let limit = envelope.payload.limit.map(|l| l as usize);
+
+ let file_history = repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.file_history_paginated(path, skip, limit)
+ })?
+ .await??;
+
+ Ok(proto::GitFileHistoryResponse {
+ entries: file_history
+ .entries
+ .into_iter()
+ .map(|entry| proto::FileHistoryEntry {
+ sha: entry.sha.to_string(),
+ subject: entry.subject.to_string(),
+ message: entry.message.to_string(),
+ commit_timestamp: entry.commit_timestamp,
+ author_name: entry.author_name.to_string(),
+ author_email: entry.author_email.to_string(),
+ })
+ .collect(),
+ path: file_history.path.to_proto(),
+ })
+ }
+
async fn handle_reset(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitReset>,
@@ -4016,6 +4075,55 @@ impl Repository {
})
}
+ pub fn file_history(
+ &mut self,
+ path: RepoPath,
+ ) -> oneshot::Receiver<Result<git::repository::FileHistory>> {
+ self.file_history_paginated(path, 0, None)
+ }
+
+ pub fn file_history_paginated(
+ &mut self,
+ path: RepoPath,
+ skip: usize,
+ limit: Option<usize>,
+ ) -> oneshot::Receiver<Result<git::repository::FileHistory>> {
+ let id = self.id;
+ self.send_job(None, move |git_repo, _cx| async move {
+ match git_repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.file_history_paginated(path, skip, limit).await
+ }
+ RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => {
+ let response = client
+ .request(proto::GitFileHistory {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ path: path.to_proto(),
+ skip: skip as u64,
+ limit: limit.map(|l| l as u64),
+ })
+ .await?;
+ Ok(git::repository::FileHistory {
+ entries: response
+ .entries
+ .into_iter()
+ .map(|entry| git::repository::FileHistoryEntry {
+ sha: entry.sha.into(),
+ subject: entry.subject.into(),
+ message: entry.message.into(),
+ commit_timestamp: entry.commit_timestamp,
+ author_name: entry.author_name.into(),
+ author_email: entry.author_email.into(),
+ })
+ .collect(),
+ path: RepoPath::from_proto(&response.path)?,
+ })
+ }
+ }
+ })
+ }
+
fn buffer_store(&self, cx: &App) -> Option<Entity<BufferStore>> {
Some(self.git_store.upgrade()?.read(cx).buffer_store.clone())
}
@@ -14,7 +14,9 @@ use editor::{
},
};
use file_icons::FileIcons;
+use git;
use git::status::GitSummary;
+use git_ui;
use git_ui::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardItem, Context, CursorStyle,
@@ -442,6 +444,72 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.delete(action, window, cx));
}
});
+
+ workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
+ // First try to get from project panel if it's focused
+ if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
+ let maybe_project_path = panel.read(cx).state.selection.and_then(|selection| {
+ let project = workspace.project().read(cx);
+ let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
+ let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
+ if entry.is_file() {
+ Some(ProjectPath {
+ worktree_id: selection.worktree_id,
+ path: entry.path.clone(),
+ })
+ } else {
+ None
+ }
+ });
+
+ if let Some(project_path) = maybe_project_path {
+ let project = workspace.project();
+ let git_store = project.read(cx).git_store();
+ if let Some((repo, repo_path)) = git_store
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ {
+ git_ui::file_history_view::FileHistoryView::open(
+ repo_path,
+ git_store.downgrade(),
+ repo.downgrade(),
+ workspace.weak_handle(),
+ window,
+ cx,
+ );
+ return;
+ }
+ }
+ }
+
+ // Fallback: try to get from active editor
+ if let Some(active_item) = workspace.active_item(cx)
+ && let Some(editor) = active_item.downcast::<Editor>()
+ && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton()
+ && let Some(file) = buffer.read(cx).file()
+ {
+ let worktree_id = file.worktree_id(cx);
+ let project_path = ProjectPath {
+ worktree_id,
+ path: file.path().clone(),
+ };
+ let project = workspace.project();
+ let git_store = project.read(cx).git_store();
+ if let Some((repo, repo_path)) = git_store
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ {
+ git_ui::file_history_view::FileHistoryView::open(
+ repo_path,
+ git_store.downgrade(),
+ repo.downgrade(),
+ workspace.weak_handle(),
+ window,
+ cx,
+ );
+ }
+ }
+ });
})
.detach();
}
@@ -1010,6 +1078,18 @@ impl ProjectPanel {
|| (settings.hide_root && visible_worktrees_count == 1));
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
+ let has_git_repo = !is_dir && {
+ let project_path = project::ProjectPath {
+ worktree_id,
+ path: entry.path.clone(),
+ };
+ project
+ .git_store()
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)
+ .is_some()
+ };
+
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.context(self.focus_handle.clone()).map(|menu| {
if is_read_only {
@@ -1060,6 +1140,10 @@ impl ProjectPanel {
"Copy Relative Path",
Box::new(zed_actions::workspace::CopyRelativePath),
)
+ .when(has_git_repo, |menu| {
+ menu.separator()
+ .action("File History", Box::new(git::FileHistory))
+ })
.when(!should_hide_rename, |menu| {
menu.separator().action("Rename", Box::new(Rename))
})
@@ -290,6 +290,29 @@ message GitCheckoutFiles {
repeated string paths = 5;
}
+message GitFileHistory {
+ uint64 project_id = 1;
+ reserved 2;
+ uint64 repository_id = 3;
+ string path = 4;
+ uint64 skip = 5;
+ optional uint64 limit = 6;
+}
+
+message GitFileHistoryResponse {
+ repeated FileHistoryEntry entries = 1;
+ string path = 2;
+}
+
+message FileHistoryEntry {
+ string sha = 1;
+ string subject = 2;
+ string message = 3;
+ int64 commit_timestamp = 4;
+ string author_name = 5;
+ string author_email = 6;
+}
+
// Move to `git.proto` once collab's min version is >=0.171.0.
message StatusEntry {
string repo_path = 1;
@@ -437,14 +437,16 @@ message Envelope {
OpenImageResponse open_image_response = 392;
CreateImageForPeer create_image_for_peer = 393;
- ExternalExtensionAgentsUpdated external_extension_agents_updated = 394;
+ GitFileHistory git_file_history = 397;
+ GitFileHistoryResponse git_file_history_response = 398;
- RunGitHook run_git_hook = 395;
+ RunGitHook run_git_hook = 399;
- GitDeleteBranch git_delete_branch = 396; // current max
+ GitDeleteBranch git_delete_branch = 400;
+ ExternalExtensionAgentsUpdated external_extension_agents_updated = 401; // current max
}
- reserved 87 to 88;
+ reserved 87 to 88, 396;
reserved 102 to 103;
reserved 158 to 161;
reserved 164;
@@ -468,6 +470,7 @@ message Envelope {
reserved 270;
reserved 280 to 281;
reserved 332 to 333;
+ reserved 394 to 395;
}
message Hello {
@@ -294,6 +294,8 @@ messages!(
(GitCheckoutFiles, Background),
(GitShow, Background),
(GitCommitDetails, Background),
+ (GitFileHistory, Background),
+ (GitFileHistoryResponse, Background),
(SetIndexText, Background),
(Push, Background),
(Fetch, Background),
@@ -492,6 +494,7 @@ request_messages!(
(InstallExtension, Ack),
(RegisterBufferWithLanguageServers, Ack),
(GitShow, GitCommitDetails),
+ (GitFileHistory, GitFileHistoryResponse),
(GitReset, Ack),
(GitDeleteBranch, Ack),
(GitCheckoutFiles, Ack),
@@ -657,6 +660,7 @@ entity_messages!(
CancelLanguageServerWork,
RegisterBufferWithLanguageServers,
GitShow,
+ GitFileHistory,
GitReset,
GitDeleteBranch,
GitCheckoutFiles,
@@ -1164,7 +1164,7 @@ fn initialize_pane(
toolbar.add_item(migration_banner, window, cx);
let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
toolbar.add_item(project_diff_toolbar, window, cx);
- let commit_view_toolbar = cx.new(|cx| CommitViewToolbar::new(workspace, cx));
+ let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
toolbar.add_item(commit_view_toolbar, window, cx);
let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
toolbar.add_item(agent_diff_toolbar, window, cx);