Allow prompts to have detail, and use for good

Conrad Irwin created

Make channel panel errors louder

Change summary

crates/assistant/src/assistant_panel.rs            |  1 
crates/auto_update/src/auto_update.rs              |  3 
crates/collab_ui/src/collab_panel.rs               | 64 ++++++++++-----
crates/collab_ui/src/collab_panel/channel_modal.rs |  8 +-
crates/feedback/src/feedback.rs                    |  3 
crates/feedback/src/feedback_modal.rs              |  4 
crates/gpui/src/platform.rs                        |  8 +
crates/gpui/src/platform/mac/window.rs             | 11 ++
crates/gpui/src/platform/test/window.rs            |  1 
crates/gpui/src/window.rs                          |  5 +
crates/project_panel/src/project_panel.rs          |  1 
crates/rpc/proto/zed.proto                         |  1 
crates/search/src/project_search.rs                |  1 
crates/workspace/src/notifications.rs              | 42 +++++++++
crates/workspace/src/pane.rs                       | 17 ++-
crates/workspace/src/workspace.rs                  | 46 +++++++----
crates/zed/src/zed.rs                              | 11 +-
17 files changed, 162 insertions(+), 65 deletions(-)

Detailed changes

crates/auto_update/src/auto_update.rs 🔗

@@ -130,7 +130,8 @@ pub fn check(_: &Check, cx: &mut WindowContext) {
     } else {
         drop(cx.prompt(
             gpui::PromptLevel::Info,
-            "Auto-updates disabled for non-bundled app.",
+            "Could not check for updates",
+            Some("Auto-updates disabled for non-bundled app."),
             &["Ok"],
         ));
     }

crates/collab_ui/src/collab_panel.rs 🔗

@@ -22,7 +22,10 @@ use gpui::{
 };
 use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev};
 use project::{Fs, Project};
-use rpc::proto::{self, PeerId};
+use rpc::{
+    proto::{self, PeerId},
+    ErrorCode, ErrorExt,
+};
 use serde_derive::{Deserialize, Serialize};
 use settings::Settings;
 use smallvec::SmallVec;
@@ -35,7 +38,7 @@ use ui::{
 use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
-    notifications::{NotifyResultExt, NotifyTaskExt},
+    notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
     Workspace,
 };
 
@@ -879,7 +882,7 @@ impl CollabPanel {
                     .update(cx, |workspace, cx| {
                         let app_state = workspace.app_state().clone();
                         workspace::join_remote_project(project_id, host_user_id, app_state, cx)
-                            .detach_and_log_err(cx);
+                            .detach_and_prompt_err("Failed to join project", cx, |_, _| None);
                     })
                     .ok();
             }))
@@ -1017,7 +1020,12 @@ impl CollabPanel {
                                     )
                                 })
                             })
-                            .detach_and_notify_err(cx)
+                            .detach_and_prompt_err("Failed to grant write access", cx, |e, _| {
+                                match e.error_code() {
+                                    ErrorCode::NeedsCla => Some("This user has not yet signed the CLA at https://zed.dev/cla.".into()),
+                                    _ => None,
+                                }
+                            })
                     }),
                 )
             } else if role == proto::ChannelRole::Member {
@@ -1038,7 +1046,7 @@ impl CollabPanel {
                                     )
                                 })
                             })
-                            .detach_and_notify_err(cx)
+                            .detach_and_prompt_err("Failed to revoke write access", cx, |_, _| None)
                     }),
                 )
             } else {
@@ -1258,7 +1266,11 @@ impl CollabPanel {
                                 app_state,
                                 cx,
                             )
-                            .detach_and_log_err(cx);
+                            .detach_and_prompt_err(
+                                "Failed to join project",
+                                cx,
+                                |_, _| None,
+                            );
                         }
                     }
                     ListEntry::ParticipantScreen { peer_id, .. } => {
@@ -1432,7 +1444,7 @@ impl CollabPanel {
     fn leave_call(cx: &mut WindowContext) {
         ActiveCall::global(cx)
             .update(cx, |call, cx| call.hang_up(cx))
-            .detach_and_log_err(cx);
+            .detach_and_prompt_err("Failed to hang up", cx, |_, _| None);
     }
 
     fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
@@ -1534,11 +1546,11 @@ impl CollabPanel {
         cx: &mut ViewContext<CollabPanel>,
     ) {
         if let Some(clipboard) = self.channel_clipboard.take() {
-            self.channel_store.update(cx, |channel_store, cx| {
-                channel_store
-                    .move_channel(clipboard.channel_id, Some(to_channel_id), cx)
-                    .detach_and_log_err(cx)
-            })
+            self.channel_store
+                .update(cx, |channel_store, cx| {
+                    channel_store.move_channel(clipboard.channel_id, Some(to_channel_id), cx)
+                })
+                .detach_and_prompt_err("Failed to move channel", cx, |_, _| None)
         }
     }
 
@@ -1610,7 +1622,12 @@ impl CollabPanel {
                 "Are you sure you want to remove the channel \"{}\"?",
                 channel.name
             );
-            let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+            let answer = cx.prompt(
+                PromptLevel::Warning,
+                &prompt_message,
+                None,
+                &["Remove", "Cancel"],
+            );
             cx.spawn(|this, mut cx| async move {
                 if answer.await? == 0 {
                     channel_store
@@ -1631,7 +1648,12 @@ impl CollabPanel {
             "Are you sure you want to remove \"{}\" from your contacts?",
             github_login
         );
-        let answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+        let answer = cx.prompt(
+            PromptLevel::Warning,
+            &prompt_message,
+            None,
+            &["Remove", "Cancel"],
+        );
         cx.spawn(|_, mut cx| async move {
             if answer.await? == 0 {
                 user_store
@@ -1641,7 +1663,7 @@ impl CollabPanel {
             }
             anyhow::Ok(())
         })
-        .detach_and_log_err(cx);
+        .detach_and_prompt_err("Failed to remove contact", cx, |_, _| None);
     }
 
     fn respond_to_contact_request(
@@ -1654,7 +1676,7 @@ impl CollabPanel {
             .update(cx, |store, cx| {
                 store.respond_to_contact_request(user_id, accept, cx)
             })
-            .detach_and_log_err(cx);
+            .detach_and_prompt_err("Failed to respond to contact request", cx, |_, _| None);
     }
 
     fn respond_to_channel_invite(
@@ -1675,7 +1697,7 @@ impl CollabPanel {
             .update(cx, |call, cx| {
                 call.invite(recipient_user_id, Some(self.project.clone()), cx)
             })
-            .detach_and_log_err(cx);
+            .detach_and_prompt_err("Call failed", cx, |_, _| None);
     }
 
     fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
@@ -1691,7 +1713,7 @@ impl CollabPanel {
             Some(handle),
             cx,
         )
-        .detach_and_log_err(cx)
+        .detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
     }
 
     fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
@@ -1704,7 +1726,7 @@ impl CollabPanel {
                     panel.update(cx, |panel, cx| {
                         panel
                             .select_channel(channel_id, None, cx)
-                            .detach_and_log_err(cx);
+                            .detach_and_notify_err(cx);
                     });
                 }
             });
@@ -1981,7 +2003,7 @@ impl CollabPanel {
                             .update(cx, |channel_store, cx| {
                                 channel_store.move_channel(dragged_channel.id, None, cx)
                             })
-                            .detach_and_log_err(cx)
+                            .detach_and_prompt_err("Failed to move channel", cx, |_, _| None)
                     }))
             })
     }
@@ -2257,7 +2279,7 @@ impl CollabPanel {
                     .update(cx, |channel_store, cx| {
                         channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
                     })
-                    .detach_and_log_err(cx)
+                    .detach_and_prompt_err("Failed to move channel", cx, |_, _| None)
             }))
             .child(
                 ListItem::new(channel_id as usize)

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -14,7 +14,7 @@ use rpc::proto::channel_member;
 use std::sync::Arc;
 use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing};
 use util::TryFutureExt;
-use workspace::{notifications::NotifyTaskExt, ModalView};
+use workspace::{notifications::DetachAndPromptErr, ModalView};
 
 actions!(
     channel_modal,
@@ -498,7 +498,7 @@ impl ChannelModalDelegate {
                 cx.notify();
             })
         })
-        .detach_and_notify_err(cx);
+        .detach_and_prompt_err("Failed to update role", cx, |_, _| None);
         Some(())
     }
 
@@ -530,7 +530,7 @@ impl ChannelModalDelegate {
                 cx.notify();
             })
         })
-        .detach_and_notify_err(cx);
+        .detach_and_prompt_err("Failed to remove member", cx, |_, _| None);
         Some(())
     }
 
@@ -556,7 +556,7 @@ impl ChannelModalDelegate {
                 cx.notify();
             })
         })
-        .detach_and_notify_err(cx);
+        .detach_and_prompt_err("Failed to invite member", cx, |_, _| None);
     }
 
     fn show_context_menu(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {

crates/feedback/src/feedback.rs 🔗

@@ -31,7 +31,8 @@ pub fn init(cx: &mut AppContext) {
 
                     let prompt = cx.prompt(
                         PromptLevel::Info,
-                        &format!("Copied into clipboard:\n\n{specs}"),
+                        "Copied into clipboard",
+                        Some(&specs),
                         &["OK"],
                     );
                     cx.spawn(|_, _cx| async move {

crates/feedback/src/feedback_modal.rs 🔗

@@ -97,7 +97,7 @@ impl ModalView for FeedbackModal {
             return true;
         }
 
-        let answer = cx.prompt(PromptLevel::Info, "Discard feedback?", &["Yes", "No"]);
+        let answer = cx.prompt(PromptLevel::Info, "Discard feedback?", None, &["Yes", "No"]);
 
         cx.spawn(move |this, mut cx| async move {
             if answer.await.ok() == Some(0) {
@@ -222,6 +222,7 @@ impl FeedbackModal {
         let answer = cx.prompt(
             PromptLevel::Info,
             "Ready to submit your feedback?",
+            None,
             &["Yes, Submit!", "No"],
         );
         let client = cx.global::<Arc<Client>>().clone();
@@ -255,6 +256,7 @@ impl FeedbackModal {
                             let prompt = cx.prompt(
                                 PromptLevel::Critical,
                                 FEEDBACK_SUBMISSION_ERROR_TEXT,
+                                None,
                                 &["OK"],
                             );
                             cx.spawn(|_, _cx| async move {

crates/gpui/src/platform.rs 🔗

@@ -150,7 +150,13 @@ pub(crate) trait PlatformWindow {
     fn as_any_mut(&mut self) -> &mut dyn Any;
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler);
     fn take_input_handler(&mut self) -> Option<PlatformInputHandler>;
-    fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver<usize>;
+    fn prompt(
+        &self,
+        level: PromptLevel,
+        msg: &str,
+        detail: Option<&str>,
+        answers: &[&str],
+    ) -> oneshot::Receiver<usize>;
     fn activate(&self);
     fn set_title(&mut self, title: &str);
     fn set_edited(&mut self, edited: bool);

crates/gpui/src/platform/mac/window.rs 🔗

@@ -772,7 +772,13 @@ impl PlatformWindow for MacWindow {
         self.0.as_ref().lock().input_handler.take()
     }
 
-    fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver<usize> {
+    fn prompt(
+        &self,
+        level: PromptLevel,
+        msg: &str,
+        detail: Option<&str>,
+        answers: &[&str],
+    ) -> oneshot::Receiver<usize> {
         // macOs applies overrides to modal window buttons after they are added.
         // Two most important for this logic are:
         // * Buttons with "Cancel" title will be displayed as the last buttons in the modal
@@ -808,6 +814,9 @@ impl PlatformWindow for MacWindow {
             };
             let _: () = msg_send![alert, setAlertStyle: alert_style];
             let _: () = msg_send![alert, setMessageText: ns_string(msg)];
+            if let Some(detail) = detail {
+                let _: () = msg_send![alert, setInformativeText: ns_string(detail)];
+            }
 
             for (ix, answer) in answers
                 .iter()

crates/gpui/src/platform/test/window.rs 🔗

@@ -185,6 +185,7 @@ impl PlatformWindow for TestWindow {
         &self,
         _level: crate::PromptLevel,
         _msg: &str,
+        _detail: Option<&str>,
         _answers: &[&str],
     ) -> futures::channel::oneshot::Receiver<usize> {
         self.0

crates/gpui/src/window.rs 🔗

@@ -1478,9 +1478,12 @@ impl<'a> WindowContext<'a> {
         &self,
         level: PromptLevel,
         message: &str,
+        detail: Option<&str>,
         answers: &[&str],
     ) -> oneshot::Receiver<usize> {
-        self.window.platform_window.prompt(level, message, answers)
+        self.window
+            .platform_window
+            .prompt(level, message, detail, answers)
     }
 
     /// Returns all available actions for the focused element.

crates/rpc/proto/zed.proto 🔗

@@ -209,6 +209,7 @@ enum ErrorCode {
     UpgradeRequired = 4;
     Forbidden = 5;
     WrongReleaseChannel = 6;
+    NeedsCla = 7;
 }
 
 message Test {

crates/workspace/src/notifications.rs 🔗

@@ -1,8 +1,8 @@
 use crate::{Toast, Workspace};
 use collections::HashMap;
 use gpui::{
-    AnyView, AppContext, AsyncWindowContext, DismissEvent, Entity, EntityId, EventEmitter, Render,
-    Task, View, ViewContext, VisualContext, WindowContext,
+    AnyView, AppContext, AsyncWindowContext, DismissEvent, Entity, EntityId, EventEmitter,
+    PromptLevel, Render, Task, View, ViewContext, VisualContext, WindowContext,
 };
 use std::{any::TypeId, ops::DerefMut};
 
@@ -299,7 +299,7 @@ pub trait NotifyTaskExt {
 
 impl<R, E> NotifyTaskExt for Task<Result<R, E>>
 where
-    E: std::fmt::Debug + 'static,
+    E: std::fmt::Debug + Sized + 'static,
     R: 'static,
 {
     fn detach_and_notify_err(self, cx: &mut WindowContext) {
@@ -307,3 +307,39 @@ where
             .detach();
     }
 }
+
+pub trait DetachAndPromptErr {
+    fn detach_and_prompt_err(
+        self,
+        msg: &str,
+        cx: &mut WindowContext,
+        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
+    );
+}
+
+impl<R> DetachAndPromptErr for Task<anyhow::Result<R>>
+where
+    R: 'static,
+{
+    fn detach_and_prompt_err(
+        self,
+        msg: &str,
+        cx: &mut WindowContext,
+        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
+    ) {
+        let msg = msg.to_owned();
+        cx.spawn(|mut cx| async move {
+            if let Err(err) = self.await {
+                log::error!("{err:?}");
+                if let Ok(prompt) = cx.update(|cx| {
+                    let detail = f(&err, cx)
+                        .unwrap_or_else(|| format!("{err:?}. Please try again.", err = err));
+                    cx.prompt(PromptLevel::Critical, &msg, Some(&detail), &["Ok"])
+                }) {
+                    prompt.await.ok();
+                }
+            }
+        })
+        .detach();
+    }
+}

crates/workspace/src/pane.rs 🔗

@@ -870,7 +870,7 @@ impl Pane {
         items: &mut dyn Iterator<Item = &Box<dyn ItemHandle>>,
         all_dirty_items: usize,
         cx: &AppContext,
-    ) -> String {
+    ) -> (String, String) {
         /// Quantity of item paths displayed in prompt prior to cutoff..
         const FILE_NAMES_CUTOFF_POINT: usize = 10;
         let mut file_names: Vec<_> = items
@@ -894,10 +894,12 @@ impl Pane {
                 file_names.push(format!(".. {} files not shown", not_shown_files).into());
             }
         }
-        let file_names = file_names.join("\n");
-        format!(
-            "Do you want to save changes to the following {} files?\n{file_names}",
-            all_dirty_items
+        (
+            format!(
+                "Do you want to save changes to the following {} files?",
+                all_dirty_items
+            ),
+            file_names.join("\n"),
         )
     }
 
@@ -929,11 +931,12 @@ impl Pane {
         cx.spawn(|pane, mut cx| async move {
             if save_intent == SaveIntent::Close && dirty_items.len() > 1 {
                 let answer = pane.update(&mut cx, |_, cx| {
-                    let prompt =
+                    let (prompt, detail) =
                         Self::file_names_for_prompt(&mut dirty_items.iter(), dirty_items.len(), cx);
                     cx.prompt(
                         PromptLevel::Warning,
                         &prompt,
+                        Some(&detail),
                         &["Save all", "Discard all", "Cancel"],
                     )
                 })?;
@@ -1131,6 +1134,7 @@ impl Pane {
                 cx.prompt(
                     PromptLevel::Warning,
                     CONFLICT_MESSAGE,
+                    None,
                     &["Overwrite", "Discard", "Cancel"],
                 )
             })?;
@@ -1154,6 +1158,7 @@ impl Pane {
                         cx.prompt(
                             PromptLevel::Warning,
                             &prompt,
+                            None,
                             &["Save", "Don't Save", "Cancel"],
                         )
                     })?;

crates/workspace/src/workspace.rs 🔗

@@ -30,8 +30,8 @@ use gpui::{
     DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
     FocusableView, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId,
     ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
-    Render, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView,
-    WindowBounds, WindowContext, WindowHandle, WindowOptions,
+    Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext,
+    WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
 };
 use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
 use itertools::Itertools;
@@ -1159,6 +1159,7 @@ impl Workspace {
                         cx.prompt(
                             PromptLevel::Warning,
                             "Do you want to leave the current call?",
+                            None,
                             &["Close window and hang up", "Cancel"],
                         )
                     })?;
@@ -1214,7 +1215,7 @@ impl Workspace {
             // Override save mode and display "Save all files" prompt
             if save_intent == SaveIntent::Close && dirty_items.len() > 1 {
                 let answer = workspace.update(&mut cx, |_, cx| {
-                    let prompt = Pane::file_names_for_prompt(
+                    let (prompt, detail) = Pane::file_names_for_prompt(
                         &mut dirty_items.iter().map(|(_, handle)| handle),
                         dirty_items.len(),
                         cx,
@@ -1222,6 +1223,7 @@ impl Workspace {
                     cx.prompt(
                         PromptLevel::Warning,
                         &prompt,
+                        Some(&detail),
                         &["Save all", "Discard all", "Cancel"],
                     )
                 })?;
@@ -3887,13 +3889,16 @@ async fn join_channel_internal(
 
     if should_prompt {
         if let Some(workspace) = requesting_window {
-            let answer  = workspace.update(cx, |_, cx| {
-                cx.prompt(
-                    PromptLevel::Warning,
-                    "Leaving this call will unshare your current project.\nDo you want to switch channels?",
-                    &["Yes, Join Channel", "Cancel"],
-                )
-            })?.await;
+            let answer = workspace
+                .update(cx, |_, cx| {
+                    cx.prompt(
+                        PromptLevel::Warning,
+                        "Do you want to switch channels?",
+                        Some("Leaving this call will unshare your current project."),
+                        &["Yes, Join Channel", "Cancel"],
+                    )
+                })?
+                .await;
 
             if answer == Ok(1) {
                 return Ok(false);
@@ -3995,23 +4000,27 @@ pub fn join_channel(
             if let Some(active_window) = active_window {
                 active_window
                     .update(&mut cx, |_, cx| {
-                        let message:SharedString = match err.error_code() {
+                        let detail: SharedString = match err.error_code() {
                             ErrorCode::SignedOut => {
-                                "Failed to join channel\n\nPlease sign in to continue.".into()
+                                "Please sign in to continue.".into()
                             },
                             ErrorCode::UpgradeRequired => {
-                                "Failed to join channel\n\nPlease update to the latest version of Zed to continue.".into()
+                                "Your are running an unsupported version of Zed. Please update to continue.".into()
                             },
                             ErrorCode::NoSuchChannel => {
-                                "Failed to find channel\n\nPlease check the link and try again.".into()
+                                "No matching channel was found. Please check the link and try again.".into()
+                            },
+                            ErrorCode::Forbidden => {
+                                "This channel is private, and you do not have access. Please ask someone to add you and try again.".into()
                             },
-                            ErrorCode::Disconnected => "Failed to join channel\n\nPlease check your internet connection and try again.".into(),
-                            ErrorCode::WrongReleaseChannel => format!("Failed to join channel\n\nOther people in the channel are using the {} release of Zed, please switch to that release instead.", err.error_tag("required").unwrap_or("other")).into(),
-                            _ => format!("Failed to join channel\n\n{}\n\nPlease try again.", err).into(),
+                            ErrorCode::Disconnected => "Please check your internet connection and try again.".into(),
+                            ErrorCode::WrongReleaseChannel => format!("Others in the channel are using the {} release of Zed. Please switch to join this call.", err.error_tag("required").unwrap_or("other")).into(),
+                            _ => format!("{}\n\nPlease try again.", err).into(),
                         };
                         cx.prompt(
                             PromptLevel::Critical,
-                            &message,
+                            "Failed to join channel",
+                            Some(&detail),
                             &["Ok"],
                         )
                     })?
@@ -4238,6 +4247,7 @@ pub fn restart(_: &Restart, cx: &mut AppContext) {
                 cx.prompt(
                     PromptLevel::Info,
                     "Are you sure you want to restart?",
+                    None,
                     &["Restart", "Cancel"],
                 )
             })

crates/zed/src/zed.rs 🔗

@@ -370,16 +370,12 @@ fn initialize_pane(workspace: &mut Workspace, pane: &View<Pane>, cx: &mut ViewCo
 }
 
 fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext<Workspace>) {
-    use std::fmt::Write as _;
-
     let app_name = cx.global::<ReleaseChannel>().display_name();
     let version = env!("CARGO_PKG_VERSION");
-    let mut message = format!("{app_name} {version}");
-    if let Some(sha) = cx.try_global::<AppCommitSha>() {
-        write!(&mut message, "\n\n{}", sha.0).unwrap();
-    }
+    let message = format!("{app_name} {version}");
+    let detail = cx.try_global::<AppCommitSha>().map(|sha| sha.0.as_ref());
 
-    let prompt = cx.prompt(PromptLevel::Info, &message, &["OK"]);
+    let prompt = cx.prompt(PromptLevel::Info, &message, detail, &["OK"]);
     cx.foreground_executor()
         .spawn(async {
             prompt.await.ok();
@@ -410,6 +406,7 @@ fn quit(_: &Quit, cx: &mut AppContext) {
                     cx.prompt(
                         PromptLevel::Info,
                         "Are you sure you want to quit?",
+                        None,
                         &["Quit", "Cancel"],
                     )
                 })