History

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)

Alvaro Parker committed 1 year ago

Commit db404fc1 parent eda7a4911 files · +461 −57Browse files

Unified · Split

11 files
  1. assets/keymaps/default-linux.json +10 −1
  2. assets/keymaps/default-macos.json +11 −1
  3. assets/keymaps/default-windows.json +11 −1
  4. crates/git/src/repository.rs +4 −4
  5. crates/git_ui/src/blame_ui.rs +6 −25
  6. crates/git_ui/src/commit_tooltip.rs +2 −1
  7. crates/git_ui/src/commit_view.rs +337 −17
  8. crates/git_ui/src/git_panel.rs +2 −1
  9. crates/git_ui/src/git_ui.rs +2 −1
  10. crates/git_ui/src/stash_picker.rs +73 −5
  11. crates/zed/src/zed.rs +3 −0
assets/keymaps/default-linux.json+10−1
@@ -1080,7 +1080,8 @@ · 1079 unmodified lines
10801080 {
10811081 "context": "StashList || (StashList > Picker > Editor)",
10821082 "bindings": {
1083− "ctrl-shift-backspace": "stash_picker::DropStashItem"
1083+ "ctrl-shift-backspace": "stash_picker::DropStashItem",
1084+ "ctrl-shift-v": "stash_picker::ShowStashItem"
10841085 }
10851086 },
10861087 {
@@ -1266,6 +1267,14 @@ · 179 unmodified lines
12661267 "ctrl-pagedown": "settings_editor::FocusNextFile"
12671268 }
12681269 },
1270+ {
1271+ "context": "StashDiff > Editor",
1272+ "bindings": {
1273+ "ctrl-space": "git::ApplyCurrentStash",
1274+ "ctrl-shift-space": "git::PopCurrentStash",
1275+ "ctrl-shift-backspace": "git::DropCurrentStash"
1276+ }
1277+ },
12691278 {
12701279 "context": "SettingsWindow > NavigationMenu",
12711280 "use_key_equivalents": true,
assets/keymaps/default-macos.json+11−1
@@ -1153,7 +1153,8 @@ · 1152 unmodified lines
11531153 "context": "StashList || (StashList > Picker > Editor)",
11541154 "use_key_equivalents": true,
11551155 "bindings": {
1156− "ctrl-shift-backspace": "stash_picker::DropStashItem"
1156+ "ctrl-shift-backspace": "stash_picker::DropStashItem",
1157+ "ctrl-shift-v": "stash_picker::ShowStashItem"
11571158 }
11581159 },
11591160 {
@@ -1371,6 +1372,15 @@ · 211 unmodified lines
13711372 "cmd-}": "settings_editor::FocusNextFile"
13721373 }
13731374 },
1375+ {
1376+ "context": "StashDiff > Editor",
1377+ "use_key_equivalents": true,
1378+ "bindings": {
1379+ "ctrl-space": "git::ApplyCurrentStash",
1380+ "ctrl-shift-space": "git::PopCurrentStash",
1381+ "ctrl-shift-backspace": "git::DropCurrentStash"
1382+ }
1383+ },
13741384 {
13751385 "context": "SettingsWindow > NavigationMenu",
13761386 "use_key_equivalents": true,
assets/keymaps/default-windows.json+11−1
@@ -1106,7 +1106,8 @@ · 1105 unmodified lines
11061106 "context": "StashList || (StashList > Picker > Editor)",
11071107 "use_key_equivalents": true,
11081108 "bindings": {
1109− "ctrl-shift-backspace": "stash_picker::DropStashItem"
1109+ "ctrl-shift-backspace": "stash_picker::DropStashItem",
1110+ "ctrl-shift-v": "stash_picker::ShowStashItem"
11101111 }
11111112 },
11121113 {
@@ -1294,6 +1295,15 @@ · 181 unmodified lines
12941295 "ctrl-pagedown": "settings_editor::FocusNextFile"
12951296 }
12961297 },
1298+ {
1299+ "context": "StashDiff > Editor",
1300+ "use_key_equivalents": true,
1301+ "bindings": {
1302+ "ctrl-space": "git::ApplyCurrentStash",
1303+ "ctrl-shift-space": "git::PopCurrentStash",
1304+ "ctrl-shift-backspace": "git::DropCurrentStash"
1305+ }
1306+ },
12971307 {
12981308 "context": "SettingsWindow > NavigationMenu",
12991309 "use_key_equivalents": true,
crates/git/src/repository.rs+4−4
@@ -693,10 +693,11 @@ impl GitRepository for RealGitRepository { · 692 unmodified lines
693693 .args([
694694 "--no-optional-locks",
695695 "show",
696− "--format=%P",
696+ "--format=",
697697 "-z",
698698 "--no-renames",
699699 "--name-status",
700+ "--first-parent",
700701 ])
701702 .arg(&commit)
702703 .stdin(Stdio::null())
@@ -707,9 +708,8 @@ impl GitRepository for RealGitRepository { · 4 unmodified lines
707708 .context("starting git show process")?;
708709
709710 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
710− let mut lines = show_stdout.split('\n');
711− let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
712− let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
711+ let changes = parse_git_diff_name_status(&show_stdout);
712+ let parent_sha = format!("{}^", commit);
713713
714714 let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
715715 .current_dir(&working_directory)
crates/git_ui/src/blame_ui.rs+6−25
@@ -98,25 +98,10 @@ impl BlameRenderer for GitBlameRenderer { · 97 unmodified lines
9898 let workspace = workspace.clone();
9999 move |_, window, cx| {
100100 CommitView::open(
101− CommitSummary {
102− sha: blame_entry.sha.to_string().into(),
103− subject: blame_entry
104− .summary
105− .clone()
106− .unwrap_or_default()
107− .into(),
108− commit_timestamp: blame_entry
109− .committer_time
110− .unwrap_or_default(),
111− author_name: blame_entry
112− .committer_name
113− .clone()
114− .unwrap_or_default()
115− .into(),
116− has_parent: true,
117− },
101+ blame_entry.sha.to_string(),
118102 repository.downgrade(),
119103 workspace.clone(),
104+ None,
120105 window,
121106 cx,
122107 )
@@ -335,9 +320,10 @@ impl BlameRenderer for GitBlameRenderer { · 212 unmodified lines
335320 .icon_size(IconSize::Small)
336321 .on_click(move |_, window, cx| {
337322 CommitView::open(
338− commit_summary.clone(),
323+ commit_summary.sha.clone().into(),
339324 repository.downgrade(),
340325 workspace.clone(),
326+ None,
341327 window,
342328 cx,
343329 );
@@ -374,15 +360,10 @@ impl BlameRenderer for GitBlameRenderer { · 30 unmodified lines
374360 cx: &mut App,
375361 ) {
376362 CommitView::open(
377− CommitSummary {
378− sha: blame_entry.sha.to_string().into(),
379− subject: blame_entry.summary.clone().unwrap_or_default().into(),
380− commit_timestamp: blame_entry.committer_time.unwrap_or_default(),
381− author_name: blame_entry.committer_name.unwrap_or_default().into(),
382− has_parent: true,
383− },
363+ blame_entry.sha.to_string(),
384364 repository.downgrade(),
385365 workspace,
366+ None,
386367 window,
387368 cx,
388369 )
crates/git_ui/src/commit_tooltip.rs+2−1
@@ -318,9 +318,10 @@ impl Render for CommitTooltip { · 317 unmodified lines
318318 .on_click(
319319 move |_, window, cx| {
320320 CommitView::open(
321− commit_summary.clone(),
321+ commit_summary.sha.to_string(),
322322 repo.downgrade(),
323323 workspace.clone(),
324+ None,
324325 window,
325326 cx,
326327 );
crates/git_ui/src/commit_view.rs+337−17
@@ -1,10 +1,11 @@
11 use anyhow::{Context as _, Result};
22 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
33 use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines};
4−use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath};
4+use git::repository::{CommitDetails, CommitDiff, RepoPath};
55 use gpui::{
6− AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
7− FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window,
6+ Action, AnyElement, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, Context,
7+ Entity, EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, WeakEntity,
8+ Window, actions,
89 };
910 use language::{
1011 Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
@@ -18,17 +19,42 @@ use std::{ · 7 unmodified lines
1819 path::PathBuf,
1920 sync::Arc,
2021 };
21−use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
22+use ui::{
23+ Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*,
24+};
2225 use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
2326 use workspace::{
24− Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
27+ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
28+ Workspace,
2529 item::{BreadcrumbText, ItemEvent, TabContentParams},
30+ notifications::NotifyTaskExt,
31+ pane::SaveIntent,
2632 searchable::SearchableItemHandle,
2733 };
2834
35+use crate::git_panel::GitPanel;
36+
37+actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]);
38+
39+pub fn init(cx: &mut App) {
40+ cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
41+ register_workspace_action(workspace, |toolbar, _: &ApplyCurrentStash, window, cx| {
42+ toolbar.apply_stash(window, cx);
43+ });
44+ register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| {
45+ toolbar.remove_stash(window, cx);
46+ });
47+ register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| {
48+ toolbar.pop_stash(window, cx);
49+ });
50+ })
51+ .detach();
52+}
53+
2954 pub struct CommitView {
3055 commit: CommitDetails,
3156 editor: Entity<Editor>,
57+ stash: Option<usize>,
3258 multibuffer: Entity<MultiBuffer>,
3359 }
3460
@@ -48,17 +74,18 @@ const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; · 13 unmodified lines
4874
4975 impl CommitView {
5076 pub fn open(
51− commit: CommitSummary,
77+ commit_sha: String,
5278 repo: WeakEntity<Repository>,
5379 workspace: WeakEntity<Workspace>,
80+ stash: Option<usize>,
5481 window: &mut Window,
5582 cx: &mut App,
5683 ) {
5784 let commit_diff = repo
58− .update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string()))
85+ .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone()))
5986 .ok();
6087 let commit_details = repo
61− .update(cx, |repo, _| repo.show(commit.sha.to_string()))
88+ .update(cx, |repo, _| repo.show(commit_sha.clone()))
6289 .ok();
6390
6491 window
@@ -77,6 +104,7 @@ impl CommitView { · 12 unmodified lines
77104 commit_diff,
78105 repo,
79106 project.clone(),
107+ stash,
80108 window,
81109 cx,
82110 )
@@ -87,7 +115,7 @@ impl CommitView { · 4 unmodified lines
87115 let ix = pane.items().position(|item| {
88116 let commit_view = item.downcast::<CommitView>();
89117 commit_view
90− .is_some_and(|view| view.read(cx).commit.sha == commit.sha)
118+ .is_some_and(|view| view.read(cx).commit.sha == commit_sha)
91119 });
92120 if let Some(ix) = ix {
93121 pane.activate_item(ix, true, true, window, cx);
@@ -106,6 +134,7 @@ impl CommitView { · 12 unmodified lines
106134 commit_diff: CommitDiff,
107135 repository: Entity<Repository>,
108136 project: Entity<Project>,
137+ stash: Option<usize>,
109138 window: &mut Window,
110139 cx: &mut Context<Self>,
111140 ) -> Self {
@@ -127,10 +156,13 @@ impl CommitView { · 15 unmodified lines
127156
128157 let mut metadata_buffer_id = None;
129158 if let Some(worktree_id) = first_worktree_id {
159+ let title = if let Some(stash) = stash {
160+ format!("stash@{{{}}}", stash)
161+ } else {
162+ format!("commit {}", commit.sha)
163+ };
130164 let file = Arc::new(CommitMetadataFile {
131− title: RelPath::unix(&format!("commit {}", commit.sha))
132− .unwrap()
133− .into(),
165+ title: RelPath::unix(&title).unwrap().into(),
134166 worktree_id,
135167 });
136168 let buffer = cx.new(|cx| {
@@ -138,7 +170,7 @@ impl CommitView { · 1 unmodified line
138170 ReplicaId::LOCAL,
139171 cx.entity_id().as_non_zero_u64().into(),
140172 LineEnding::default(),
141− format_commit(&commit).into(),
173+ format_commit(&commit, stash.is_some()).into(),
142174 );
143175 metadata_buffer_id = Some(buffer.remote_id());
144176 Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite)
@@ -211,6 +243,7 @@ impl CommitView { · 66 unmodified lines
211243 commit,
212244 editor,
213245 multibuffer,
246+ stash,
214247 }
215248 }
216249 }
@@ -369,9 +402,13 @@ async fn build_buffer_diff( · 152 unmodified lines
369402 })
370403 }
371404
372−fn format_commit(commit: &CommitDetails) -> String {
405+fn format_commit(commit: &CommitDetails, is_stash: bool) -> String {
373406 let mut result = String::new();
374− writeln!(&mut result, "commit {}", commit.sha).unwrap();
407+ if is_stash {
408+ writeln!(&mut result, "stash commit {}", commit.sha).unwrap();
409+ } else {
410+ writeln!(&mut result, "commit {}", commit.sha).unwrap();
411+ }
375412 writeln!(
376413 &mut result,
377414 "Author: {} <{}>",
@@ -538,13 +575,296 @@ impl Item for CommitView { · 160 unmodified lines
538575 editor,
539576 multibuffer,
540577 commit: self.commit.clone(),
578+ stash: self.stash,
541579 }
542580 }))
543581 }
544582 }
545583
546584 impl Render for CommitView {
547− fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
548− self.editor.clone()
585+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
586+ let is_stash = self.stash.is_some();
587+ div()
588+ .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
589+ .bg(cx.theme().colors().editor_background)
590+ .flex()
591+ .items_center()
592+ .justify_center()
593+ .size_full()
594+ .child(self.editor.clone())
595+ }
596+}
597+
598+pub struct CommitViewToolbar {
599+ commit_view: Option<WeakEntity<CommitView>>,
600+ workspace: WeakEntity<Workspace>,
601+}
602+
603+impl CommitViewToolbar {
604+ pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
605+ Self {
606+ commit_view: None,
607+ workspace: workspace.weak_handle(),
608+ }
609+ }
610+
611+ fn commit_view(&self, _: &App) -> Option<Entity<CommitView>> {
612+ self.commit_view.as_ref()?.upgrade()
613+ }
614+
615+ async fn close_commit_view(
616+ commit_view: Entity<CommitView>,
617+ workspace: WeakEntity<Workspace>,
618+ cx: &mut AsyncWindowContext,
619+ ) -> anyhow::Result<()> {
620+ workspace
621+ .update_in(cx, |workspace, window, cx| {
622+ let active_pane = workspace.active_pane();
623+ let commit_view_id = commit_view.entity_id();
624+ active_pane.update(cx, |pane, cx| {
625+ pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
626+ })
627+ })?
628+ .await?;
629+ anyhow::Ok(())
630+ }
631+
632+ fn apply_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
633+ self.stash_action(
634+ "Apply",
635+ window,
636+ cx,
637+ async move |repository, sha, stash, commit_view, workspace, cx| {
638+ let result = repository.update(cx, |repo, cx| {
639+ if !stash_matches_index(&sha, stash, repo) {
640+ return Err(anyhow::anyhow!("Stash has changed, not applying"));
641+ }
642+ Ok(repo.stash_apply(Some(stash), cx))
643+ })?;
644+
645+ match result {
646+ Ok(task) => task.await?,
647+ Err(err) => {
648+ Self::close_commit_view(commit_view, workspace, cx).await?;
649+ return Err(err);
650+ }
651+ };
652+ Self::close_commit_view(commit_view, workspace, cx).await?;
653+ anyhow::Ok(())
654+ },
655+ );
656+ }
657+
658+ fn pop_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
659+ self.stash_action(
660+ "Pop",
661+ window,
662+ cx,
663+ async move |repository, sha, stash, commit_view, workspace, cx| {
664+ let result = repository.update(cx, |repo, cx| {
665+ if !stash_matches_index(&sha, stash, repo) {
666+ return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
667+ }
668+ Ok(repo.stash_pop(Some(stash), cx))
669+ })?;
670+
671+ match result {
672+ Ok(task) => task.await?,
673+ Err(err) => {
674+ Self::close_commit_view(commit_view, workspace, cx).await?;
675+ return Err(err);
676+ }
677+ };
678+ Self::close_commit_view(commit_view, workspace, cx).await?;
679+ anyhow::Ok(())
680+ },
681+ );
682+ }
683+
684+ fn remove_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
685+ self.stash_action(
686+ "Drop",
687+ window,
688+ cx,
689+ async move |repository, sha, stash, commit_view, workspace, cx| {
690+ let result = repository.update(cx, |repo, cx| {
691+ if !stash_matches_index(&sha, stash, repo) {
692+ return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
693+ }
694+ Ok(repo.stash_drop(Some(stash), cx))
695+ })?;
696+
697+ match result {
698+ Ok(task) => task.await??,
699+ Err(err) => {
700+ Self::close_commit_view(commit_view, workspace, cx).await?;
701+ return Err(err);
702+ }
703+ };
704+ Self::close_commit_view(commit_view, workspace, cx).await?;
705+ anyhow::Ok(())
706+ },
707+ );
708+ }
709+
710+ fn stash_action<AsyncFn>(
711+ &mut self,
712+ str_action: &str,
713+ window: &mut Window,
714+ cx: &mut Context<Self>,
715+ callback: AsyncFn,
716+ ) where
717+ AsyncFn: AsyncFnOnce(
718+ Entity<Repository>,
719+ &SharedString,
720+ usize,
721+ Entity<CommitView>,
722+ WeakEntity<Workspace>,
723+ &mut AsyncWindowContext,
724+ ) -> anyhow::Result<()>
725+ + 'static,
726+ {
727+ let Some(commit_view) = self.commit_view(cx) else {
728+ return;
729+ };
730+ let Some(stash) = commit_view.read(cx).stash else {
731+ return;
732+ };
733+ let sha = commit_view.read(cx).commit.sha.clone();
734+ let answer = window.prompt(
735+ PromptLevel::Info,
736+ &format!("{} stash@{{{}}}?", str_action, stash),
737+ None,
738+ &[str_action, "Cancel"],
739+ cx,
740+ );
741+
742+ let workspace = self.workspace.clone();
743+ cx.spawn_in(window, async move |_, cx| {
744+ if answer.await != Ok(0) {
745+ return anyhow::Ok(());
746+ }
747+ let repo = workspace.update(cx, |workspace, cx| {
748+ workspace
749+ .panel::<GitPanel>(cx)
750+ .and_then(|p| p.read(cx).active_repository.clone())
751+ })?;
752+
753+ let Some(repo) = repo else {
754+ return Ok(());
755+ };
756+ callback(repo, &sha, stash, commit_view, workspace, cx).await?;
757+ anyhow::Ok(())
758+ })
759+ .detach_and_notify_err(window, cx);
760+ }
761+}
762+
763+impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
764+
765+impl ToolbarItemView for CommitViewToolbar {
766+ fn set_active_pane_item(
767+ &mut self,
768+ active_pane_item: Option<&dyn ItemHandle>,
769+ _: &mut Window,
770+ cx: &mut Context<Self>,
771+ ) -> ToolbarItemLocation {
772+ if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx))
773+ && entity.read(cx).stash.is_some()
774+ {
775+ self.commit_view = Some(entity.downgrade());
776+ return ToolbarItemLocation::PrimaryRight;
777+ }
778+ ToolbarItemLocation::Hidden
779+ }
780+
781+ fn pane_focus_update(
782+ &mut self,
783+ _pane_focused: bool,
784+ _window: &mut Window,
785+ _cx: &mut Context<Self>,
786+ ) {
787+ }
788+}
789+
790+impl Render for CommitViewToolbar {
791+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
792+ let Some(commit_view) = self.commit_view(cx) else {
793+ return div();
794+ };
795+
796+ let is_stash = commit_view.read(cx).stash.is_some();
797+ if !is_stash {
798+ return div();
799+ }
800+
801+ let focus_handle = commit_view.focus_handle(cx);
802+
803+ h_group_xl().my_neg_1().py_1().items_center().child(
804+ h_group_sm()
805+ .child(
806+ Button::new("apply-stash", "Apply")
807+ .tooltip(Tooltip::for_action_title_in(
808+ "Apply current stash",
809+ &ApplyCurrentStash,
810+ &focus_handle,
811+ ))
812+ .on_click(cx.listener(|this, _, window, cx| this.apply_stash(window, cx))),
813+ )
814+ .child(
815+ Button::new("pop-stash", "Pop")
816+ .tooltip(Tooltip::for_action_title_in(
817+ "Pop current stash",
818+ &PopCurrentStash,
819+ &focus_handle,
820+ ))
821+ .on_click(cx.listener(|this, _, window, cx| this.pop_stash(window, cx))),
822+ )
823+ .child(
824+ Button::new("remove-stash", "Remove")
825+ .icon(IconName::Trash)
826+ .tooltip(Tooltip::for_action_title_in(
827+ "Remove current stash",
828+ &DropCurrentStash,
829+ &focus_handle,
830+ ))
831+ .on_click(cx.listener(|this, _, window, cx| this.remove_stash(window, cx))),
832+ ),
833+ )
834+ }
835+}
836+
837+fn register_workspace_action<A: Action>(
838+ workspace: &mut Workspace,
839+ callback: fn(&mut CommitViewToolbar, &A, &mut Window, &mut Context<CommitViewToolbar>),
840+) {
841+ workspace.register_action(move |workspace, action: &A, window, cx| {
842+ if workspace.has_active_modal(window, cx) {
843+ cx.propagate();
844+ return;
845+ }
846+
847+ workspace.active_pane().update(cx, |pane, cx| {
848+ pane.toolbar().update(cx, move |workspace, cx| {
849+ if let Some(toolbar) = workspace.item_of_type::<CommitViewToolbar>() {
850+ toolbar.update(cx, move |toolbar, cx| {
851+ callback(toolbar, action, window, cx);
852+ cx.notify();
853+ });
854+ }
855+ });
856+ })
857+ });
858+}
859+
860+fn stash_matches_index(sha: &str, index: usize, repo: &mut Repository) -> bool {
861+ match repo
862+ .cached_stash()
863+ .entries
864+ .iter()
865+ .find(|entry| entry.index == index)
866+ {
867+ Some(entry) => entry.oid.to_string() == sha,
868+ None => false,
549869 }
550870 }
crates/git_ui/src/git_panel.rs+2−1
@@ -3611,9 +3611,10 @@ impl GitPanel { · 3610 unmodified lines
36113611 let repo = active_repository.downgrade();
36123612 move |_, window, cx| {
36133613 CommitView::open(
3614− commit.clone(),
3614+ commit.sha.to_string(),
36153615 repo.clone(),
36163616 workspace.clone(),
3617+ None,
36173618 window,
36183619 cx,
36193620 );
crates/git_ui/src/git_ui.rs+2−1
@@ -34,7 +34,7 @@ mod askpass_modal; · 33 unmodified lines
3434 pub mod branch_picker;
3535 mod commit_modal;
3636 pub mod commit_tooltip;
37−mod commit_view;
37+pub mod commit_view;
3838 mod conflict_view;
3939 pub mod file_diff_view;
4040 pub mod git_panel;
@@ -59,6 +59,7 @@ pub fn init(cx: &mut App) { · 18 unmodified lines
5959 GitPanelSettings::register(cx);
6060
6161 editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
62+ commit_view::init(cx);
6263
6364 cx.observe_new(|editor: &mut Editor, _, cx| {
6465 conflict_view::register_editor(editor, editor.buffer().clone(), cx);
crates/git_ui/src/stash_picker.rs+73−5
@@ -5,18 +5,21 @@ use git::stash::StashEntry; · 4 unmodified lines
55 use gpui::{
66 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
77 InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
8− SharedString, Styled, Subscription, Task, Window, actions, rems,
8+ SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, svg,
99 };
1010 use picker::{Picker, PickerDelegate};
1111 use project::git_store::{Repository, RepositoryEvent};
1212 use std::sync::Arc;
1313 use time::{OffsetDateTime, UtcOffset};
1414 use time_format;
15−use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
15+use ui::{
16+ ButtonLike, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*,
17+};
1618 use util::ResultExt;
1719 use workspace::notifications::DetachAndPromptErr;
1820 use workspace::{ModalView, Workspace};
1921
22+use crate::commit_view::CommitView;
2023 use crate::stash_picker;
2124
2225 actions!(
@@ -24,6 +27,8 @@ actions!( · 1 unmodified line
2427 [
2528 /// Drop the selected stash entry.
2629 DropStashItem,
30+ /// Show the diff view of the selected stash entry.
31+ ShowStashItem,
2732 ]
2833 );
2934
@@ -38,8 +43,9 @@ pub fn open( · 8 unmodified lines
3843 cx: &mut Context<Workspace>,
3944 ) {
4045 let repository = workspace.project().read(cx).active_repository(cx);
46+ let weak_workspace = workspace.weak_handle();
4147 workspace.toggle_modal(window, cx, |window, cx| {
42− StashList::new(repository, rems(34.), window, cx)
48+ StashList::new(repository, weak_workspace, rems(34.), window, cx)
4349 })
4450 }
4551
@@ -53,6 +59,7 @@ pub struct StashList { · 7 unmodified lines
5359 impl StashList {
5460 fn new(
5561 repository: Option<Entity<Repository>>,
62+ workspace: WeakEntity<Workspace>,
5663 width: Rems,
5764 window: &mut Window,
5865 cx: &mut Context<Self>,
@@ -98,7 +105,7 @@ impl StashList { · 39 unmodified lines
98105 })
99106 .detach_and_log_err(cx);
100107
101− let delegate = StashListDelegate::new(repository, window, cx);
108+ let delegate = StashListDelegate::new(repository, workspace, window, cx);
102109 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
103110 let picker_focus_handle = picker.focus_handle(cx);
104111 picker.update(cx, |picker, _| {
@@ -131,6 +138,20 @@ impl StashList { · 26 unmodified lines
131138 cx.notify();
132139 }
133140
141+ fn handle_show_stash(
142+ &mut self,
143+ _: &ShowStashItem,
144+ window: &mut Window,
145+ cx: &mut Context<Self>,
146+ ) {
147+ self.picker.update(cx, |picker, cx| {
148+ picker
149+ .delegate
150+ .show_stash_at(picker.delegate.selected_index(), window, cx);
151+ });
152+ cx.notify();
153+ }
154+
134155 fn handle_modifiers_changed(
135156 &mut self,
136157 ev: &ModifiersChangedEvent,
@@ -157,6 +178,7 @@ impl Render for StashList { · 20 unmodified lines
157178 .w(self.width)
158179 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
159180 .on_action(cx.listener(Self::handle_drop_stash))
181+ .on_action(cx.listener(Self::handle_show_stash))
160182 .child(self.picker.clone())
161183 }
162184 }
@@ -172,6 +194,7 @@ pub struct StashListDelegate { · 9 unmodified lines
172194 matches: Vec<StashEntryMatch>,
173195 all_stash_entries: Option<Vec<StashEntry>>,
174196 repo: Option<Entity<Repository>>,
197+ workspace: WeakEntity<Workspace>,
175198 selected_index: usize,
176199 last_query: String,
177200 modifiers: Modifiers,
@@ -182,6 +205,7 @@ pub struct StashListDelegate { · 4 unmodified lines
182205 impl StashListDelegate {
183206 fn new(
184207 repo: Option<Entity<Repository>>,
208+ workspace: WeakEntity<Workspace>,
185209 _window: &mut Window,
186210 cx: &mut Context<StashList>,
187211 ) -> Self {
@@ -192,6 +216,7 @@ impl StashListDelegate { · 4 unmodified lines
192216 Self {
193217 matches: vec![],
194218 repo,
219+ workspace,
195220 all_stash_entries: None,
196221 selected_index: 0,
197222 last_query: Default::default(),
@@ -235,6 +260,25 @@ impl StashListDelegate { · 37 unmodified lines
235260 });
236261 }
237262
263+ fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
264+ let Some(entry_match) = self.matches.get(ix) else {
265+ return;
266+ };
267+ let stash_sha = entry_match.entry.oid.to_string();
268+ let stash_index = entry_match.entry.index;
269+ let Some(repo) = self.repo.clone() else {
270+ return;
271+ };
272+ CommitView::open(
273+ stash_sha,
274+ repo.downgrade(),
275+ self.workspace.clone(),
276+ Some(stash_index),
277+ window,
278+ cx,
279+ );
280+ }
281+
238282 fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
239283 let Some(repo) = self.repo.clone() else {
240284 return;
@@ -390,7 +434,7 @@ impl PickerDelegate for StashListDelegate { · 149 unmodified lines
390434 ix: usize,
391435 selected: bool,
392436 _window: &mut Window,
393− _cx: &mut Context<Picker<Self>>,
437+ cx: &mut Context<Picker<Self>>,
394438 ) -> Option<Self::ListItem> {
395439 let entry_match = &self.matches[ix];
396440
@@ -432,11 +476,35 @@ impl PickerDelegate for StashListDelegate { · 35 unmodified lines
432476 .size(LabelSize::Small),
433477 );
434478
479+ let show_button = div()
480+ .group("show-button-hover")
481+ .child(
482+ ButtonLike::new("show-button")
483+ .child(
484+ svg()
485+ .size(IconSize::Medium.rems())
486+ .flex_none()
487+ .path(IconName::Eye.path())
488+ .text_color(Color::Default.color(cx))
489+ .group_hover("show-button-hover", |this| {
490+ this.text_color(Color::Accent.color(cx))
491+ })
492+ .hover(|this| this.text_color(Color::Accent.color(cx))),
493+ )
494+ .tooltip(Tooltip::for_action_title("Show Stash", &ShowStashItem))
495+ .on_click(cx.listener(move |picker, _, window, cx| {
496+ cx.stop_propagation();
497+ picker.delegate.show_stash_at(ix, window, cx);
498+ })),
499+ )
500+ .into_any_element();
501+
435502 Some(
436503 ListItem::new(SharedString::from(format!("stash-{ix}")))
437504 .inset(true)
438505 .spacing(ListItemSpacing::Sparse)
439506 .toggle_state(selected)
507+ .end_slot(show_button)
440508 .child(
441509 v_flex()
442510 .w_full()
crates/zed/src/zed.rs+3−0
@@ -25,6 +25,7 @@ use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag}; · 24 unmodified lines
2525 use fs::Fs;
2626 use futures::future::Either;
2727 use futures::{StreamExt, channel::mpsc, select_biased};
28+use git_ui::commit_view::CommitViewToolbar;
2829 use git_ui::git_panel::GitPanel;
2930 use git_ui::project_diff::ProjectDiffToolbar;
3031 use gpui::{
@@ -1049,6 +1050,8 @@ fn initialize_pane( · 1018 unmodified lines
10491050 toolbar.add_item(migration_banner, window, cx);
10501051 let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
10511052 toolbar.add_item(project_diff_toolbar, window, cx);
1053+ let commit_view_toolbar = cx.new(|cx| CommitViewToolbar::new(workspace, cx));
1054+ toolbar.add_item(commit_view_toolbar, window, cx);
10521055 let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
10531056 toolbar.add_item(agent_diff_toolbar, window, cx);
10541057 let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx));

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t