Add file history view (#42441)

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>
This commit is contained in:
ozzy
2025-12-01 13:25:33 +00:00
committed by GitHub
co-authored by cameron
parent 747dc23138
commit 05c2028068
19 changed files with 1703 additions and 394 deletions
+3
View File
@@ -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,
)
+1
View File
@@ -323,6 +323,7 @@ impl Render for CommitTooltip {
repo.downgrade(),
workspace.clone(),
None,
None,
window,
cx,
);
File diff suppressed because it is too large Load Diff
+629
View File
@@ -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
}
}
}
+1
View File
@@ -3698,6 +3698,7 @@ impl GitPanel {
repo.clone(),
workspace.clone(),
None,
None,
window,
cx,
);
+38
View File
@@ -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();
}
+1
View File
@@ -269,6 +269,7 @@ impl StashListDelegate {
repo.downgrade(),
self.workspace.clone(),
Some(stash_index),
None,
window,
cx,
);