From 05c2028068dbf3ea09e072dff977419052b5aba0 Mon Sep 17 00:00:00 2001 From: ozzy <109994179+ddoemonn@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:25:33 +0300 Subject: [PATCH] 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. Screenshot 2025-11-11 at 16 31 32 Screenshot 2025-11-11 at 16 31 24 --------- Co-authored-by: cameron --- Cargo.lock | 1 + crates/editor/src/mouse_context_menu.rs | 3 +- crates/fs/src/fake_git_repo.rs | 19 + crates/git/src/git.rs | 2 + crates/git/src/repository.rs | 111 +++ crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/blame_ui.rs | 3 + crates/git_ui/src/commit_tooltip.rs | 1 + crates/git_ui/src/commit_view.rs | 1031 +++++++++++++-------- crates/git_ui/src/file_history_view.rs | 629 +++++++++++++ crates/git_ui/src/git_panel.rs | 1 + crates/git_ui/src/git_ui.rs | 38 + crates/git_ui/src/stash_picker.rs | 1 + crates/project/src/git_store.rs | 108 +++ crates/project_panel/src/project_panel.rs | 84 ++ crates/proto/proto/git.proto | 23 + crates/proto/proto/zed.proto | 11 +- crates/proto/src/proto.rs | 4 + crates/zed/src/zed.rs | 2 +- 19 files changed, 1691 insertions(+), 382 deletions(-) create mode 100644 crates/git_ui/src/file_history_view.rs diff --git a/Cargo.lock b/Cargo.lock index 43658ef42a0d0e459e834cccbb36fbc658c2930f..c3d1d764822596c0060f77e684852553b5b74b0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7109,6 +7109,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "git", + "git_hosting_providers", "gpui", "indoc", "itertools 0.14.0", diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 94e5019d59b68a33d2d64245d2d1e17a764638da..39ad8a3672511724d69ba59a366513a24edb2198 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -276,7 +276,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, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 2b19b0bf85f11e846154f6b6781c884bb1e3c0fe..c641988ab891889b8ebb63c7e9414d69d3107558 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -446,6 +446,25 @@ impl GitRepository for FakeGitRepository { }) } + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { + self.file_history_paginated(path, 0, None) + } + + fn file_history_paginated( + &self, + path: RepoPath, + _skip: usize, + _limit: Option, + ) -> 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 4dc2f0a8a93cec82da4df4d3b4431dbf6f4d3862..8b8f88ef65b86ea9157e1c3217fa01bb0d6355cb 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 110396b0450ada5a97d8c3362f9cc367f260fd0e..801e0e7d3c7d44bec890a5d9af39262e1bf2aa66 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, @@ -464,6 +480,13 @@ 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>; + fn file_history_paginated( + &self, + path: RepoPath, + skip: usize, + limit: Option, + ) -> 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/`). @@ -1452,6 +1475,94 @@ impl GitRepository for RealGitRepository { .boxed() } + fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { + self.file_history_paginated(path, 0, None) + } + + fn file_history_paginated( + &self, + path: RepoPath, + skip: usize, + limit: Option, + ) -> 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 with a hardcoded UUID to separate commits + // This essentially eliminates any chance of encountering the delimiter in actual commit data + let commit_delimiter = + concat!("<>",); + + let format_string = format!( + "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}", + commit_delimiter + ); + + let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string]; + + let skip_str; + let limit_str; + if skip > 0 { + skip_str = skip.to_string(); + args.push("--skip"); + args.push(&skip_str); + } + if let Some(n) = limit { + limit_str = n.to_string(); + args.push("-n"); + args.push(&limit_str); + } + + args.push("--"); + + let output = new_smol_command(&git_binary_path) + .current_dir(&working_directory) + .args(&args) + .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 = std::str::from_utf8(&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/Cargo.toml b/crates/git_ui/Cargo.toml index 8ac2f318e46bb114fae151db99427a80eaba61b0..5e96cd3529b48bb401ee14e1a704b9bec485e356 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -69,6 +69,7 @@ windows.workspace = true [dev-dependencies] ctor.workspace = true editor = { workspace = true, features = ["test-support"] } +git_hosting_providers.workspace = true gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true 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 e9cfa5f719e5435d9e13343028f6397aba6587f3..1f67cef5be6546e9633b524173364ba02cb4af3a 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,29 +1,31 @@ use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{ - Editor, EditorEvent, MultiBuffer, MultiBufferOffset, SelectionEffects, - multibuffer_context_lines, -}; +use editor::{Addon, Editor, EditorEvent, MultiBuffer}; use git::repository::{CommitDetails, CommitDiff, RepoPath}; +use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ - Action, AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Entity, - EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, Task, WeakEntity, - Window, actions, + AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element, + Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, + PromptLevel, Render, Styled, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, + actions, px, }; use language::{ - Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, - Point, ReplicaId, Rope, TextBuffer, + Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer, + ToPoint, }; +use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use multi_buffer::ExcerptInfo; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; use std::{ any::{Any, TypeId}, - fmt::Write as _, path::PathBuf, sync::Arc, }; +use theme::ActiveTheme; use ui::{ - Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*, + Avatar, Button, ButtonCommon, Clickable, Color, Icon, IconName, IconSize, Label, + LabelCommon as _, LabelSize, SharedString, div, h_flex, v_flex, }; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::{ @@ -41,14 +43,14 @@ actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]); pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - register_workspace_action(workspace, |toolbar, _: &ApplyCurrentStash, window, cx| { - toolbar.apply_stash(window, cx); + workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| { + CommitView::apply_stash(workspace, window, cx); }); - register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| { - toolbar.remove_stash(window, cx); + workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| { + CommitView::remove_stash(workspace, window, cx); }); - register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| { - toolbar.pop_stash(window, cx); + workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| { + CommitView::pop_stash(workspace, window, cx); }); }) .detach(); @@ -59,6 +61,9 @@ pub struct CommitView { editor: Entity, stash: Option, multibuffer: Entity, + repository: Entity, + remote: Option, + markdown: Entity, } struct GitBlob { @@ -67,12 +72,6 @@ struct GitBlob { is_deleted: bool, } -struct CommitMetadataFile { - title: Arc, - worktree_id: WorktreeId, -} - -const COMMIT_METADATA_SORT_PREFIX: u64 = 0; const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { @@ -81,6 +80,7 @@ impl CommitView { repo: WeakEntity, workspace: WeakEntity, stash: Option, + file_filter: Option, window: &mut Window, cx: &mut App, ) { @@ -94,8 +94,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 @@ -148,6 +154,9 @@ impl CommitView { Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); editor.disable_inline_diagnostics(); editor.set_expand_all_diff_hunks(cx); + editor.register_addon(CommitViewAddon { + multibuffer: multibuffer.downgrade(), + }); editor }); @@ -157,50 +166,13 @@ impl CommitView { .next() .map(|worktree| worktree.read(cx).id()); - let mut metadata_buffer_id = None; - if let Some(worktree_id) = first_worktree_id { - let title = if let Some(stash) = stash { - format!("stash@{{{}}}", stash) - } else { - format!("commit {}", commit.sha) - }; - let file = Arc::new(CommitMetadataFile { - title: RelPath::unix(&title).unwrap().into(), - worktree_id, - }); - let buffer = cx.new(|cx| { - let buffer = TextBuffer::new_normalized( - ReplicaId::LOCAL, - cx.entity_id().as_non_zero_u64().into(), - LineEnding::default(), - format_commit(&commit, stash.is_some()).into(), - ); - metadata_buffer_id = Some(buffer.remote_id()); - Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite) - }); - multibuffer.update(cx, |multibuffer, cx| { - multibuffer.set_excerpts_for_path( - PathKey::with_sort_prefix(COMMIT_METADATA_SORT_PREFIX, file.title.clone()), - buffer.clone(), - vec![Point::zero()..buffer.read(cx).max_point()], - 0, - cx, - ); - }); - editor.update(cx, |editor, cx| { - editor.disable_header_for_buffer(metadata_buffer_id.unwrap(), cx); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(0)]); - }); - }); - } - + let repository_clone = repository.clone(); cx.spawn(async move |this, cx| { for file in commit_diff.files { let is_deleted = file.new_text.is_none(); let new_text = file.new_text.unwrap_or_default(); let old_text = file.old_text; - let worktree_id = repository + let worktree_id = repository_clone .update(cx, |repository, cx| { repository .repo_path_to_project_path(&file.path, cx) @@ -221,21 +193,34 @@ impl CommitView { this.update(cx, |this, cx| { this.multibuffer.update(cx, |multibuffer, cx| { let snapshot = buffer.read(cx).snapshot(); - let diff = buffer_diff.read(cx); - let diff_hunk_ranges = diff - .hunks_intersecting_range( - Anchor::min_max_range_for_buffer(diff.buffer_id), - &snapshot, - cx, - ) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) - .collect::>(); let path = snapshot.file().unwrap().path().clone(); + + let hunks: Vec<_> = buffer_diff.read(cx).hunks(&snapshot, cx).collect(); + + let excerpt_ranges = if hunks.is_empty() { + vec![language::Point::zero()..snapshot.max_point()] + } else { + hunks + .into_iter() + .map(|hunk| { + let start = hunk.range.start.max(language::Point::new( + hunk.range.start.row.saturating_sub(3), + 0, + )); + let end_row = + (hunk.range.end.row + 3).min(snapshot.max_point().row); + let end = + language::Point::new(end_row, snapshot.line_len(end_row)); + start..end + }) + .collect() + }; + let _is_newly_added = multibuffer.set_excerpts_for_path( PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path), buffer, - diff_hunk_ranges, - multibuffer_context_lines(cx), + excerpt_ranges, + 0, cx, ); multibuffer.add_diff(buffer_diff, cx); @@ -246,64 +231,409 @@ impl CommitView { }) .detach(); + 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(), + }) + }); + + let processed_message = if let Some(ref remote) = remote { + Self::process_github_issues(&commit.message, remote) + } else { + commit.message.to_string() + }; + + let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); + Self { commit, editor, multibuffer, stash, + repository, + remote, + markdown, } } -} -impl language::File for GitBlob { - fn as_local(&self) -> Option<&dyn language::LocalFile> { - None + fn fallback_commit_avatar() -> AnyElement { + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Medium) + .into_element() + .into_any() } - fn disk_state(&self) -> DiskState { - if self.is_deleted { - DiskState::Deleted + fn render_commit_avatar( + &self, + sha: &SharedString, + size: impl Into, + 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::(&avatar_asset, cx) { + Avatar::new(url.to_string()) + .size(size) + .into_element() + .into_any() + } else { + Self::fallback_commit_avatar() + } } else { - DiskState::New + Self::fallback_commit_avatar() } } - fn path_style(&self, _: &App) -> PathStyle { - PathStyle::Posix + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let commit = &self.commit; + let author_name = commit.author_name.clone(); + let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let date_string = time_format::format_localized_timestamp( + commit_date, + time::OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::MediumAbsolute, + ); + + let github_url = self.remote.as_ref().map(|remote| { + format!( + "{}/{}/{}/commit/{}", + remote.host.base_url(), + remote.owner, + remote.repo, + commit.sha + ) + }); + + v_flex() + .p_4() + .gap_4() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .items_start() + .gap_3() + .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx)) + .child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_3() + .items_baseline() + .child(Label::new(author_name).color(Color::Default)) + .child( + Label::new(format!("commit {}", commit.sha)) + .color(Color::Muted), + ), + ) + .child(Label::new(date_string).color(Color::Muted)), + ) + .child(div().flex_grow()) + .children(github_url.map(|url| { + Button::new("view_on_github", "View on GitHub") + .icon(IconName::Github) + .style(ui::ButtonStyle::Subtle) + .on_click(move |_, _, cx| cx.open_url(&url)) + })), + ) + .child(self.render_commit_message(window, cx)) } - fn path(&self) -> &Arc { - self.path.as_ref() + fn process_github_issues(message: &str, remote: &GitRemote) -> String { + let mut result = String::new(); + let chars: Vec = message.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() { + let mut j = i + 1; + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + let issue_number = &message[i + 1..i + (j - i)]; + let url = format!( + "{}/{}/{}/issues/{}", + remote.host.base_url().as_str().trim_end_matches('/'), + remote.owner, + remote.repo, + issue_number + ); + result.push_str(&format!("[#{}]({})", issue_number, url)); + i = j; + } else if i + 3 < chars.len() + && chars[i] == 'G' + && chars[i + 1] == 'H' + && chars[i + 2] == '-' + && chars[i + 3].is_ascii_digit() + { + let mut j = i + 3; + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + let issue_number = &message[i + 3..i + (j - i)]; + let url = format!( + "{}/{}/{}/issues/{}", + remote.host.base_url().as_str().trim_end_matches('/'), + remote.owner, + remote.repo, + issue_number + ); + result.push_str(&format!("[GH-{}]({})", issue_number, url)); + i = j; + } else { + result.push(chars[i]); + i += 1; + } + } + + result } - fn full_path(&self, _: &App) -> PathBuf { - self.path.as_std_path().to_path_buf() + fn render_commit_message( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let style = hover_markdown_style(window, cx); + MarkdownElement::new(self.markdown.clone(), style) } - fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.path.file_name().unwrap() + fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { + Self::stash_action( + workspace, + "Apply", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, not applying")); + } + Ok(repo.stash_apply(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await?, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); } - fn worktree_id(&self, _: &App) -> WorktreeId { - self.worktree_id + fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { + Self::stash_action( + workspace, + "Pop", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, pop aborted")); + } + Ok(repo.stash_pop(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await?, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); } - fn to_proto(&self, _cx: &App) -> language::proto::File { - unimplemented!() + fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { + Self::stash_action( + workspace, + "Drop", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, drop aborted")); + } + Ok(repo.stash_drop(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await??, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); } - fn is_private(&self) -> bool { - false + fn stash_action( + workspace: &mut Workspace, + str_action: &str, + window: &mut Window, + cx: &mut App, + callback: AsyncFn, + ) where + AsyncFn: AsyncFnOnce( + Entity, + &SharedString, + usize, + Entity, + WeakEntity, + &mut AsyncWindowContext, + ) -> anyhow::Result<()> + + 'static, + { + let Some(commit_view) = workspace.active_item_as::(cx) else { + return; + }; + let Some(stash) = commit_view.read(cx).stash else { + return; + }; + let sha = commit_view.read(cx).commit.sha.clone(); + let answer = window.prompt( + PromptLevel::Info, + &format!("{} stash@{{{}}}?", str_action, stash), + None, + &[str_action, "Cancel"], + cx, + ); + + let workspace_weak = workspace.weak_handle(); + let commit_view_entity = commit_view; + + window + .spawn(cx, async move |cx| { + if answer.await != Ok(0) { + return anyhow::Ok(()); + } + + let Some(workspace) = workspace_weak.upgrade() else { + return Ok(()); + }; + + let repo = workspace.update(cx, |workspace, cx| { + workspace + .panel::(cx) + .and_then(|p| p.read(cx).active_repository.clone()) + })?; + + let Some(repo) = repo else { + return Ok(()); + }; + + callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?; + anyhow::Ok(()) + }) + .detach_and_notify_err(window, cx); + } + + async fn close_commit_view( + commit_view: Entity, + workspace: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> anyhow::Result<()> { + workspace + .update_in(cx, |workspace, window, cx| { + let active_pane = workspace.active_pane(); + let commit_view_id = commit_view.entity_id(); + active_pane.update(cx, |pane, cx| { + pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx) + }) + })? + .await?; + anyhow::Ok(()) + } +} + +#[derive(Clone, Debug)] +struct CommitAvatarAsset { + sha: SharedString, + remote: GitRemote, +} + +impl std::hash::Hash for CommitAvatarAsset { + fn hash(&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; + + fn load( + source: Self::Source, + cx: &mut App, + ) -> impl Future + 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 language::File for CommitMetadataFile { +impl language::File for GitBlob { fn as_local(&self) -> Option<&dyn language::LocalFile> { None } fn disk_state(&self) -> DiskState { - DiskState::New + if self.is_deleted { + DiskState::Deleted + } else { + DiskState::New + } } fn path_style(&self, _: &App) -> PathStyle { @@ -311,22 +641,22 @@ impl language::File for CommitMetadataFile { } fn path(&self) -> &Arc { - &self.title + self.path.as_ref() } fn full_path(&self, _: &App) -> PathBuf { - PathBuf::from(self.title.as_unix_str().to_owned()) + self.path.as_std_path().to_path_buf() } fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.title.file_name().unwrap() + self.path.file_name().unwrap() } fn worktree_id(&self, _: &App) -> WorktreeId { self.worktree_id } - fn to_proto(&self, _: &App) -> language::proto::File { + fn to_proto(&self, _cx: &App) -> language::proto::File { unimplemented!() } @@ -335,6 +665,94 @@ impl language::File for CommitMetadataFile { } } +// No longer needed since metadata buffer is not created +// impl language::File for CommitMetadataFile { +// fn as_local(&self) -> Option<&dyn language::LocalFile> { +// None +// } +// +// fn disk_state(&self) -> DiskState { +// DiskState::New +// } +// +// fn path_style(&self, _: &App) -> PathStyle { +// PathStyle::Posix +// } +// +// fn path(&self) -> &Arc { +// &self.title +// } +// +// fn full_path(&self, _: &App) -> PathBuf { +// self.title.as_std_path().to_path_buf() +// } +// +// fn file_name<'a>(&'a self, _: &'a App) -> &'a str { +// self.title.file_name().unwrap_or("commit") +// } +// +// fn worktree_id(&self, _: &App) -> WorktreeId { +// self.worktree_id +// } +// +// fn to_proto(&self, _cx: &App) -> language::proto::File { +// unimplemented!() +// } +// +// fn is_private(&self) -> bool { +// false +// } +// } + +struct CommitViewAddon { + multibuffer: WeakEntity, +} + +impl Addon for CommitViewAddon { + fn render_buffer_header_controls( + &self, + excerpt: &ExcerptInfo, + _window: &Window, + cx: &App, + ) -> Option { + let multibuffer = self.multibuffer.upgrade()?; + let snapshot = multibuffer.read(cx).snapshot(cx); + let excerpts = snapshot.excerpts().collect::>(); + let current_idx = excerpts.iter().position(|(id, _, _)| *id == excerpt.id)?; + let (_, _, current_range) = &excerpts[current_idx]; + + let start_row = current_range.context.start.to_point(&excerpt.buffer).row; + + let prev_end_row = if current_idx > 0 { + let (_, prev_buffer, prev_range) = &excerpts[current_idx - 1]; + if prev_buffer.remote_id() == excerpt.buffer_id { + prev_range.context.end.to_point(&excerpt.buffer).row + } else { + 0 + } + } else { + 0 + }; + + let skipped_lines = start_row.saturating_sub(prev_end_row); + + if skipped_lines > 0 { + Some( + Label::new(format!("{} unchanged lines", skipped_lines)) + .color(Color::Muted) + .size(LabelSize::Small) + .into_any_element(), + ) + } else { + None + } + } + + fn to_any(&self) -> &dyn Any { + self + } +} + async fn build_buffer( mut text: String, blob: Arc, @@ -409,45 +827,6 @@ async fn build_buffer_diff( }) } -fn format_commit(commit: &CommitDetails, is_stash: bool) -> String { - let mut result = String::new(); - if is_stash { - writeln!(&mut result, "stash commit {}", commit.sha).unwrap(); - } else { - writeln!(&mut result, "commit {}", commit.sha).unwrap(); - } - writeln!( - &mut result, - "Author: {} <{}>", - commit.author_name, commit.author_email - ) - .unwrap(); - let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); - writeln!( - &mut result, - "Date: {}", - time_format::format_localized_timestamp( - time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(), - time::OffsetDateTime::now_utc(), - local_offset, - time_format::TimestampFormat::MediumAbsolute, - ), - ) - .unwrap(); - result.push('\n'); - for line in commit.message.split('\n') { - if line.is_empty() { - result.push('\n'); - } else { - writeln!(&mut result, " {}", line).unwrap(); - } - } - if result.ends_with("\n\n") { - result.pop(); - } - result -} - impl EventEmitter for CommitView {} impl Focusable for CommitView { @@ -547,11 +926,11 @@ impl Item for CommitView { } fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + ToolbarItemLocation::Hidden } - fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { - self.editor.breadcrumbs(theme, cx) + fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option> { + None } fn added_to_workspace( @@ -584,197 +963,57 @@ impl Item for CommitView { .update(cx, |editor, cx| editor.clone(window, cx)) }); let multibuffer = editor.read(cx).buffer().clone(); + let processed_message = if let Some(ref remote) = self.remote { + Self::process_github_issues(&self.commit.message, remote) + } else { + self.commit.message.to_string() + }; + let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); Self { editor, multibuffer, commit: self.commit.clone(), stash: self.stash, + repository: self.repository.clone(), + remote: self.remote.clone(), + markdown, } }))) } } impl Render for CommitView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_stash = self.stash.is_some(); div() .key_context(if is_stash { "StashDiff" } else { "CommitDiff" }) .bg(cx.theme().colors().editor_background) .flex() - .items_center() - .justify_center() + .flex_col() .size_full() - .child(self.editor.clone()) + .child(self.render_header(window, cx)) + .child(div().flex_grow().child(self.editor.clone())) } } pub struct CommitViewToolbar { commit_view: Option>, - workspace: WeakEntity, } impl CommitViewToolbar { - pub fn new(workspace: &Workspace, _: &mut Context) -> Self { - Self { - commit_view: None, - workspace: workspace.weak_handle(), - } - } - - fn commit_view(&self, _: &App) -> Option> { - self.commit_view.as_ref()?.upgrade() - } - - async fn close_commit_view( - commit_view: Entity, - workspace: WeakEntity, - cx: &mut AsyncWindowContext, - ) -> anyhow::Result<()> { - workspace - .update_in(cx, |workspace, window, cx| { - let active_pane = workspace.active_pane(); - let commit_view_id = commit_view.entity_id(); - active_pane.update(cx, |pane, cx| { - pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx) - }) - })? - .await?; - anyhow::Ok(()) - } - - fn apply_stash(&mut self, window: &mut Window, cx: &mut Context) { - self.stash_action( - "Apply", - window, - cx, - async move |repository, sha, stash, commit_view, workspace, cx| { - let result = repository.update(cx, |repo, cx| { - if !stash_matches_index(&sha, stash, repo) { - return Err(anyhow::anyhow!("Stash has changed, not applying")); - } - Ok(repo.stash_apply(Some(stash), cx)) - })?; - - match result { - Ok(task) => task.await?, - Err(err) => { - Self::close_commit_view(commit_view, workspace, cx).await?; - return Err(err); - } - }; - Self::close_commit_view(commit_view, workspace, cx).await?; - anyhow::Ok(()) - }, - ); - } - - fn pop_stash(&mut self, window: &mut Window, cx: &mut Context) { - self.stash_action( - "Pop", - window, - cx, - async move |repository, sha, stash, commit_view, workspace, cx| { - let result = repository.update(cx, |repo, cx| { - if !stash_matches_index(&sha, stash, repo) { - return Err(anyhow::anyhow!("Stash has changed, pop aborted")); - } - Ok(repo.stash_pop(Some(stash), cx)) - })?; - - match result { - Ok(task) => task.await?, - Err(err) => { - Self::close_commit_view(commit_view, workspace, cx).await?; - return Err(err); - } - }; - Self::close_commit_view(commit_view, workspace, cx).await?; - anyhow::Ok(()) - }, - ); + pub fn new() -> Self { + Self { commit_view: None } } +} - fn remove_stash(&mut self, window: &mut Window, cx: &mut Context) { - self.stash_action( - "Drop", - window, - cx, - async move |repository, sha, stash, commit_view, workspace, cx| { - let result = repository.update(cx, |repo, cx| { - if !stash_matches_index(&sha, stash, repo) { - return Err(anyhow::anyhow!("Stash has changed, drop aborted")); - } - Ok(repo.stash_drop(Some(stash), cx)) - })?; - - match result { - Ok(task) => task.await??, - Err(err) => { - Self::close_commit_view(commit_view, workspace, cx).await?; - return Err(err); - } - }; - Self::close_commit_view(commit_view, workspace, cx).await?; - anyhow::Ok(()) - }, - ); - } - - fn stash_action( - &mut self, - str_action: &str, - window: &mut Window, - cx: &mut Context, - callback: AsyncFn, - ) where - AsyncFn: AsyncFnOnce( - Entity, - &SharedString, - usize, - Entity, - WeakEntity, - &mut AsyncWindowContext, - ) -> anyhow::Result<()> - + 'static, - { - let Some(commit_view) = self.commit_view(cx) else { - return; - }; - let Some(stash) = commit_view.read(cx).stash else { - return; - }; - let sha = commit_view.read(cx).commit.sha.clone(); - let answer = window.prompt( - PromptLevel::Info, - &format!("{} stash@{{{}}}?", str_action, stash), - None, - &[str_action, "Cancel"], - cx, - ); - - let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { - if answer.await != Ok(0) { - return anyhow::Ok(()); - } - let repo = workspace.update(cx, |workspace, cx| { - workspace - .panel::(cx) - .and_then(|p| p.read(cx).active_repository.clone()) - })?; +impl EventEmitter for CommitViewToolbar {} - let Some(repo) = repo else { - return Ok(()); - }; - callback(repo, &sha, stash, commit_view, workspace, cx).await?; - anyhow::Ok(()) - }) - .detach_and_notify_err(window, cx); +impl Render for CommitViewToolbar { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() } } -impl EventEmitter for CommitViewToolbar {} - impl ToolbarItemView for CommitViewToolbar { fn set_active_pane_item( &mut self, @@ -800,84 +1039,124 @@ impl ToolbarItemView for CommitViewToolbar { } } -impl Render for CommitViewToolbar { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(commit_view) = self.commit_view(cx) else { - return div(); - }; +fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool { + repo.stash_entries + .entries + .get(stash_index) + .map(|entry| entry.oid.to_string() == sha) + .unwrap_or(false) +} - let is_stash = commit_view.read(cx).stash.is_some(); - if !is_stash { - return div(); +fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let colors = cx.theme().colors(); + let mut style = MarkdownStyle::default(); + style.base_text_style = window.text_style(); + style.syntax = cx.theme().syntax().clone(); + style.selection_background_color = colors.element_selection_background; + style.link = TextStyleRefinement { + color: Some(colors.text_accent), + underline: Some(UnderlineStyle { + thickness: px(1.0), + color: Some(colors.text_accent), + wavy: false, + }), + ..Default::default() + }; + style +} + +#[cfg(test)] +mod tests { + use super::*; + use git_hosting_providers::Github; + + fn create_test_remote() -> GitRemote { + GitRemote { + host: Arc::new(Github::public_instance()), + owner: "zed-industries".into(), + repo: "zed".into(), } + } - let focus_handle = commit_view.focus_handle(cx); - - h_group_xl().my_neg_1().py_1().items_center().child( - h_group_sm() - .child( - Button::new("apply-stash", "Apply") - .tooltip(Tooltip::for_action_title_in( - "Apply current stash", - &ApplyCurrentStash, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| this.apply_stash(window, cx))), - ) - .child( - Button::new("pop-stash", "Pop") - .tooltip(Tooltip::for_action_title_in( - "Pop current stash", - &PopCurrentStash, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| this.pop_stash(window, cx))), - ) - .child( - Button::new("remove-stash", "Remove") - .icon(IconName::Trash) - .tooltip(Tooltip::for_action_title_in( - "Remove current stash", - &DropCurrentStash, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| this.remove_stash(window, cx))), - ), - ) + #[test] + fn test_process_github_issues_simple_issue_number() { + let remote = create_test_remote(); + let message = "Fix bug #123"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!( + result, + "Fix bug [#123](https://github.com/zed-industries/zed/issues/123)" + ); } -} -fn register_workspace_action( - workspace: &mut Workspace, - callback: fn(&mut CommitViewToolbar, &A, &mut Window, &mut Context), -) { - workspace.register_action(move |workspace, action: &A, window, cx| { - if workspace.has_active_modal(window, cx) { - cx.propagate(); - return; - } + #[test] + fn test_process_github_issues_multiple_issue_numbers() { + let remote = create_test_remote(); + let message = "Fix #123 and #456"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!( + result, + "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [#456](https://github.com/zed-industries/zed/issues/456)" + ); + } - workspace.active_pane().update(cx, |pane, cx| { - pane.toolbar().update(cx, move |workspace, cx| { - if let Some(toolbar) = workspace.item_of_type::() { - toolbar.update(cx, move |toolbar, cx| { - callback(toolbar, action, window, cx); - cx.notify(); - }); - } - }); - }) - }); -} + #[test] + fn test_process_github_issues_gh_format() { + let remote = create_test_remote(); + let message = "Fix GH-789"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!( + result, + "Fix [GH-789](https://github.com/zed-industries/zed/issues/789)" + ); + } -fn stash_matches_index(sha: &str, index: usize, repo: &mut Repository) -> bool { - match repo - .cached_stash() - .entries - .iter() - .find(|entry| entry.index == index) - { - Some(entry) => entry.oid.to_string() == sha, - None => false, + #[test] + fn test_process_github_issues_mixed_formats() { + let remote = create_test_remote(); + let message = "Fix #123 and GH-456"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!( + result, + "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [GH-456](https://github.com/zed-industries/zed/issues/456)" + ); + } + + #[test] + fn test_process_github_issues_no_issues() { + let remote = create_test_remote(); + let message = "This is a commit message without any issues"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!(result, message); + } + + #[test] + fn test_process_github_issues_hash_without_number() { + let remote = create_test_remote(); + let message = "Use # for comments"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!(result, message); + } + + #[test] + fn test_process_github_issues_consecutive_issues() { + let remote = create_test_remote(); + let message = "#123#456"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!( + result, + "[#123](https://github.com/zed-industries/zed/issues/123)[#456](https://github.com/zed-industries/zed/issues/456)" + ); + } + + #[test] + fn test_process_github_issues_multiline() { + let remote = create_test_remote(); + let message = "Fix #123\n\nThis also fixes #456"; + let result = CommitView::process_github_issues(message, &remote); + assert_eq!( + result, + "Fix [#123](https://github.com/zed-industries/zed/issues/123)\n\nThis also fixes [#456](https://github.com/zed-industries/zed/issues/456)" + ); } } 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..d2bac8cdeda4ce41c35906d25eec470e4de00bad --- /dev/null +++ b/crates/git_ui/src/file_history_view.rs @@ -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, + git_store: WeakEntity, + workspace: WeakEntity, + remote: Option, + selected_entry: Option, + scroll_handle: UniformListScrollHandle, + focus_handle: FocusHandle, + loading_more: bool, + has_more: bool, +} + +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_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::(); + 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, + repository: Entity, + workspace: WeakEntity, + _project: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> 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) { + 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::(&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, + ) -> AnyElement { + let pr_number = entry + .subject + .rfind("(#") + .and_then(|start| { + let rest = &entry.subject[start + 2..]; + rest.find(')') + .and_then(|end| rest[..end].parse::().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(&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; + + fn load( + source: Self::Source, + cx: &mut App, + ) -> impl Future + 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 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( + 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 { + 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 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 { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else { + None + } + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a986c62440c0a370de8ebbf5a4e3d528010a3f0c..bc71c8249f90b6b5c2994e99077f9f27129eae6d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3698,6 +3698,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..54adc8130d78e80af5c561541efb8128f1b2a017 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,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::() 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 b0aef8f8bcc4eba3d228bfb02c803585e0b14eb8..be08ed27440ee1951a166333dccfde9aa173a9f5 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -488,6 +488,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_show); client.add_entity_request_handler(Self::handle_load_commit_diff); + client.add_entity_request_handler(Self::handle_file_history); client.add_entity_request_handler(Self::handle_checkout_files); client.add_entity_request_handler(Self::handle_open_commit_message_buffer); client.add_entity_request_handler(Self::handle_set_index_text); @@ -1057,6 +1058,30 @@ impl GitStore { }) } + pub fn file_history( + &self, + repo: &Entity, + path: RepoPath, + cx: &mut App, + ) -> Task> { + let rx = repo.update(cx, |repo, _| repo.file_history(path)); + + cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) + } + + pub fn file_history_paginated( + &self, + repo: &Entity, + path: RepoPath, + skip: usize, + limit: Option, + cx: &mut App, + ) -> Task> { + let rx = repo.update(cx, |repo, _| repo.file_history_paginated(path, skip, limit)); + + cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) + } + pub fn get_permalink_to_line( &self, buffer: &Entity, @@ -2314,6 +2339,40 @@ impl GitStore { }) } + async fn handle_file_history( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let path = RepoPath::from_proto(&envelope.payload.path)?; + let skip = envelope.payload.skip as usize; + let limit = envelope.payload.limit.map(|l| l as usize); + + let file_history = repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.file_history_paginated(path, skip, limit) + })? + .await??; + + Ok(proto::GitFileHistoryResponse { + entries: file_history + .entries + .into_iter() + .map(|entry| proto::FileHistoryEntry { + sha: entry.sha.to_string(), + subject: entry.subject.to_string(), + message: entry.message.to_string(), + commit_timestamp: entry.commit_timestamp, + author_name: entry.author_name.to_string(), + author_email: entry.author_email.to_string(), + }) + .collect(), + path: file_history.path.to_proto(), + }) + } + async fn handle_reset( this: Entity, envelope: TypedEnvelope, @@ -4016,6 +4075,55 @@ impl Repository { }) } + pub fn file_history( + &mut self, + path: RepoPath, + ) -> oneshot::Receiver> { + self.file_history_paginated(path, 0, None) + } + + pub fn file_history_paginated( + &mut self, + path: RepoPath, + skip: usize, + limit: Option, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job(None, move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.file_history_paginated(path, skip, limit).await + } + RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => { + let response = client + .request(proto::GitFileHistory { + project_id: project_id.0, + repository_id: id.to_proto(), + path: path.to_proto(), + skip: skip as u64, + limit: limit.map(|l| l as u64), + }) + .await?; + Ok(git::repository::FileHistory { + entries: response + .entries + .into_iter() + .map(|entry| git::repository::FileHistoryEntry { + sha: entry.sha.into(), + subject: entry.subject.into(), + message: entry.message.into(), + commit_timestamp: entry.commit_timestamp, + author_name: entry.author_name.into(), + author_email: entry.author_email.into(), + }) + .collect(), + path: RepoPath::from_proto(&response.path)?, + }) + } + } + }) + } + fn buffer_store(&self, cx: &App) -> Option> { Some(self.git_store.upgrade()?.read(cx).buffer_store.clone()) } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e9af8bbe3fff1f5ff7d910b6aa16e05090351777..d191b9f3fea5a7183bbcc89b751a71b00c1a31b7 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, @@ -442,6 +444,72 @@ 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) + && let Some(editor) = active_item.downcast::() + && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() + && 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(); } @@ -1010,6 +1078,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 { @@ -1060,6 +1140,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)) }) diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index a2a99a7d42cc1148dd5ed5e6a74baabf7b60908d..6ef21c2d7a1339b0893b352845d62c432243abf7 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -290,6 +290,29 @@ message GitCheckoutFiles { repeated string paths = 5; } +message GitFileHistory { + uint64 project_id = 1; + reserved 2; + uint64 repository_id = 3; + string path = 4; + uint64 skip = 5; + optional uint64 limit = 6; +} + +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; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index edcab3ef2b76c05591b4c350d90cec4ad2382f8f..39faeeac88cfc49cbaba4a777da3fb8daa015a66 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -437,14 +437,16 @@ message Envelope { OpenImageResponse open_image_response = 392; CreateImageForPeer create_image_for_peer = 393; - ExternalExtensionAgentsUpdated external_extension_agents_updated = 394; + GitFileHistory git_file_history = 397; + GitFileHistoryResponse git_file_history_response = 398; - RunGitHook run_git_hook = 395; + RunGitHook run_git_hook = 399; - GitDeleteBranch git_delete_branch = 396; // current max + GitDeleteBranch git_delete_branch = 400; + ExternalExtensionAgentsUpdated external_extension_agents_updated = 401; // current max } - reserved 87 to 88; + reserved 87 to 88, 396; reserved 102 to 103; reserved 158 to 161; reserved 164; @@ -468,6 +470,7 @@ message Envelope { reserved 270; reserved 280 to 281; reserved 332 to 333; + reserved 394 to 395; } message Hello { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 44576bc369b08213372fc894bd15fd63a66c70a8..38a994a37b6c62f7a1f078eb287f120c49b0ce82 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -294,6 +294,8 @@ messages!( (GitCheckoutFiles, Background), (GitShow, Background), (GitCommitDetails, Background), + (GitFileHistory, Background), + (GitFileHistoryResponse, Background), (SetIndexText, Background), (Push, Background), (Fetch, Background), @@ -492,6 +494,7 @@ request_messages!( (InstallExtension, Ack), (RegisterBufferWithLanguageServers, Ack), (GitShow, GitCommitDetails), + (GitFileHistory, GitFileHistoryResponse), (GitReset, Ack), (GitDeleteBranch, Ack), (GitCheckoutFiles, Ack), @@ -657,6 +660,7 @@ entity_messages!( CancelLanguageServerWork, RegisterBufferWithLanguageServers, GitShow, + GitFileHistory, GitReset, GitDeleteBranch, GitCheckoutFiles, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c1b5b791d9479844eb0c5af6f517a3af0140ccd8..41f5d970dd8a81136df522f38f9381bf208bd608 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1164,7 +1164,7 @@ fn initialize_pane( toolbar.add_item(migration_banner, window, cx); let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx)); toolbar.add_item(project_diff_toolbar, window, cx); - let commit_view_toolbar = cx.new(|cx| CommitViewToolbar::new(workspace, cx)); + let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new()); toolbar.add_item(commit_view_toolbar, window, cx); let agent_diff_toolbar = cx.new(AgentDiffToolbar::new); toolbar.add_item(agent_diff_toolbar, window, cx);