feat(git-ui): add file history timeline view

ozer created

Change summary

crates/editor/src/mouse_context_menu.rs   |   5 
crates/fs/src/fake_git_repo.rs            |  10 
crates/git/src/git.rs                     |   2 
crates/git/src/repository.rs              |  77 ++++
crates/git_ui/src/blame_ui.rs             |   3 
crates/git_ui/src/commit_tooltip.rs       |   1 
crates/git_ui/src/commit_view.rs          |   9 
crates/git_ui/src/file_history_view.rs    | 422 +++++++++++++++++++++++++
crates/git_ui/src/git_panel.rs            |   1 
crates/git_ui/src/git_ui.rs               |  45 ++
crates/git_ui/src/stash_picker.rs         |   1 
crates/project/src/git_store.rs           |  20 +
crates/project_panel/src/project_panel.rs |  85 +++++
crates/proto/proto/git.proto              |  21 +
14 files changed, 701 insertions(+), 1 deletion(-)

Detailed changes

crates/editor/src/mouse_context_menu.rs 🔗

@@ -264,6 +264,11 @@ 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),

crates/fs/src/fake_git_repo.rs 🔗

@@ -441,6 +441,16 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn file_history(&self, path: RepoPath) -> 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>,
@@ -461,6 +477,7 @@ 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>>;
 
     /// 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>`).
@@ -1421,6 +1438,66 @@ impl GitRepository for RealGitRepository {
         .boxed()
     }
 
+    fn file_history(&self, path: RepoPath) -> 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 to separate commits
+                const COMMIT_DELIMITER: &str = "<<COMMIT_END>>";
+                let output = new_smol_command(&git_binary_path)
+                    .current_dir(&working_directory)
+                    .args([
+                        "--no-optional-locks",
+                        "log",
+                        "--follow",
+                        &format!("--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}", COMMIT_DELIMITER),
+                        "--",
+                    ])
+                    .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 = String::from_utf8_lossy(&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/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 🔗

@@ -78,6 +78,7 @@ impl CommitView {
         repo: WeakEntity<Repository>,
         workspace: WeakEntity<Workspace>,
         stash: Option<usize>,
+        file_filter: Option<RepoPath>,
         window: &mut Window,
         cx: &mut App,
     ) {
@@ -91,8 +92,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

crates/git_ui/src/file_history_view.rs 🔗

@@ -0,0 +1,422 @@
+use anyhow::Result;
+use editor::{Editor, MultiBuffer};
+use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
+use gpui::{
+    actions, uniform_list, App, AnyElement, AnyView, Context, Entity, EventEmitter, FocusHandle,
+    Focusable, IntoElement, ListSizingBehavior, Render, Task, UniformListScrollHandle, WeakEntity,
+    Window, rems,
+};
+use language::Capability;
+use project::{Project, ProjectPath, git_store::{GitStore, Repository}};
+use std::any::{Any, TypeId};
+use time::OffsetDateTime;
+use ui::{Icon, IconName, Label, LabelCommon as _, SharedString, prelude::*};
+use util::{ResultExt, truncate_and_trailoff};
+use workspace::{
+    Item, Workspace,
+    item::{ItemEvent, SaveOptions},
+    searchable::SearchableItemHandle,
+};
+
+use crate::commit_view::CommitView;
+
+actions!(git, [ViewCommitFromHistory]);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace.register_action(|_workspace, _: &ViewCommitFromHistory, _window, _cx| {
+        });
+    })
+    .detach();
+}
+
+pub struct FileHistoryView {
+    history: FileHistory,
+    editor: Entity<Editor>,
+    repository: WeakEntity<Repository>,
+    workspace: WeakEntity<Workspace>,
+    selected_entry: Option<usize>,
+    scroll_handle: UniformListScrollHandle,
+    focus_handle: FocusHandle,
+}
+
+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(&repo, path.clone(), 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,
+                                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,
+        repository: Entity<Repository>,
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly));
+        let editor = cx.new(|cx| {
+            Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx)
+        });
+        let focus_handle = cx.focus_handle();
+        let scroll_handle = UniformListScrollHandle::new();
+
+        Self {
+            history,
+            editor,
+            repository: repository.downgrade(),
+            workspace,
+            selected_entry: None,
+            scroll_handle,
+            focus_handle,
+        }
+    }
+
+    fn list_item_height(&self) -> Rems {
+        rems(1.75)
+    }
+
+    fn render_commit_entry(
+        &self,
+        ix: usize,
+        entry: &FileHistoryEntry,
+        _window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let short_sha = 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.15)
+        } else {
+            cx.theme().colors().element_background
+        };
+
+        let hover_bg = if selected {
+            cx.theme().status().info.alpha(0.2)
+        } 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();
+                
+                // Open the commit view filtered to show only this file's changes
+                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()
+                    .w(rems(4.5))
+                    .text_color(cx.theme().status().info)
+                    .font_family(".SystemUIFontMonospaced-Regular")
+                    .child(short_sha),
+            )
+            .child(
+                Label::new(truncate_and_trailoff(&entry.subject, 60))
+                    .single_line()
+                    .color(ui::Color::Default),
+            )
+            .child(div().flex_1())
+            .child(
+                Label::new(truncate_and_trailoff(&entry.author_name, 20))
+                    .size(LabelSize::Small)
+                    .color(ui::Color::Muted)
+                    .single_line(),
+            )
+            .child(
+                div()
+                    .flex_none()
+                    .w(rems(6.5))
+                    .child(
+                        Label::new(relative_timestamp)
+                            .size(LabelSize::Small)
+                            .color(ui::Color::Muted)
+                            .single_line(),
+                    ),
+            )
+            .into_any_element()
+    }
+}
+
+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(
+                                Icon::new(IconName::FileGit)
+                                    .size(IconSize::Small)
+                                    .color(ui::Color::Muted),
+                            )
+                            .child(
+                                Label::new(format!("History: {}", file_name))
+                                    .size(LabelSize::Default),
+                            ),
+                    )
+                    .child(
+                        Label::new(format!("{} commits", entry_count))
+                            .size(LabelSize::Small)
+                            .color(ui::Color::Muted),
+                    ),
+            )
+            .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.clone())
+            })
+    }
+}
+
+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 as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        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<AnyView> {
+        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,48 @@ 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 🔗

@@ -1009,6 +1009,26 @@ impl GitStore {
         cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
     }
 
+    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.send_job(None, move |state, _| async move {
+                match state {
+                    RepositoryState::Local { backend, .. } => backend.file_history(path).await,
+                    RepositoryState::Remote { .. } => {
+                        Err(anyhow!("file history not supported for remote repositories yet"))
+                    }
+                }
+            })
+        });
+
+        cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
+    }
+
     pub fn get_permalink_to_line(
         &self,
         buffer: &Entity<Buffer>,

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,
@@ -428,6 +430,74 @@ 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) {
+                if let Some(editor) = active_item.downcast::<Editor>() {
+                    if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
+                        if 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();
 }
@@ -997,6 +1067,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 {
@@ -1047,6 +1129,9 @@ 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 🔗

@@ -284,6 +284,27 @@ message GitCheckoutFiles {
     repeated string paths = 5;
 }
 
+message GitFileHistory {
+    uint64 project_id = 1;
+    reserved 2;
+    uint64 repository_id = 3;
+    string path = 4;
+}
+
+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;