git: Add diff view for stash entries (#38280)
Continues the work from #35927 to add a git diff view for stash entries. [Screencast From 2025-09-17 19-46-01.webm](https://github.com/user-attachments/assets/ded33782-adef-4696-8e34-3665911c09c7) Stash entries are [represented as commits](https://git-scm.com/docs/git-stash#_discussion) except they have up to 3 parents: ``` .----W (this is the stash entry) / /| -----H----I | \| U ``` Where `H` is the `HEAD` commit, `I` is a commit that records the state of the index, and `U` is another commit that records untracked files (when using `git stash -u`). Given this, I modified the existing commit view struct to allow loading stash and commits entries with git sha identifier so that we can get a similar git diff view for both of them. The stash diff is generated by comparing the stash commit with its parent (`<commit>^` or `H` in the diagram) which generates the same diff as doing `git stash show -p <stash entry>`. This *can* be counter-intuitive since a user may expect the comparison to be made between the stash commit and the current commit (`HEAD`), but given that the default behavior in git cli is to compare with the stash parent, I went for that approach. Hoping to get some feedback from a Zed team member to see if they agree with this approach. Release Notes: - Add git diff view for stash entries - Add toolbar on git diff view for stash entries - Prompt before executing a destructive stash action on diff view - Fix commit view for merge commits (see #38289)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
|
||||
use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines};
|
||||
use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath};
|
||||
use git::repository::{CommitDetails, CommitDiff, RepoPath};
|
||||
use gpui::{
|
||||
AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window,
|
||||
Action, AnyElement, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, WeakEntity,
|
||||
Window, actions,
|
||||
};
|
||||
use language::{
|
||||
Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
|
||||
@@ -18,17 +19,42 @@ use std::{
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
|
||||
use ui::{
|
||||
Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
|
||||
use workspace::{
|
||||
Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
|
||||
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
|
||||
Workspace,
|
||||
item::{BreadcrumbText, ItemEvent, TabContentParams},
|
||||
notifications::NotifyTaskExt,
|
||||
pane::SaveIntent,
|
||||
searchable::SearchableItemHandle,
|
||||
};
|
||||
|
||||
use crate::git_panel::GitPanel;
|
||||
|
||||
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);
|
||||
});
|
||||
register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| {
|
||||
toolbar.remove_stash(window, cx);
|
||||
});
|
||||
register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| {
|
||||
toolbar.pop_stash(window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub struct CommitView {
|
||||
commit: CommitDetails,
|
||||
editor: Entity<Editor>,
|
||||
stash: Option<usize>,
|
||||
multibuffer: Entity<MultiBuffer>,
|
||||
}
|
||||
|
||||
@@ -48,17 +74,18 @@ const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
|
||||
|
||||
impl CommitView {
|
||||
pub fn open(
|
||||
commit: CommitSummary,
|
||||
commit_sha: String,
|
||||
repo: WeakEntity<Repository>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
stash: Option<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let commit_diff = repo
|
||||
.update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string()))
|
||||
.update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone()))
|
||||
.ok();
|
||||
let commit_details = repo
|
||||
.update(cx, |repo, _| repo.show(commit.sha.to_string()))
|
||||
.update(cx, |repo, _| repo.show(commit_sha.clone()))
|
||||
.ok();
|
||||
|
||||
window
|
||||
@@ -77,6 +104,7 @@ impl CommitView {
|
||||
commit_diff,
|
||||
repo,
|
||||
project.clone(),
|
||||
stash,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -87,7 +115,7 @@ impl CommitView {
|
||||
let ix = pane.items().position(|item| {
|
||||
let commit_view = item.downcast::<CommitView>();
|
||||
commit_view
|
||||
.is_some_and(|view| view.read(cx).commit.sha == commit.sha)
|
||||
.is_some_and(|view| view.read(cx).commit.sha == commit_sha)
|
||||
});
|
||||
if let Some(ix) = ix {
|
||||
pane.activate_item(ix, true, true, window, cx);
|
||||
@@ -106,6 +134,7 @@ impl CommitView {
|
||||
commit_diff: CommitDiff,
|
||||
repository: Entity<Repository>,
|
||||
project: Entity<Project>,
|
||||
stash: Option<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -127,10 +156,13 @@ impl CommitView {
|
||||
|
||||
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(&format!("commit {}", commit.sha))
|
||||
.unwrap()
|
||||
.into(),
|
||||
title: RelPath::unix(&title).unwrap().into(),
|
||||
worktree_id,
|
||||
});
|
||||
let buffer = cx.new(|cx| {
|
||||
@@ -138,7 +170,7 @@ impl CommitView {
|
||||
ReplicaId::LOCAL,
|
||||
cx.entity_id().as_non_zero_u64().into(),
|
||||
LineEnding::default(),
|
||||
format_commit(&commit).into(),
|
||||
format_commit(&commit, stash.is_some()).into(),
|
||||
);
|
||||
metadata_buffer_id = Some(buffer.remote_id());
|
||||
Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite)
|
||||
@@ -211,6 +243,7 @@ impl CommitView {
|
||||
commit,
|
||||
editor,
|
||||
multibuffer,
|
||||
stash,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,9 +402,13 @@ async fn build_buffer_diff(
|
||||
})
|
||||
}
|
||||
|
||||
fn format_commit(commit: &CommitDetails) -> String {
|
||||
fn format_commit(commit: &CommitDetails, is_stash: bool) -> String {
|
||||
let mut result = String::new();
|
||||
writeln!(&mut result, "commit {}", commit.sha).unwrap();
|
||||
if is_stash {
|
||||
writeln!(&mut result, "stash commit {}", commit.sha).unwrap();
|
||||
} else {
|
||||
writeln!(&mut result, "commit {}", commit.sha).unwrap();
|
||||
}
|
||||
writeln!(
|
||||
&mut result,
|
||||
"Author: {} <{}>",
|
||||
@@ -538,13 +575,296 @@ impl Item for CommitView {
|
||||
editor,
|
||||
multibuffer,
|
||||
commit: self.commit.clone(),
|
||||
stash: self.stash,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommitView {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
self.editor.clone()
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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()
|
||||
.size_full()
|
||||
.child(self.editor.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitViewToolbar {
|
||||
commit_view: Option<WeakEntity<CommitView>>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
}
|
||||
|
||||
impl CommitViewToolbar {
|
||||
pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
commit_view: None,
|
||||
workspace: workspace.weak_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_view(&self, _: &App) -> Option<Entity<CommitView>> {
|
||||
self.commit_view.as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
async fn close_commit_view(
|
||||
commit_view: Entity<CommitView>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
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>) {
|
||||
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>) {
|
||||
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(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn remove_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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<AsyncFn>(
|
||||
&mut self,
|
||||
str_action: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
callback: AsyncFn,
|
||||
) where
|
||||
AsyncFn: AsyncFnOnce(
|
||||
Entity<Repository>,
|
||||
&SharedString,
|
||||
usize,
|
||||
Entity<CommitView>,
|
||||
WeakEntity<Workspace>,
|
||||
&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::<GitPanel>(cx)
|
||||
.and_then(|p| p.read(cx).active_repository.clone())
|
||||
})?;
|
||||
|
||||
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 EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
|
||||
|
||||
impl ToolbarItemView for CommitViewToolbar {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx))
|
||||
&& entity.read(cx).stash.is_some()
|
||||
{
|
||||
self.commit_view = Some(entity.downgrade());
|
||||
return ToolbarItemLocation::PrimaryRight;
|
||||
}
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
|
||||
fn pane_focus_update(
|
||||
&mut self,
|
||||
_pane_focused: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommitViewToolbar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(commit_view) = self.commit_view(cx) else {
|
||||
return div();
|
||||
};
|
||||
|
||||
let is_stash = commit_view.read(cx).stash.is_some();
|
||||
if !is_stash {
|
||||
return div();
|
||||
}
|
||||
|
||||
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))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn register_workspace_action<A: Action>(
|
||||
workspace: &mut Workspace,
|
||||
callback: fn(&mut CommitViewToolbar, &A, &mut Window, &mut Context<CommitViewToolbar>),
|
||||
) {
|
||||
workspace.register_action(move |workspace, action: &A, window, cx| {
|
||||
if workspace.has_active_modal(window, cx) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.active_pane().update(cx, |pane, cx| {
|
||||
pane.toolbar().update(cx, move |workspace, cx| {
|
||||
if let Some(toolbar) = workspace.item_of_type::<CommitViewToolbar>() {
|
||||
toolbar.update(cx, move |toolbar, cx| {
|
||||
callback(toolbar, action, window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user