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