Detailed changes
@@ -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),
@@ -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>,
@@ -43,6 +43,8 @@ actions!(
/// Shows git blame information for the current file.
#[action(deprecated_aliases = ["editor::ToggleGitBlame"])]
Blame,
+ /// Shows the git history for the current file.
+ FileHistory,
/// Stages the current file.
StageFile,
/// Unstages the current file.
@@ -207,6 +207,22 @@ pub struct CommitDetails {
pub author_name: SharedString,
}
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct FileHistoryEntry {
+ pub sha: SharedString,
+ pub subject: SharedString,
+ pub message: SharedString,
+ pub commit_timestamp: i64,
+ pub author_name: SharedString,
+ pub author_email: SharedString,
+}
+
+#[derive(Debug, Clone)]
+pub struct FileHistory {
+ pub entries: Vec<FileHistoryEntry>,
+ pub path: RepoPath,
+}
+
#[derive(Debug)]
pub struct CommitDiff {
pub files: Vec<CommitFile>,
@@ -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();
@@ -101,6 +101,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
)
@@ -325,6 +326,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -365,6 +367,7 @@ impl BlameRenderer for GitBlameRenderer {
repository.downgrade(),
workspace,
None,
+ None,
window,
cx,
)
@@ -323,6 +323,7 @@ impl Render for CommitTooltip {
repo.downgrade(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -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
@@ -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
+ }
+}
+
@@ -3570,6 +3570,7 @@ impl GitPanel {
repo.clone(),
workspace.clone(),
None,
+ None,
window,
cx,
);
@@ -3,6 +3,7 @@ use std::any::Any;
use command_palette_hooks::CommandPaletteFilter;
use commit_modal::CommitModal;
use editor::{Editor, actions::DiffClipboardWithSelectionData};
+use project::ProjectPath;
use ui::{
Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled,
StyledExt, div, h_flex, rems, v_flex,
@@ -35,6 +36,7 @@ pub mod commit_tooltip;
pub mod commit_view;
mod conflict_view;
pub mod file_diff_view;
+pub mod file_history_view;
pub mod git_panel;
mod git_panel_settings;
pub mod onboarding;
@@ -57,6 +59,7 @@ actions!(
pub fn init(cx: &mut App) {
editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
commit_view::init(cx);
+ file_history_view::init(cx);
cx.observe_new(|editor: &mut Editor, _, cx| {
conflict_view::register_editor(editor, editor.buffer().clone(), cx);
@@ -227,6 +230,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();
}
@@ -269,6 +269,7 @@ impl StashListDelegate {
repo.downgrade(),
self.workspace.clone(),
Some(stash_index),
+ None,
window,
cx,
);
@@ -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>,
@@ -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))
})
@@ -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;