Compare commits

...

3 Commits

Author SHA1 Message Date
ozer
5840484ef2 Merge branch 'zed-industries:main' into feat/file-history 2025-11-11 16:28:20 +03:00
ozer
d7efc388e9 fix: formatting and clippy errors 2025-11-11 16:27:57 +03:00
ozer
34bd8201aa feat(git-ui): add file history timeline view 2025-11-10 19:00:35 +03:00
14 changed files with 696 additions and 2 deletions

View File

@@ -264,7 +264,8 @@ pub fn deploy_context_menu(
!has_git_repo,
"Copy Permalink",
Box::new(CopyPermalinkToLine),
);
)
.action_disabled_when(!has_git_repo, "File History", Box::new(git::FileHistory));
match focus {
Some(focus) => builder.context(focus),
None => builder,

View File

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

View File

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

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

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

View File

@@ -323,6 +323,7 @@ impl Render for CommitTooltip {
repo.downgrade(),
workspace.clone(),
None,
None,
window,
cx,
);

View File

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

View File

@@ -0,0 +1,423 @@
use anyhow::Result;
use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
use gpui::{
AnyElement, AnyView, App, 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::{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,
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 focus_handle = cx.focus_handle();
let scroll_handle = UniformListScrollHandle::new();
Self {
history,
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
}
}

View File

@@ -3570,6 +3570,7 @@ impl GitPanel {
repo.clone(),
workspace.clone(),
None,
None,
window,
cx,
);

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

View File

@@ -269,6 +269,7 @@ impl StashListDelegate {
repo.downgrade(),
self.workspace.clone(),
Some(stash_index),
None,
window,
cx,
);

View File

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

View File

@@ -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,10 @@ impl ProjectPanel {
"Copy Relative Path",
Box::new(zed_actions::workspace::CopyRelativePath),
)
.when(has_git_repo, |menu| {
menu.separator()
.action("File History", Box::new(git::FileHistory))
})
.when(!should_hide_rename, |menu| {
menu.separator().action("Rename", Box::new(Rename))
})

View File

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