Add user-visible output for remote operations (#25849)

Mikayla Maki and julia created

This PR adds toasts for reporting success and errors from remote git
operations. This PR also adds a focus handle to notifications, in
anticipation of making them keyboard accessible.

Release Notes:

- N/A

---------

Co-authored-by: julia <julia@zed.dev>

Change summary

Cargo.lock                                          |   3 
assets/icons/cloud.svg                              |   1 
assets/keymaps/default-macos.json                   |   2 
crates/auto_update_ui/src/auto_update_ui.rs         |  27 
crates/breadcrumbs/src/breadcrumbs.rs               |   2 
crates/collab_ui/src/notification_panel.rs          |  14 
crates/diagnostics/src/diagnostics.rs               |   2 
crates/editor/src/code_context_menus.rs             |   2 
crates/editor/src/editor.rs                         |   4 
crates/editor/src/signature_help.rs                 |   2 
crates/extensions_ui/src/extension_suggest.rs       |  13 
crates/file_finder/src/new_path_prompt.rs           |   2 
crates/git/Cargo.toml                               |   1 
crates/git/src/repository.rs                        | 125 ++++++-
crates/git_ui/Cargo.toml                            |   2 
crates/git_ui/src/git_panel.rs                      | 162 ++++++++--
crates/git_ui/src/git_ui.rs                         |   1 
crates/git_ui/src/picker_prompt.rs                  |   7 
crates/git_ui/src/remote_output_toast.rs            | 214 +++++++++++++++
crates/gpui/src/elements/text.rs                    |  51 ++
crates/language/src/buffer.rs                       |   2 
crates/markdown_preview/src/markdown_renderer.rs    |   4 
crates/outline/src/outline.rs                       |   2 
crates/project/src/git.rs                           |  58 ++-
crates/project_symbols/src/project_symbols.rs       |   6 
crates/proto/proto/zed.proto                        |   9 
crates/proto/src/proto.rs                           |   7 
crates/rich_text/src/rich_text.rs                   |   2 
crates/storybook/src/stories/text.rs                |   2 
crates/ui/src/components/icon.rs                    |   1 
crates/ui/src/components/label/highlighted_label.rs |   2 
crates/workspace/src/modal_layer.rs                 |   2 
crates/workspace/src/notifications.rs               |  89 +++++-
crates/workspace/src/persistence.rs                 |  29 +
crates/workspace/src/workspace.rs                   |  18 
crates/zed/src/zed.rs                               |  27 +
crates/zeta/src/completion_diff_element.rs          |   2 
crates/zeta/src/zeta.rs                             |   4 
38 files changed, 712 insertions(+), 191 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5353,6 +5353,7 @@ dependencies = [
  "serde_json",
  "smol",
  "sum_tree",
+ "tempfile",
  "text",
  "time",
  "unindent",
@@ -5408,7 +5409,9 @@ dependencies = [
  "gpui",
  "itertools 0.14.0",
  "language",
+ "linkify",
  "linkme",
+ "log",
  "menu",
  "multi_buffer",
  "panel",

assets/icons/cloud.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>

assets/keymaps/default-macos.json 🔗

@@ -32,12 +32,12 @@
       "ctrl-enter": "menu::SecondaryConfirm",
       "cmd-enter": "menu::SecondaryConfirm",
       "ctrl-escape": "menu::Cancel",
-      "cmd-escape": "menu::Cancel",
       "ctrl-c": "menu::Cancel",
       "escape": "menu::Cancel",
       "alt-shift-enter": "menu::Restart",
       "cmd-shift-w": "workspace::CloseWindow",
       "shift-escape": "workspace::ToggleZoom",
+      "cmd-escape": "menu::Cancel",
       "cmd-o": "workspace::Open",
       "cmd-=": ["zed::IncreaseBufferFontSize", { "persist": false }],
       "cmd-+": ["zed::IncreaseBufferFontSize", { "persist": false }],

crates/auto_update_ui/src/auto_update_ui.rs 🔗

@@ -141,19 +141,20 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
                     cx,
                     move |cx| {
                         let workspace_handle = cx.entity().downgrade();
-                        cx.new(|_cx| {
-                            MessageNotification::new(format!("Updated to {app_name} {}", version))
-                                .primary_message("View Release Notes")
-                                .primary_on_click(move |window, cx| {
-                                    if let Some(workspace) = workspace_handle.upgrade() {
-                                        workspace.update(cx, |workspace, cx| {
-                                            crate::view_release_notes_locally(
-                                                workspace, window, cx,
-                                            );
-                                        })
-                                    }
-                                    cx.emit(DismissEvent);
-                                })
+                        cx.new(|cx| {
+                            MessageNotification::new(
+                                format!("Updated to {app_name} {}", version),
+                                cx,
+                            )
+                            .primary_message("View Release Notes")
+                            .primary_on_click(move |window, cx| {
+                                if let Some(workspace) = workspace_handle.upgrade() {
+                                    workspace.update(cx, |workspace, cx| {
+                                        crate::view_release_notes_locally(workspace, window, cx);
+                                    })
+                                }
+                                cx.emit(DismissEvent);
+                            })
                         })
                     },
                 );

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -82,7 +82,7 @@ impl Render for Breadcrumbs {
             text_style.color = Color::Muted.color(cx);
 
             StyledText::new(segment.text.replace('\n', "⏎"))
-                .with_highlights(&text_style, segment.highlights.unwrap_or_default())
+                .with_default_highlights(&text_style, segment.highlights.unwrap_or_default())
                 .into_any()
         });
         let breadcrumbs = Itertools::intersperse_with(highlighted_segments, || {

crates/collab_ui/src/notification_panel.rs 🔗

@@ -22,7 +22,7 @@ use ui::{
     h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip,
 };
 use util::{ResultExt, TryFutureExt};
-use workspace::notifications::NotificationId;
+use workspace::notifications::{Notification as WorkspaceNotification, NotificationId};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     Workspace,
@@ -570,11 +570,12 @@ impl NotificationPanel {
                 workspace.dismiss_notification(&id, cx);
                 workspace.show_notification(id, cx, |cx| {
                     let workspace = cx.entity().downgrade();
-                    cx.new(|_| NotificationToast {
+                    cx.new(|cx| NotificationToast {
                         notification_id,
                         actor,
                         text,
                         workspace,
+                        focus_handle: cx.focus_handle(),
                     })
                 })
             })
@@ -771,8 +772,17 @@ pub struct NotificationToast {
     actor: Option<Arc<User>>,
     text: String,
     workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+}
+
+impl Focusable for NotificationToast {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
 }
 
+impl WorkspaceNotification for NotificationToast {}
+
 impl NotificationToast {
     fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
         let workspace = self.workspace.clone();

crates/diagnostics/src/diagnostics.rs 🔗

@@ -995,7 +995,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
                         h_flex()
                             .gap_1()
                             .child(
-                                StyledText::new(message.clone()).with_highlights(
+                                StyledText::new(message.clone()).with_default_highlights(
                                     &cx.window.text_style(),
                                     code_ranges
                                         .iter()

crates/editor/src/code_context_menus.rs 🔗

@@ -514,7 +514,7 @@ impl CompletionsMenu {
                         );
 
                         let completion_label = StyledText::new(completion.label.text.clone())
-                            .with_highlights(&style.text, highlights);
+                            .with_default_highlights(&style.text, highlights);
                         let documentation_label = if let Some(
                             CompletionDocumentation::SingleLine(text),
                         ) = documentation

crates/editor/src/editor.rs 🔗

@@ -6781,7 +6781,7 @@ impl Editor {
                 .first_line_preview();
 
                 let styled_text = gpui::StyledText::new(highlighted_edits.text)
-                    .with_highlights(&style.text, highlighted_edits.highlights);
+                    .with_default_highlights(&style.text, highlighted_edits.highlights);
 
                 let preview = h_flex()
                     .gap_1()
@@ -18007,7 +18007,7 @@ pub fn diagnostic_block_renderer(
             )
             .child(buttons(&diagnostic))
             .child(div().flex().flex_shrink_0().child(
-                StyledText::new(text_without_backticks.clone()).with_highlights(
+                StyledText::new(text_without_backticks.clone()).with_default_highlights(
                     &text_style,
                     code_ranges.iter().map(|range| {
                         (

crates/editor/src/signature_help.rs 🔗

@@ -310,7 +310,7 @@ impl SignatureHelpPopover {
             .child(
                 div().px_4().pb_1().child(
                     StyledText::new(self.label.clone())
-                        .with_highlights(&self.style, self.highlights.iter().cloned()),
+                        .with_default_highlights(&self.style, self.highlights.iter().cloned()),
                 ),
             )
             .into_any_element()

crates/extensions_ui/src/extension_suggest.rs 🔗

@@ -168,11 +168,14 @@ pub(crate) fn suggest(buffer: Entity<Buffer>, window: &mut Window, cx: &mut Cont
         );
 
         workspace.show_notification(notification_id, cx, |cx| {
-            cx.new(move |_cx| {
-                MessageNotification::new(format!(
-                    "Do you want to install the recommended '{}' extension for '{}' files?",
-                    extension_id, file_name_or_extension
-                ))
+            cx.new(move |cx| {
+                MessageNotification::new(
+                    format!(
+                        "Do you want to install the recommended '{}' extension for '{}' files?",
+                        extension_id, file_name_or_extension
+                    ),
+                    cx,
+                )
                 .primary_message("Yes, install extension")
                 .primary_icon(IconName::Check)
                 .primary_icon_color(Color::Success)

crates/file_finder/src/new_path_prompt.rs 🔗

@@ -192,7 +192,7 @@ impl Match {
             }
         }
 
-        StyledText::new(text).with_highlights(&window.text_style().clone(), highlights)
+        StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights)
     }
 }
 

crates/git/Cargo.toml 🔗

@@ -34,6 +34,7 @@ text.workspace = true
 time.workspace = true
 url.workspace = true
 util.workspace = true
+tempfile.workspace = true
 
 [dev-dependencies]
 pretty_assertions.workspace = true

crates/git/src/repository.rs 🔗

@@ -11,6 +11,8 @@ use schemars::JsonSchema;
 use serde::Deserialize;
 use std::borrow::Borrow;
 use std::io::Write as _;
+#[cfg(not(windows))]
+use std::os::unix::fs::PermissionsExt;
 use std::process::Stdio;
 use std::sync::LazyLock;
 use std::{
@@ -61,6 +63,12 @@ pub enum UpstreamTracking {
     Tracked(UpstreamTrackingStatus),
 }
 
+impl From<UpstreamTrackingStatus> for UpstreamTracking {
+    fn from(status: UpstreamTrackingStatus) -> Self {
+        UpstreamTracking::Tracked(status)
+    }
+}
+
 impl UpstreamTracking {
     pub fn is_gone(&self) -> bool {
         matches!(self, UpstreamTracking::Gone)
@@ -74,9 +82,15 @@ impl UpstreamTracking {
     }
 }
 
-impl From<UpstreamTrackingStatus> for UpstreamTracking {
-    fn from(status: UpstreamTrackingStatus) -> Self {
-        UpstreamTracking::Tracked(status)
+#[derive(Debug)]
+pub struct RemoteCommandOutput {
+    pub stdout: String,
+    pub stderr: String,
+}
+
+impl RemoteCommandOutput {
+    pub fn is_empty(&self) -> bool {
+        self.stdout.is_empty() && self.stderr.is_empty()
     }
 }
 
@@ -185,10 +199,10 @@ pub trait GitRepository: Send + Sync {
         branch_name: &str,
         upstream_name: &str,
         options: Option<PushOptions>,
-    ) -> Result<()>;
-    fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<()>;
+    ) -> Result<RemoteCommandOutput>;
+    fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<RemoteCommandOutput>;
     fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
-    fn fetch(&self) -> Result<()>;
+    fn fetch(&self) -> Result<RemoteCommandOutput>;
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
@@ -611,19 +625,30 @@ impl GitRepository for RealGitRepository {
         branch_name: &str,
         remote_name: &str,
         options: Option<PushOptions>,
-    ) -> Result<()> {
+    ) -> Result<RemoteCommandOutput> {
         let working_directory = self.working_directory()?;
 
-        let output = new_std_command("git")
+        // We do this on every operation to ensure that the askpass script exists and is executable.
+        #[cfg(not(windows))]
+        let (askpass_script_path, _temp_dir) = setup_askpass()?;
+
+        let mut command = new_std_command("git");
+        command
             .current_dir(&working_directory)
-            .args(["push", "--quiet"])
+            .args(["push"])
             .args(options.map(|option| match option {
                 PushOptions::SetUpstream => "--set-upstream",
                 PushOptions::Force => "--force-with-lease",
             }))
             .arg(remote_name)
-            .arg(format!("{}:{}", branch_name, branch_name))
-            .output()?;
+            .arg(format!("{}:{}", branch_name, branch_name));
+
+        #[cfg(not(windows))]
+        {
+            command.env("GIT_ASKPASS", askpass_script_path);
+        }
+
+        let output = command.output()?;
 
         if !output.status.success() {
             return Err(anyhow!(
@@ -631,19 +656,33 @@ impl GitRepository for RealGitRepository {
                 String::from_utf8_lossy(&output.stderr)
             ));
         } else {
-            Ok(())
+            return Ok(RemoteCommandOutput {
+                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+            });
         }
     }
 
-    fn pull(&self, branch_name: &str, remote_name: &str) -> Result<()> {
+    fn pull(&self, branch_name: &str, remote_name: &str) -> Result<RemoteCommandOutput> {
         let working_directory = self.working_directory()?;
 
-        let output = new_std_command("git")
+        // We do this on every operation to ensure that the askpass script exists and is executable.
+        #[cfg(not(windows))]
+        let (askpass_script_path, _temp_dir) = setup_askpass()?;
+
+        let mut command = new_std_command("git");
+        command
             .current_dir(&working_directory)
-            .args(["pull", "--quiet"])
+            .args(["pull"])
             .arg(remote_name)
-            .arg(branch_name)
-            .output()?;
+            .arg(branch_name);
+
+        #[cfg(not(windows))]
+        {
+            command.env("GIT_ASKPASS", askpass_script_path);
+        }
+
+        let output = command.output()?;
 
         if !output.status.success() {
             return Err(anyhow!(
@@ -651,17 +690,31 @@ impl GitRepository for RealGitRepository {
                 String::from_utf8_lossy(&output.stderr)
             ));
         } else {
-            return Ok(());
+            return Ok(RemoteCommandOutput {
+                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+            });
         }
     }
 
-    fn fetch(&self) -> Result<()> {
+    fn fetch(&self) -> Result<RemoteCommandOutput> {
         let working_directory = self.working_directory()?;
 
-        let output = new_std_command("git")
+        // We do this on every operation to ensure that the askpass script exists and is executable.
+        #[cfg(not(windows))]
+        let (askpass_script_path, _temp_dir) = setup_askpass()?;
+
+        let mut command = new_std_command("git");
+        command
             .current_dir(&working_directory)
-            .args(["fetch", "--quiet", "--all"])
-            .output()?;
+            .args(["fetch", "--all"]);
+
+        #[cfg(not(windows))]
+        {
+            command.env("GIT_ASKPASS", askpass_script_path);
+        }
+
+        let output = command.output()?;
 
         if !output.status.success() {
             return Err(anyhow!(
@@ -669,7 +722,10 @@ impl GitRepository for RealGitRepository {
                 String::from_utf8_lossy(&output.stderr)
             ));
         } else {
-            return Ok(());
+            return Ok(RemoteCommandOutput {
+                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+            });
         }
     }
 
@@ -716,6 +772,18 @@ impl GitRepository for RealGitRepository {
     }
 }
 
+#[cfg(not(windows))]
+fn setup_askpass() -> Result<(PathBuf, tempfile::TempDir), anyhow::Error> {
+    let temp_dir = tempfile::Builder::new()
+        .prefix("zed-git-askpass")
+        .tempdir()?;
+    let askpass_script = "#!/bin/sh\necho ''";
+    let askpass_script_path = temp_dir.path().join("git-askpass.sh");
+    std::fs::write(&askpass_script_path, askpass_script)?;
+    std::fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755))?;
+    Ok((askpass_script_path, temp_dir))
+}
+
 #[derive(Debug, Clone)]
 pub struct FakeGitRepository {
     state: Arc<Mutex<FakeGitRepositoryState>>,
@@ -899,15 +967,20 @@ impl GitRepository for FakeGitRepository {
         unimplemented!()
     }
 
-    fn push(&self, _branch: &str, _remote: &str, _options: Option<PushOptions>) -> Result<()> {
+    fn push(
+        &self,
+        _branch: &str,
+        _remote: &str,
+        _options: Option<PushOptions>,
+    ) -> Result<RemoteCommandOutput> {
         unimplemented!()
     }
 
-    fn pull(&self, _branch: &str, _remote: &str) -> Result<()> {
+    fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
         unimplemented!()
     }
 
-    fn fetch(&self) -> Result<()> {
+    fn fetch(&self) -> Result<RemoteCommandOutput> {
         unimplemented!()
     }
 

crates/git_ui/Cargo.toml 🔗

@@ -30,7 +30,9 @@ git.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true
+linkify.workspace = true
 linkme.workspace = true
+log.workspace = true
 menu.workspace = true
 multi_buffer.workspace = true
 panel.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,5 +1,6 @@
 use crate::branch_picker::{self, BranchList};
 use crate::git_panel_settings::StatusStyle;
+use crate::remote_output_toast::{RemoteAction, RemoteOutputToast};
 use crate::repository_selector::RepositorySelectorPopoverMenu;
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@@ -12,8 +13,8 @@ use editor::{
     ShowScrollbar,
 };
 use git::repository::{
-    Branch, CommitDetails, CommitSummary, PushOptions, Remote, ResetMode, Upstream,
-    UpstreamTracking, UpstreamTrackingStatus,
+    Branch, CommitDetails, CommitSummary, PushOptions, Remote, RemoteCommandOutput, ResetMode,
+    Upstream, UpstreamTracking, UpstreamTrackingStatus,
 };
 use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
 use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
@@ -43,6 +44,7 @@ use ui::{
     PopoverButton, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
 };
 use util::{maybe, post_inc, ResultExt, TryFutureExt};
+
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotificationId},
@@ -283,6 +285,7 @@ impl GitPanel {
             let commit_editor = cx.new(|cx| {
                 commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
             });
+
             commit_editor.update(cx, |editor, cx| {
                 editor.clear(window, cx);
             });
@@ -1330,62 +1333,114 @@ impl GitPanel {
         };
         let guard = self.start_remote_operation();
         let fetch = repo.read(cx).fetch();
-        cx.spawn(|_, _| async move {
-            fetch.await??;
+        cx.spawn(|this, mut cx| async move {
+            let remote_message = fetch.await?;
             drop(guard);
+            this.update(&mut cx, |this, cx| {
+                match remote_message {
+                    Ok(remote_message) => {
+                        this.show_remote_output(RemoteAction::Fetch, remote_message, cx);
+                    }
+                    Err(e) => {
+                        this.show_err_toast(e, cx);
+                    }
+                }
+
+                anyhow::Ok(())
+            })
+            .ok();
             anyhow::Ok(())
         })
         .detach_and_log_err(cx);
     }
 
     fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(repo) = self.active_repository.clone() else {
+            return;
+        };
+        let Some(branch) = repo.read(cx).current_branch() else {
+            return;
+        };
+        let branch = branch.clone();
         let guard = self.start_remote_operation();
         let remote = self.get_current_remote(window, cx);
         cx.spawn(move |this, mut cx| async move {
-            let remote = remote.await?;
+            let remote = match remote.await {
+                Ok(Some(remote)) => remote,
+                Ok(None) => {
+                    return Ok(());
+                }
+                Err(e) => {
+                    log::error!("Failed to get current remote: {}", e);
+                    this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
+                        .ok();
+                    return Ok(());
+                }
+            };
 
-            this.update(&mut cx, |this, cx| {
-                let Some(repo) = this.active_repository.clone() else {
-                    return Err(anyhow::anyhow!("No active repository"));
-                };
+            let pull = repo.update(&mut cx, |repo, _cx| {
+                repo.pull(branch.name.clone(), remote.name.clone())
+            })?;
 
-                let Some(branch) = repo.read(cx).current_branch() else {
-                    return Err(anyhow::anyhow!("No active branch"));
-                };
+            let remote_message = pull.await?;
+            drop(guard);
 
-                Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
-            })??
-            .await??;
+            this.update(&mut cx, |this, cx| match remote_message {
+                Ok(remote_message) => {
+                    this.show_remote_output(RemoteAction::Pull, remote_message, cx)
+                }
+                Err(err) => this.show_err_toast(err, cx),
+            })
+            .ok();
 
-            drop(guard);
             anyhow::Ok(())
         })
         .detach_and_log_err(cx);
     }
 
     fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(repo) = self.active_repository.clone() else {
+            return;
+        };
+        let Some(branch) = repo.read(cx).current_branch() else {
+            return;
+        };
+        let branch = branch.clone();
         let guard = self.start_remote_operation();
         let options = action.options;
         let remote = self.get_current_remote(window, cx);
-        cx.spawn(move |this, mut cx| async move {
-            let remote = remote.await?;
 
-            this.update(&mut cx, |this, cx| {
-                let Some(repo) = this.active_repository.clone() else {
-                    return Err(anyhow::anyhow!("No active repository"));
-                };
+        cx.spawn(move |this, mut cx| async move {
+            let remote = match remote.await {
+                Ok(Some(remote)) => remote,
+                Ok(None) => {
+                    return Ok(());
+                }
+                Err(e) => {
+                    log::error!("Failed to get current remote: {}", e);
+                    this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
+                        .ok();
+                    return Ok(());
+                }
+            };
 
-                let Some(branch) = repo.read(cx).current_branch() else {
-                    return Err(anyhow::anyhow!("No active branch"));
-                };
+            let push = repo.update(&mut cx, |repo, _cx| {
+                repo.push(branch.name.clone(), remote.name.clone(), options)
+            })?;
 
-                Ok(repo
-                    .read(cx)
-                    .push(branch.name.clone(), remote.name, options))
-            })??
-            .await??;
+            let remote_output = push.await?;
 
             drop(guard);
+
+            this.update(&mut cx, |this, cx| match remote_output {
+                Ok(remote_message) => {
+                    this.show_remote_output(RemoteAction::Push(remote), remote_message, cx);
+                }
+                Err(e) => {
+                    this.show_err_toast(e, cx);
+                }
+            })?;
+
             anyhow::Ok(())
         })
         .detach_and_log_err(cx);
@@ -1395,7 +1450,7 @@ impl GitPanel {
         &mut self,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> impl Future<Output = Result<Remote>> {
+    ) -> impl Future<Output = Result<Option<Remote>>> {
         let repo = self.active_repository.clone();
         let workspace = self.workspace.clone();
         let mut cx = window.to_async(cx);
@@ -1418,7 +1473,7 @@ impl GitPanel {
             if current_remotes.len() == 0 {
                 return Err(anyhow::anyhow!("No active remote"));
             } else if current_remotes.len() == 1 {
-                return Ok(current_remotes.pop().unwrap());
+                return Ok(Some(current_remotes.pop().unwrap()));
             } else {
                 let current_remotes: Vec<_> = current_remotes
                     .into_iter()
@@ -1436,9 +1491,9 @@ impl GitPanel {
                     })?
                     .await?;
 
-                return Ok(Remote {
+                Ok(selection.map(|selection| Remote {
                     name: current_remotes[selection].clone(),
-                });
+                }))
             }
         }
     }
@@ -1789,16 +1844,40 @@ impl GitPanel {
         };
         let notif_id = NotificationId::Named("git-operation-error".into());
 
-        let message = e.to_string();
-        workspace.update(cx, |workspace, cx| {
-            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
+        let mut message = e.to_string().trim().to_string();
+        let toast;
+        if message.matches("Authentication failed").count() >= 1 {
+            message = format!(
+                "{}\n\n{}",
+                message, "Please set your credentials via the CLI"
+            );
+            toast = Toast::new(notif_id, message);
+        } else {
+            toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
                 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
             });
+        }
+        workspace.update(cx, |workspace, cx| {
             workspace.show_toast(toast, cx);
         });
     }
 
-    fn render_spinner(&self) -> Option<impl IntoElement> {
+    fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let notification_id = NotificationId::Named("git-remote-info".into());
+
+        workspace.update(cx, |workspace, cx| {
+            workspace.show_notification(notification_id.clone(), cx, |cx| {
+                let workspace = cx.weak_entity();
+                cx.new(|cx| RemoteOutputToast::new(action, info, notification_id, workspace, cx))
+            });
+        });
+    }
+
+    pub fn render_spinner(&self) -> Option<impl IntoElement> {
         (!self.pending_remote_operations.borrow().is_empty()).then(|| {
             Icon::new(IconName::ArrowCircle)
                 .size(IconSize::XSmall)
@@ -2274,9 +2353,10 @@ impl GitPanel {
         let Some(repo) = self.active_repository.clone() else {
             return Task::ready(Err(anyhow::anyhow!("no active repo")));
         };
-
-        let show = repo.read(cx).show(sha);
-        cx.spawn(|_, _| async move { show.await? })
+        repo.update(cx, |repo, cx| {
+            let show = repo.show(sha);
+            cx.spawn(|_, _| async move { show.await? })
+        })
     }
 
     fn deploy_entry_context_menu(

crates/git_ui/src/git_ui.rs 🔗

@@ -11,6 +11,7 @@ pub mod git_panel;
 mod git_panel_settings;
 pub mod picker_prompt;
 pub mod project_diff;
+mod remote_output_toast;
 pub mod repository_selector;
 
 pub fn init(cx: &mut App) {

crates/git_ui/src/picker_prompt.rs 🔗

@@ -26,7 +26,7 @@ pub fn prompt(
     workspace: WeakEntity<Workspace>,
     window: &mut Window,
     cx: &mut App,
-) -> Task<Result<usize>> {
+) -> Task<Result<Option<usize>>> {
     if options.is_empty() {
         return Task::ready(Err(anyhow!("No options")));
     }
@@ -43,7 +43,10 @@ pub fn prompt(
             })
         })?;
 
-        rx.await?
+        match rx.await {
+            Ok(selection) => Some(selection).transpose(),
+            Err(_) => anyhow::Ok(None), // User cancelled
+        }
     })
 }
 

crates/git_ui/src/remote_output_toast.rs 🔗

@@ -0,0 +1,214 @@
+use std::{ops::Range, time::Duration};
+
+use git::repository::{Remote, RemoteCommandOutput};
+use gpui::{
+    DismissEvent, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveText,
+    StyledText, Task, UnderlineStyle, WeakEntity,
+};
+use itertools::Itertools;
+use linkify::{LinkFinder, LinkKind};
+use ui::{
+    div, h_flex, px, v_flex, vh, Clickable, Color, Context, FluentBuilder, Icon, IconButton,
+    IconName, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
+    Render, SharedString, Styled, StyledExt, Window,
+};
+use workspace::{
+    notifications::{Notification, NotificationId},
+    Workspace,
+};
+
+pub enum RemoteAction {
+    Fetch,
+    Pull,
+    Push(Remote),
+}
+
+struct InfoFromRemote {
+    name: SharedString,
+    remote_text: SharedString,
+    links: Vec<Range<usize>>,
+}
+
+pub struct RemoteOutputToast {
+    _workspace: WeakEntity<Workspace>,
+    _id: NotificationId,
+    message: SharedString,
+    remote_info: Option<InfoFromRemote>,
+    _dismiss_task: Task<()>,
+    focus_handle: FocusHandle,
+}
+
+impl Focusable for RemoteOutputToast {
+    fn focus_handle(&self, _cx: &ui::App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Notification for RemoteOutputToast {}
+
+const REMOTE_OUTPUT_TOAST_SECONDS: u64 = 5;
+
+impl RemoteOutputToast {
+    pub fn new(
+        action: RemoteAction,
+        output: RemoteCommandOutput,
+        id: NotificationId,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let task = cx.spawn({
+            let workspace = workspace.clone();
+            let id = id.clone();
+            |_, mut cx| async move {
+                cx.background_executor()
+                    .timer(Duration::from_secs(REMOTE_OUTPUT_TOAST_SECONDS))
+                    .await;
+                workspace
+                    .update(&mut cx, |workspace, cx| {
+                        workspace.dismiss_notification(&id, cx);
+                    })
+                    .ok();
+            }
+        });
+
+        let message;
+        let remote;
+
+        match action {
+            RemoteAction::Fetch | RemoteAction::Pull => {
+                if output.is_empty() {
+                    message = "Up to date".into();
+                } else {
+                    message = output.stderr.into();
+                }
+                remote = None;
+            }
+
+            RemoteAction::Push(remote_ref) => {
+                message = output.stdout.trim().to_string().into();
+                let remote_message = get_remote_lines(&output.stderr);
+                let finder = LinkFinder::new();
+                let links = finder
+                    .links(&remote_message)
+                    .filter(|link| *link.kind() == LinkKind::Url)
+                    .map(|link| link.start()..link.end())
+                    .collect_vec();
+
+                remote = Some(InfoFromRemote {
+                    name: remote_ref.name,
+                    remote_text: remote_message.into(),
+                    links,
+                });
+            }
+        }
+
+        Self {
+            _workspace: workspace,
+            _id: id,
+            message,
+            remote_info: remote,
+            _dismiss_task: task,
+            focus_handle: cx.focus_handle(),
+        }
+    }
+}
+
+impl Render for RemoteOutputToast {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
+        div()
+            .occlude()
+            .w_full()
+            .max_h(vh(0.8, window))
+            .elevation_3(cx)
+            .child(
+                v_flex()
+                    .p_3()
+                    .overflow_hidden()
+                    .child(
+                        h_flex()
+                            .justify_between()
+                            .items_start()
+                            .child(
+                                h_flex()
+                                    .gap_2()
+                                    .child(Icon::new(IconName::GitBranch).color(Color::Default))
+                                    .child(Label::new("Git")),
+                            )
+                            .child(h_flex().child(
+                                IconButton::new("close", IconName::Close).on_click(
+                                    cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)),
+                                ),
+                            )),
+                    )
+                    .child(Label::new(self.message.clone()).size(LabelSize::Default))
+                    .when_some(self.remote_info.as_ref(), |this, remote_info| {
+                        this.child(
+                            div()
+                                .border_1()
+                                .border_color(Color::Muted.color(cx))
+                                .rounded_lg()
+                                .text_sm()
+                                .mt_1()
+                                .p_1()
+                                .child(
+                                    h_flex()
+                                        .gap_2()
+                                        .child(Icon::new(IconName::Cloud).color(Color::Default))
+                                        .child(
+                                            Label::new(remote_info.name.clone())
+                                                .size(LabelSize::Default),
+                                        ),
+                                )
+                                .map(|div| {
+                                    let styled_text =
+                                        StyledText::new(remote_info.remote_text.clone())
+                                            .with_highlights(remote_info.links.iter().map(
+                                                |link| {
+                                                    (
+                                                        link.clone(),
+                                                        HighlightStyle {
+                                                            underline: Some(UnderlineStyle {
+                                                                thickness: px(1.0),
+                                                                ..Default::default()
+                                                            }),
+                                                            ..Default::default()
+                                                        },
+                                                    )
+                                                },
+                                            ));
+                                    let this = cx.weak_entity();
+                                    let text = InteractiveText::new("remote-message", styled_text)
+                                        .on_click(
+                                            remote_info.links.clone(),
+                                            move |ix, _window, cx| {
+                                                this.update(cx, |this, cx| {
+                                                    if let Some(remote_info) = &this.remote_info {
+                                                        cx.open_url(
+                                                            &remote_info.remote_text
+                                                                [remote_info.links[ix].clone()],
+                                                        )
+                                                    }
+                                                })
+                                                .ok();
+                                            },
+                                        );
+
+                                    div.child(text)
+                                }),
+                        )
+                    }),
+            )
+    }
+}
+
+impl EventEmitter<DismissEvent> for RemoteOutputToast {}
+
+fn get_remote_lines(output: &str) -> String {
+    output
+        .lines()
+        .filter_map(|line| line.strip_prefix("remote:"))
+        .map(|line| line.trim())
+        .filter(|line| !line.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n")
+}

crates/gpui/src/elements/text.rs 🔗

@@ -137,6 +137,7 @@ impl IntoElement for SharedString {
 pub struct StyledText {
     text: SharedString,
     runs: Option<Vec<TextRun>>,
+    delayed_highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,
     layout: TextLayout,
 }
 
@@ -146,6 +147,7 @@ impl StyledText {
         StyledText {
             text: text.into(),
             runs: None,
+            delayed_highlights: None,
             layout: TextLayout::default(),
         }
     }
@@ -157,11 +159,39 @@ impl StyledText {
 
     /// Set the styling attributes for the given text, as well as
     /// as any ranges of text that have had their style customized.
-    pub fn with_highlights(
+    pub fn with_default_highlights(
         mut self,
         default_style: &TextStyle,
         highlights: impl IntoIterator<Item = (Range<usize>, HighlightStyle)>,
     ) -> Self {
+        debug_assert!(
+            self.delayed_highlights.is_none(),
+            "Can't use `with_default_highlights` and `with_highlights`"
+        );
+        let runs = Self::compute_runs(&self.text, default_style, highlights);
+        self.runs = Some(runs);
+        self
+    }
+
+    /// Set the styling attributes for the given text, as well as
+    /// as any ranges of text that have had their style customized.
+    pub fn with_highlights(
+        mut self,
+        highlights: impl IntoIterator<Item = (Range<usize>, HighlightStyle)>,
+    ) -> Self {
+        debug_assert!(
+            self.runs.is_none(),
+            "Can't use `with_highlights` and `with_default_highlights`"
+        );
+        self.delayed_highlights = Some(highlights.into_iter().collect::<Vec<_>>());
+        self
+    }
+
+    fn compute_runs(
+        text: &str,
+        default_style: &TextStyle,
+        highlights: impl IntoIterator<Item = (Range<usize>, HighlightStyle)>,
+    ) -> Vec<TextRun> {
         let mut runs = Vec::new();
         let mut ix = 0;
         for (range, highlight) in highlights {
@@ -176,11 +206,10 @@ impl StyledText {
             );
             ix = range.end;
         }
-        if ix < self.text.len() {
-            runs.push(default_style.to_run(self.text.len() - ix));
+        if ix < text.len() {
+            runs.push(default_style.to_run(text.len() - ix));
         }
-        self.runs = Some(runs);
-        self
+        runs
     }
 
     /// Set the text runs for this piece of text.
@@ -200,15 +229,17 @@ impl Element for StyledText {
 
     fn request_layout(
         &mut self,
-
         _id: Option<&GlobalElementId>,
-
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        let layout_id = self
-            .layout
-            .layout(self.text.clone(), self.runs.take(), window, cx);
+        let runs = self.runs.take().or_else(|| {
+            self.delayed_highlights.take().map(|delayed_highlights| {
+                Self::compute_runs(&self.text, &window.text_style(), delayed_highlights)
+            })
+        });
+
+        let layout_id = self.layout.layout(self.text.clone(), runs, window, cx);
         (layout_id, ())
     }
 

crates/language/src/buffer.rs 🔗

@@ -590,7 +590,7 @@ impl HighlightedText {
 
     pub fn to_styled_text(&self, default_style: &TextStyle) -> StyledText {
         gpui::StyledText::new(self.text.clone())
-            .with_highlights(default_style, self.highlights.iter().cloned())
+            .with_default_highlights(default_style, self.highlights.iter().cloned())
     }
 
     /// Returns the first line without leading whitespace unless highlighted

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -370,7 +370,7 @@ fn render_markdown_code_block(
     cx: &mut RenderContext,
 ) -> AnyElement {
     let body = if let Some(highlights) = parsed.highlights.as_ref() {
-        StyledText::new(parsed.contents.clone()).with_highlights(
+        StyledText::new(parsed.contents.clone()).with_default_highlights(
             &cx.buffer_text_style,
             highlights.iter().filter_map(|(range, highlight_id)| {
                 highlight_id
@@ -468,7 +468,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
                         InteractiveText::new(
                             element_id,
                             StyledText::new(parsed.contents.clone())
-                                .with_highlights(&text_style, highlights),
+                                .with_default_highlights(&text_style, highlights),
                         )
                         .tooltip({
                             let links = links.clone();

crates/outline/src/outline.rs 🔗

@@ -359,7 +359,7 @@ pub fn render_item<T>(
         outline_item.highlight_ranges.iter().cloned(),
     );
 
-    StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights)
+    StyledText::new(outline_item.text.clone()).with_default_highlights(&text_style, highlights)
 }
 
 #[cfg(test)]

crates/project/src/git.rs 🔗

@@ -5,7 +5,7 @@ use anyhow::{Context as _, Result};
 use client::ProjectId;
 use futures::channel::{mpsc, oneshot};
 use futures::StreamExt as _;
-use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode};
+use git::repository::{Branch, CommitDetails, PushOptions, Remote, RemoteCommandOutput, ResetMode};
 use git::{
     repository::{GitRepository, RepoPath},
     status::{GitSummary, TrackedSummary},
@@ -265,23 +265,27 @@ impl GitStore {
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::Fetch>,
         mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
+    ) -> Result<proto::RemoteMessageResponse> {
         let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
         let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
         let repository_handle =
             Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
 
-        repository_handle
+        let remote_output = repository_handle
             .update(&mut cx, |repository_handle, _cx| repository_handle.fetch())?
             .await??;
-        Ok(proto::Ack {})
+
+        Ok(proto::RemoteMessageResponse {
+            stdout: remote_output.stdout,
+            stderr: remote_output.stderr,
+        })
     }
 
     async fn handle_push(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::Push>,
         mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
+    ) -> Result<proto::RemoteMessageResponse> {
         let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
         let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
         let repository_handle =
@@ -299,19 +303,22 @@ impl GitStore {
         let branch_name = envelope.payload.branch_name.into();
         let remote_name = envelope.payload.remote_name.into();
 
-        repository_handle
+        let remote_output = repository_handle
             .update(&mut cx, |repository_handle, _cx| {
                 repository_handle.push(branch_name, remote_name, options)
             })?
             .await??;
-        Ok(proto::Ack {})
+        Ok(proto::RemoteMessageResponse {
+            stdout: remote_output.stdout,
+            stderr: remote_output.stderr,
+        })
     }
 
     async fn handle_pull(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::Pull>,
         mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
+    ) -> Result<proto::RemoteMessageResponse> {
         let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
         let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
         let repository_handle =
@@ -320,12 +327,15 @@ impl GitStore {
         let branch_name = envelope.payload.branch_name.into();
         let remote_name = envelope.payload.remote_name.into();
 
-        repository_handle
+        let remote_message = repository_handle
             .update(&mut cx, |repository_handle, _cx| {
                 repository_handle.pull(branch_name, remote_name)
             })?
             .await??;
-        Ok(proto::Ack {})
+        Ok(proto::RemoteMessageResponse {
+            stdout: remote_message.stdout,
+            stderr: remote_message.stderr,
+        })
     }
 
     async fn handle_stage(
@@ -1086,7 +1096,7 @@ impl Repository {
         })
     }
 
-    pub fn fetch(&self) -> oneshot::Receiver<Result<()>> {
+    pub fn fetch(&self) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
         self.send_job(|git_repo| async move {
             match git_repo {
                 GitRepo::Local(git_repository) => git_repository.fetch(),
@@ -1096,7 +1106,7 @@ impl Repository {
                     worktree_id,
                     work_directory_id,
                 } => {
-                    client
+                    let response = client
                         .request(proto::Fetch {
                             project_id: project_id.0,
                             worktree_id: worktree_id.to_proto(),
@@ -1105,7 +1115,10 @@ impl Repository {
                         .await
                         .context("sending fetch request")?;
 
-                    Ok(())
+                    Ok(RemoteCommandOutput {
+                        stdout: response.stdout,
+                        stderr: response.stderr,
+                    })
                 }
             }
         })
@@ -1116,7 +1129,7 @@ impl Repository {
         branch: SharedString,
         remote: SharedString,
         options: Option<PushOptions>,
-    ) -> oneshot::Receiver<Result<()>> {
+    ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
         self.send_job(move |git_repo| async move {
             match git_repo {
                 GitRepo::Local(git_repository) => git_repository.push(&branch, &remote, options),
@@ -1126,7 +1139,7 @@ impl Repository {
                     worktree_id,
                     work_directory_id,
                 } => {
-                    client
+                    let response = client
                         .request(proto::Push {
                             project_id: project_id.0,
                             worktree_id: worktree_id.to_proto(),
@@ -1141,7 +1154,10 @@ impl Repository {
                         .await
                         .context("sending push request")?;
 
-                    Ok(())
+                    Ok(RemoteCommandOutput {
+                        stdout: response.stdout,
+                        stderr: response.stderr,
+                    })
                 }
             }
         })
@@ -1151,7 +1167,7 @@ impl Repository {
         &self,
         branch: SharedString,
         remote: SharedString,
-    ) -> oneshot::Receiver<Result<()>> {
+    ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
         self.send_job(|git_repo| async move {
             match git_repo {
                 GitRepo::Local(git_repository) => git_repository.pull(&branch, &remote),
@@ -1161,7 +1177,7 @@ impl Repository {
                     worktree_id,
                     work_directory_id,
                 } => {
-                    client
+                    let response = client
                         .request(proto::Pull {
                             project_id: project_id.0,
                             worktree_id: worktree_id.to_proto(),
@@ -1172,8 +1188,10 @@ impl Repository {
                         .await
                         .context("sending pull request")?;
 
-                    // TODO: wire through remote
-                    Ok(())
+                    Ok(RemoteCommandOutput {
+                        stdout: response.stdout,
+                        stderr: response.stderr,
+                    })
                 }
             }
         })

crates/project_symbols/src/project_symbols.rs 🔗

@@ -252,8 +252,10 @@ impl PickerDelegate for ProjectSymbolsDelegate {
                     v_flex()
                         .child(
                             LabelLike::new().child(
-                                StyledText::new(label)
-                                    .with_highlights(&window.text_style().clone(), highlights),
+                                StyledText::new(label).with_default_highlights(
+                                    &window.text_style().clone(),
+                                    highlights,
+                                ),
                             ),
                         )
                         .child(Label::new(path).color(Color::Muted)),

crates/proto/proto/zed.proto 🔗

@@ -330,7 +330,9 @@ message Envelope {
         Pull pull = 308;
 
         ApplyCodeActionKind apply_code_action_kind = 309;
-        ApplyCodeActionKindResponse apply_code_action_kind_response = 310; // current max
+        ApplyCodeActionKindResponse apply_code_action_kind_response = 310;
+
+        RemoteMessageResponse remote_message_response = 311; // current max
     }
 
     reserved 87 to 88;
@@ -2834,3 +2836,8 @@ message Pull {
     string remote_name = 4;
     string branch_name = 5;
 }
+
+message RemoteMessageResponse {
+    string stdout = 1;
+    string stderr = 2;
+}

crates/proto/src/proto.rs 🔗

@@ -451,6 +451,7 @@ messages!(
     (GetRemotes, Background),
     (GetRemotesResponse, Background),
     (Pull, Background),
+    (RemoteMessageResponse, Background),
 );
 
 request_messages!(
@@ -589,10 +590,10 @@ request_messages!(
     (GitReset, Ack),
     (GitCheckoutFiles, Ack),
     (SetIndexText, Ack),
-    (Push, Ack),
-    (Fetch, Ack),
+    (Push, RemoteMessageResponse),
+    (Fetch, RemoteMessageResponse),
     (GetRemotes, GetRemotesResponse),
-    (Pull, Ack),
+    (Pull, RemoteMessageResponse),
 );
 
 entity_messages!(

crates/rich_text/src/rich_text.rs 🔗

@@ -96,7 +96,7 @@ impl RichText {
 
         InteractiveText::new(
             id,
-            StyledText::new(self.text.clone()).with_highlights(
+            StyledText::new(self.text.clone()).with_default_highlights(
                 &window.text_style(),
                 self.highlights.iter().map(|(range, highlight)| {
                     (

crates/storybook/src/stories/text.rs 🔗

@@ -81,7 +81,7 @@ impl Render for TextStory {
                             "Interactive Text",
                             InteractiveText::new(
                                 "interactive",
-                                StyledText::new("Hello world, how is it going?").with_highlights(
+                                StyledText::new("Hello world, how is it going?").with_default_highlights(
                                     &window.text_style(),
                                     [
                                         (

crates/ui/src/components/label/highlighted_label.rs 🔗

@@ -126,6 +126,6 @@ impl RenderOnce for HighlightedLabel {
         text_style.color = self.base.color.color(cx);
 
         self.base
-            .child(StyledText::new(self.label).with_highlights(&text_style, highlights))
+            .child(StyledText::new(self.label).with_default_highlights(&text_style, highlights))
     }
 }

crates/workspace/src/modal_layer.rs 🔗

@@ -112,7 +112,7 @@ impl ModalLayer {
         cx.notify();
     }
 
-    fn hide_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+    pub fn hide_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
         let Some(active_modal) = self.active_modal.as_mut() else {
             self.dismiss_on_focus_lost = false;
             return false;

crates/workspace/src/notifications.rs 🔗

@@ -1,14 +1,34 @@
 use crate::{Toast, Workspace};
 use gpui::{
     svg, AnyView, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
-    Entity, EventEmitter, PromptLevel, Render, ScrollHandle, Task,
+    Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task,
 };
 use parking_lot::Mutex;
+use std::ops::Deref;
 use std::sync::{Arc, LazyLock};
 use std::{any::TypeId, time::Duration};
 use ui::{prelude::*, Tooltip};
 use util::ResultExt;
 
+#[derive(Default)]
+pub struct Notifications {
+    notifications: Vec<(NotificationId, AnyView)>,
+}
+
+impl Deref for Notifications {
+    type Target = Vec<(NotificationId, AnyView)>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.notifications
+    }
+}
+
+impl std::ops::DerefMut for Notifications {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.notifications
+    }
+}
+
 #[derive(Debug, PartialEq, Clone)]
 pub enum NotificationId {
     Unique(TypeId),
@@ -34,9 +54,7 @@ impl NotificationId {
     }
 }
 
-pub trait Notification: EventEmitter<DismissEvent> + Render {}
-
-impl<V: EventEmitter<DismissEvent> + Render> Notification for V {}
+pub trait Notification: EventEmitter<DismissEvent> + Focusable + Render {}
 
 impl Workspace {
     #[cfg(any(test, feature = "test-support"))]
@@ -89,7 +107,7 @@ impl Workspace {
         E: std::fmt::Debug + std::fmt::Display,
     {
         self.show_notification(workspace_error_notification_id(), cx, |cx| {
-            cx.new(|_| ErrorMessagePrompt::new(format!("Error: {err}")))
+            cx.new(|cx| ErrorMessagePrompt::new(format!("Error: {err}"), cx))
         });
     }
 
@@ -97,8 +115,8 @@ impl Workspace {
         struct PortalError;
 
         self.show_notification(NotificationId::unique::<PortalError>(), cx, |cx| {
-            cx.new(|_| {
-                ErrorMessagePrompt::new(err.to_string()).with_link_button(
+            cx.new(|cx| {
+                ErrorMessagePrompt::new(err.to_string(), cx).with_link_button(
                     "See docs",
                     "https://zed.dev/docs/linux#i-cant-open-any-files",
                 )
@@ -120,14 +138,16 @@ impl Workspace {
     pub fn show_toast(&mut self, toast: Toast, cx: &mut Context<Self>) {
         self.dismiss_notification(&toast.id, cx);
         self.show_notification(toast.id.clone(), cx, |cx| {
-            cx.new(|_| match toast.on_click.as_ref() {
+            cx.new(|cx| match toast.on_click.as_ref() {
                 Some((click_msg, on_click)) => {
                     let on_click = on_click.clone();
-                    simple_message_notification::MessageNotification::new(toast.msg.clone())
+                    simple_message_notification::MessageNotification::new(toast.msg.clone(), cx)
                         .primary_message(click_msg.clone())
                         .primary_on_click(move |window, cx| on_click(window, cx))
                 }
-                None => simple_message_notification::MessageNotification::new(toast.msg.clone()),
+                None => {
+                    simple_message_notification::MessageNotification::new(toast.msg.clone(), cx)
+                }
             })
         });
         if toast.autohide {
@@ -171,13 +191,23 @@ impl Workspace {
 }
 
 pub struct LanguageServerPrompt {
+    focus_handle: FocusHandle,
     request: Option<project::LanguageServerPromptRequest>,
     scroll_handle: ScrollHandle,
 }
 
+impl Focusable for LanguageServerPrompt {
+    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Notification for LanguageServerPrompt {}
+
 impl LanguageServerPrompt {
-    pub fn new(request: project::LanguageServerPromptRequest) -> Self {
+    pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self {
         Self {
+            focus_handle: cx.focus_handle(),
             request: Some(request),
             scroll_handle: ScrollHandle::new(),
         }
@@ -286,16 +316,18 @@ fn workspace_error_notification_id() -> NotificationId {
 #[derive(Debug, Clone)]
 pub struct ErrorMessagePrompt {
     message: SharedString,
+    focus_handle: gpui::FocusHandle,
     label_and_url_button: Option<(SharedString, SharedString)>,
 }
 
 impl ErrorMessagePrompt {
-    pub fn new<S>(message: S) -> Self
+    pub fn new<S>(message: S, cx: &mut App) -> Self
     where
         S: Into<SharedString>,
     {
         Self {
             message: message.into(),
+            focus_handle: cx.focus_handle(),
             label_and_url_button: None,
         }
     }
@@ -364,17 +396,29 @@ impl Render for ErrorMessagePrompt {
     }
 }
 
+impl Focusable for ErrorMessagePrompt {
+    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
 impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
 
+impl Notification for ErrorMessagePrompt {}
+
 pub mod simple_message_notification {
     use std::sync::Arc;
 
     use gpui::{
-        div, AnyElement, DismissEvent, EventEmitter, ParentElement, Render, SharedString, Styled,
+        div, AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render,
+        SharedString, Styled,
     };
     use ui::prelude::*;
 
+    use super::Notification;
+
     pub struct MessageNotification {
+        focus_handle: FocusHandle,
         build_content: Box<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
         primary_message: Option<SharedString>,
         primary_icon: Option<IconName>,
@@ -390,18 +434,28 @@ pub mod simple_message_notification {
         title: Option<SharedString>,
     }
 
+    impl Focusable for MessageNotification {
+        fn focus_handle(&self, _: &App) -> FocusHandle {
+            self.focus_handle.clone()
+        }
+    }
+
     impl EventEmitter<DismissEvent> for MessageNotification {}
 
+    impl Notification for MessageNotification {}
+
     impl MessageNotification {
-        pub fn new<S>(message: S) -> MessageNotification
+        pub fn new<S>(message: S, cx: &mut App) -> MessageNotification
         where
             S: Into<SharedString>,
         {
             let message = message.into();
-            Self::new_from_builder(move |_, _| Label::new(message.clone()).into_any_element())
+            Self::new_from_builder(cx, move |_, _| {
+                Label::new(message.clone()).into_any_element()
+            })
         }
 
-        pub fn new_from_builder<F>(content: F) -> MessageNotification
+        pub fn new_from_builder<F>(cx: &mut App, content: F) -> MessageNotification
         where
             F: 'static + Fn(&mut Window, &mut Context<Self>) -> AnyElement,
         {
@@ -419,6 +473,7 @@ pub mod simple_message_notification {
                 more_info_url: None,
                 show_close_button: true,
                 title: None,
+                focus_handle: cx.focus_handle(),
             }
         }
 
@@ -769,7 +824,7 @@ where
                     move |cx| {
                         cx.new({
                             let message = message.clone();
-                            move |_cx| ErrorMessagePrompt::new(message)
+                            move |cx| ErrorMessagePrompt::new(message, cx)
                         })
                     }
                 });

crates/workspace/src/persistence.rs 🔗

@@ -1156,11 +1156,14 @@ impl WorkspaceDb {
 
 #[cfg(test)]
 mod tests {
+    use std::thread;
+    use std::time::Duration;
+
     use super::*;
     use crate::persistence::model::SerializedWorkspace;
     use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
     use db::open_test_db;
-    use gpui::{self};
+    use gpui;
 
     #[gpui::test]
     async fn test_next_id_stability() {
@@ -1556,31 +1559,33 @@ mod tests {
         };
 
         db.save_workspace(workspace_1.clone()).await;
+        thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
         db.save_workspace(workspace_2.clone()).await;
         db.save_workspace(workspace_3.clone()).await;
+        thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
         db.save_workspace(workspace_4.clone()).await;
         db.save_workspace(workspace_5.clone()).await;
         db.save_workspace(workspace_6.clone()).await;
 
         let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
         assert_eq!(locations.len(), 2);
-        assert_eq!(locations[0].0, LocalPaths::new(["/tmp1"]));
+        assert_eq!(locations[0].0, LocalPaths::new(["/tmp2"]));
         assert_eq!(locations[0].1, LocalPathsOrder::new([0]));
-        assert_eq!(locations[0].2, Some(10));
-        assert_eq!(locations[1].0, LocalPaths::new(["/tmp2"]));
+        assert_eq!(locations[0].2, Some(20));
+        assert_eq!(locations[1].0, LocalPaths::new(["/tmp1"]));
         assert_eq!(locations[1].1, LocalPathsOrder::new([0]));
-        assert_eq!(locations[1].2, Some(20));
+        assert_eq!(locations[1].2, Some(10));
 
         let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
         assert_eq!(locations.len(), 2);
-        assert_eq!(locations[0].0, LocalPaths::new(["/tmp3"]));
-        assert_eq!(locations[0].1, LocalPathsOrder::new([0]));
-        assert_eq!(locations[0].2, Some(30));
         let empty_paths: Vec<&str> = Vec::new();
-        assert_eq!(locations[1].0, LocalPaths::new(empty_paths.iter()));
-        assert_eq!(locations[1].1, LocalPathsOrder::new([]));
-        assert_eq!(locations[1].2, Some(50));
-        assert_eq!(locations[1].3, Some(ssh_project.id.0));
+        assert_eq!(locations[0].0, LocalPaths::new(empty_paths.iter()));
+        assert_eq!(locations[0].1, LocalPathsOrder::new([]));
+        assert_eq!(locations[0].2, Some(50));
+        assert_eq!(locations[0].3, Some(ssh_project.id.0));
+        assert_eq!(locations[1].0, LocalPaths::new(["/tmp3"]));
+        assert_eq!(locations[1].1, LocalPathsOrder::new([0]));
+        assert_eq!(locations[1].2, Some(30));
 
         let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
         assert_eq!(locations.len(), 1);

crates/workspace/src/workspace.rs 🔗

@@ -47,7 +47,9 @@ use itertools::Itertools;
 use language::{LanguageRegistry, Rope};
 pub use modal_layer::*;
 use node_runtime::NodeRuntime;
-use notifications::{simple_message_notification::MessageNotification, DetachAndPromptErr};
+use notifications::{
+    simple_message_notification::MessageNotification, DetachAndPromptErr, Notifications,
+};
 pub use pane::*;
 pub use pane_group::*;
 pub use persistence::{
@@ -815,7 +817,7 @@ pub struct Workspace {
     status_bar: Entity<StatusBar>,
     modal_layer: Entity<ModalLayer>,
     titlebar_item: Option<AnyView>,
-    notifications: Vec<(NotificationId, AnyView)>,
+    notifications: Notifications,
     project: Entity<Project>,
     follower_states: HashMap<PeerId, FollowerState>,
     last_leaders_by_pane: HashMap<WeakEntity<Pane>, PeerId>,
@@ -920,7 +922,7 @@ impl Workspace {
                 } => this.show_notification(
                     NotificationId::named(notification_id.clone()),
                     cx,
-                    |cx| cx.new(|_| MessageNotification::new(message.clone())),
+                    |cx| cx.new(|cx| MessageNotification::new(message.clone(), cx)),
                 ),
 
                 project::Event::HideToast { notification_id } => {
@@ -937,7 +939,11 @@ impl Workspace {
                     this.show_notification(
                         NotificationId::composite::<LanguageServerPrompt>(id as usize),
                         cx,
-                        |cx| cx.new(|_| notifications::LanguageServerPrompt::new(request.clone())),
+                        |cx| {
+                            cx.new(|cx| {
+                                notifications::LanguageServerPrompt::new(request.clone(), cx)
+                            })
+                        },
                     );
                 }
 
@@ -5223,8 +5229,8 @@ fn notify_if_database_failed(workspace: WindowHandle<Workspace>, cx: &mut AsyncA
                     NotificationId::unique::<DatabaseFailedNotification>(),
                     cx,
                     |cx| {
-                        cx.new(|_| {
-                            MessageNotification::new("Failed to load the database file.")
+                        cx.new(|cx| {
+                            MessageNotification::new("Failed to load the database file.", cx)
                                 .primary_message("File an Issue")
                                 .primary_icon(IconName::Plus)
                                 .primary_on_click(|_window, cx| cx.open_url(REPORT_ISSUE_URL))

crates/zed/src/zed.rs 🔗

@@ -1114,11 +1114,14 @@ fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Contex
                                 NotificationId::unique::<OpenLogError>(),
                                 cx,
                                 |cx| {
-                                    cx.new(|_| {
-                                        MessageNotification::new(format!(
-                                            "Unable to access/open log file at path {:?}",
-                                            paths::log_file().as_path()
-                                        ))
+                                    cx.new(|cx| {
+                                        MessageNotification::new(
+                                            format!(
+                                                "Unable to access/open log file at path {:?}",
+                                                paths::log_file().as_path()
+                                            ),
+                                            cx,
+                                        )
                                     })
                                 },
                             );
@@ -1323,8 +1326,8 @@ fn show_keymap_file_json_error(
     let message: SharedString =
         format!("JSON parse error in keymap file. Bindings not reloaded.\n\n{error}").into();
     show_app_notification(notification_id, cx, move |cx| {
-        cx.new(|_cx| {
-            MessageNotification::new(message.clone())
+        cx.new(|cx| {
+            MessageNotification::new(message.clone(), cx)
                 .primary_message("Open Keymap File")
                 .primary_on_click(|window, cx| {
                     window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx);
@@ -1381,8 +1384,8 @@ fn show_markdown_app_notification<F>(
                 let parsed_markdown = parsed_markdown.clone();
                 let primary_button_message = primary_button_message.clone();
                 let primary_button_on_click = primary_button_on_click.clone();
-                cx.new(move |_cx| {
-                    MessageNotification::new_from_builder(move |window, cx| {
+                cx.new(move |cx| {
+                    MessageNotification::new_from_builder(cx, move |window, cx| {
                         gpui::div()
                             .text_xs()
                             .child(markdown_preview::markdown_renderer::render_parsed_markdown(
@@ -1441,8 +1444,8 @@ pub fn handle_settings_changed(error: Option<anyhow::Error>, cx: &mut App) {
                 return;
             }
             show_app_notification(id, cx, move |cx| {
-                cx.new(|_cx| {
-                    MessageNotification::new(format!("Invalid user settings file\n{error}"))
+                cx.new(|cx| {
+                    MessageNotification::new(format!("Invalid user settings file\n{error}"), cx)
                         .primary_message("Open Settings File")
                         .primary_icon(IconName::Settings)
                         .primary_on_click(|window, cx| {
@@ -1580,7 +1583,7 @@ fn open_local_file(
         struct NoOpenFolders;
 
         workspace.show_notification(NotificationId::unique::<NoOpenFolders>(), cx, |cx| {
-            cx.new(|_| MessageNotification::new("This project has no folders open."))
+            cx.new(|cx| MessageNotification::new("This project has no folders open.", cx))
         })
     }
 }

crates/zeta/src/completion_diff_element.rs 🔗

@@ -78,7 +78,7 @@ impl CompletionDiffElement {
             font_style: settings.buffer_font.style,
             ..Default::default()
         };
-        let element = StyledText::new(diff).with_highlights(&text_style, diff_highlights);
+        let element = StyledText::new(diff).with_default_highlights(&text_style, diff_highlights);
         let text_layout = element.layout().clone();
 
         CompletionDiffElement {

crates/zeta/src/zeta.rs 🔗

@@ -489,8 +489,8 @@ impl Zeta {
                                         NotificationId::unique::<ZedUpdateRequiredError>(),
                                         cx,
                                         |cx| {
-                                            cx.new(|_| {
-                                                ErrorMessagePrompt::new(err.to_string())
+                                            cx.new(|cx| {
+                                                ErrorMessagePrompt::new(err.to_string(), cx)
                                                     .with_link_button(
                                                         "Update Zed",
                                                         "https://zed.dev/releases",