Add file history view (#42441)

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>

Change summary

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(-)

Detailed changes

Cargo.lock 🔗

@@ -7109,6 +7109,7 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "git",
+ "git_hosting_providers",
  "gpui",
  "indoc",
  "itertools 0.14.0",

crates/editor/src/mouse_context_menu.rs 🔗

@@ -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,

crates/fs/src/fake_git_repo.rs 🔗

@@ -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>,

crates/git/src/git.rs 🔗

@@ -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.

crates/git/src/repository.rs 🔗

@@ -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();

crates/git_ui/Cargo.toml 🔗

@@ -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

crates/git_ui/src/blame_ui.rs 🔗

@@ -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,
         )

crates/git_ui/src/commit_view.rs 🔗

@@ -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,

crates/git_ui/src/file_history_view.rs 🔗

@@ -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
+        }
+    }
+}

crates/git_ui/src/git_ui.rs 🔗

@@ -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();
 }

crates/project/src/git_store.rs 🔗

@@ -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())
     }

crates/project_panel/src/project_panel.rs 🔗

@@ -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))
                             })

crates/proto/proto/git.proto 🔗

@@ -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;

crates/proto/proto/zed.proto 🔗

@@ -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 {

crates/proto/src/proto.rs 🔗

@@ -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,

crates/zed/src/zed.rs 🔗

@@ -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);