From 34bd8201aa8467848dc80730415a5ea9f956a412 Mon Sep 17 00:00:00 2001 From: ozer Date: Mon, 10 Nov 2025 19:00:35 +0300 Subject: [PATCH] feat(git-ui): add file history timeline view --- 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(-) create mode 100644 crates/git_ui/src/file_history_view.rs diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 2a63e39adda52734b301eda0d32a5bfa10a8e47e..783e74c70d6b03ea4440a88e6bd0bc0ad76ca093 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/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), diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 97cd13d185817453c369356bdc60cbc1517bf1e1..30f9d1edec20d86d9e6b7f77e0f3fb93de8bae6c 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -441,6 +441,16 @@ impl GitRepository for FakeGitRepository { }) } + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { + async move { + Ok(git::repository::FileHistory { + entries: Vec::new(), + path, + }) + } + .boxed() + } + fn stage_paths( &self, paths: Vec, diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 50a1e1234ba3caeff729d37b6fa3022336b54e96..9113ccc1cf74b8590c2b30f349711adcfe9b4359 100644 --- a/crates/git/src/git.rs +++ b/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. diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 2a1cd9478d3079716eda8234c02c8122b9381b38..9c0f91651a05151945ac2f4df12503e846bf03c9 100644 --- a/crates/git/src/repository.rs +++ b/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, + pub path: RepoPath, +} + #[derive(Debug)] pub struct CommitDiff { pub files: Vec, @@ -461,6 +477,7 @@ pub trait GitRepository: Send + Sync { fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result>; fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result>; + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result>; /// 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/`). @@ -1421,6 +1438,66 @@ impl GitRepository for RealGitRepository { .boxed() } + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { + 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 = "<>"; + 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> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index fc26f4608a38027e6abd4db122e713cc9ff4dc56..d3f89831898c4ef3e3fa5c088d0094c0efa6e8b5 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/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, ) diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 7646d1d64f58c24d112eb929646efec983b8e69b..26bd42c6549457df0f530580bbfc838797134860 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -323,6 +323,7 @@ impl Render for CommitTooltip { repo.downgrade(), workspace.clone(), None, + None, window, cx, ); diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 765e1f84a4a3a5b7e257e51df9a9542d0abff067..91babdfc1104a9692395394f555bfd80cd104d38 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -78,6 +78,7 @@ impl CommitView { repo: WeakEntity, workspace: WeakEntity, stash: Option, + file_filter: Option, 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 diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..88f4a5764628d5367428491388b74e395d6f13c1 --- /dev/null +++ b/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, + repository: WeakEntity, + workspace: WeakEntity, + selected_entry: Option, + scroll_handle: UniformListScrollHandle, + focus_handle: FocusHandle, +} + +impl FileHistoryView { + pub fn open( + path: RepoPath, + git_store: WeakEntity, + repo: WeakEntity, + workspace: WeakEntity, + 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::(); + 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, + workspace: WeakEntity, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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, + ) -> 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 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) -> 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 { + Some(format!("Git history for {}", self.history.path.as_unix_str()).into()) + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::FileGit)) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("file history") + } + + fn clone_on_split( + &self, + _workspace_id: Option, + _window: &mut Window, + _cx: &mut Context, + ) -> Task>> { + Task::ready(None) + } + + fn navigate(&mut self, _: Box, _window: &mut Window, _: &mut Context) -> bool { + false + } + + fn deactivated(&mut self, _window: &mut Window, _: &mut Context) {} + + fn can_save(&self, _: &App) -> bool { + false + } + + fn save( + &mut self, + _options: SaveOptions, + _project: Entity, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn save_as( + &mut self, + _project: Entity, + _path: ProjectPath, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn reload( + &mut self, + _project: Entity, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + 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> { + None + } + + fn added_to_workspace(&mut self, _workspace: &mut Workspace, window: &mut Window, _cx: &mut Context) { + window.focus(&self.focus_handle); + } + + fn show_toolbar(&self) -> bool { + true + } + + fn pixel_position_of_cursor(&self, _: &App) -> Option> { + None + } + + fn as_searchable(&self, _: &Entity) -> Option> { + None + } + + fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _window: &mut Window, _: &mut Context) {} + + fn act_as_type<'a>( + &'a self, + _type_id: TypeId, + _self_handle: &'a Entity, + _: &'a App, + ) -> Option { + None + } +} + diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 85cfb3b499f5cc2baefdc23f8e0ffc91f09b620d..c93c6095945bbc2833acb28b5733b15dd5576908 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3570,6 +3570,7 @@ impl GitPanel { repo.clone(), workspace.clone(), None, + None, window, cx, ); diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index b4e833f7af72cf7843d3797b51ea349b24c7adc5..186dd702085166bb9760526868fa65426cf58bf7 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/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::() 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(); } diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index d25117e3806ff0bdf73985eb60ee1d8f5b373752..fd81176a127e6032ebb84f1c8afdb6f61a5aa9b8 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -269,6 +269,7 @@ impl StashListDelegate { repo.downgrade(), self.workspace.clone(), Some(stash_index), + None, window, cx, ); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 5fcf28aff3554149ece954074f312e0fe37a9208..ca9148885b84ec55763896bdf5aacf0d6dd17593 100644 --- a/crates/project/src/git_store.rs +++ b/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, + path: RepoPath, + cx: &mut App, + ) -> Task> { + 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, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 38fd1d08c9802bd04c7e5faf60c171d492ed996f..0c1576f8257027864c3903dda0ad22b6de1d6143 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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::(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::() { + 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)) }) diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index efbd7f616f9e75c4e0409f4dc73c67f9eb1836e0..51e1418f0dd219fc7ada14a14638e8a555f1c75f 100644 --- a/crates/proto/proto/git.proto +++ b/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;