collab errors (#4152)

One of the complaints of users on our first Hack call was that the error
messages you got when channel joining failed were not great.

This aims to fix that specific case, and lay the groundwork for future
improvements.

It adds two new methods to anyhow::Error

* `.error_code()` which returns a value from zed.proto (or
ErrorCode::Internal if the error has no specific tag)
* `.error_tag("key")` which returns the value of the tag (or None).

To construct errors with these fields set, you can use a builder API
based on the ErrorCode type:

* `Err(ErrorCode::Forbidden.anyhow())`
* `Err(ErrorCode::Forbidden.message("cannot join channel").into())` - to
add any context you want in the logs
* `Err(ErrorCode::WrongReleaseChannel.tag("required", "stable").into())`
- to add structured metadata to help the client handle the error better.


Release Notes:

- Improved error messaging when channel joining fails.
This commit is contained in:
Conrad Irwin 2024-01-24 23:23:58 -07:00 committed by GitHub
commit 1c2859d72b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 446 additions and 101 deletions

View file

@ -2959,6 +2959,7 @@ impl InlineAssistant {
cx.prompt( cx.prompt(
PromptLevel::Info, PromptLevel::Info,
prompt_text.as_str(), prompt_text.as_str(),
None,
&["Continue", "Cancel"], &["Continue", "Cancel"],
) )
})?; })?;

View file

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

View file

@ -689,12 +689,7 @@ impl Client {
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
client.respond_with_error( client.respond_with_error(receipt, error.to_proto())?;
receipt,
proto::Error {
message: format!("{:?}", error),
},
)?;
Err(error) Err(error)
} }
} }

View file

@ -1,5 +1,5 @@
use super::*; use super::*;
use rpc::proto::channel_member::Kind; use rpc::{proto::channel_member::Kind, ErrorCode, ErrorCodeExt};
use sea_orm::TryGetableMany; use sea_orm::TryGetableMany;
impl Database { impl Database {
@ -166,7 +166,7 @@ impl Database {
} }
if role.is_none() || role == Some(ChannelRole::Banned) { if role.is_none() || role == Some(ChannelRole::Banned) {
Err(anyhow!("not allowed"))? Err(ErrorCode::Forbidden.anyhow())?
} }
let role = role.unwrap(); let role = role.unwrap();
@ -1201,7 +1201,7 @@ impl Database {
Ok(channel::Entity::find_by_id(channel_id) Ok(channel::Entity::find_by_id(channel_id)
.one(&*tx) .one(&*tx)
.await? .await?
.ok_or_else(|| anyhow!("no such channel"))?) .ok_or_else(|| proto::ErrorCode::NoSuchChannel.anyhow())?)
} }
pub(crate) async fn get_or_create_channel_room( pub(crate) async fn get_or_create_channel_room(
@ -1219,7 +1219,9 @@ impl Database {
let room_id = if let Some(room) = room { let room_id = if let Some(room) = room {
if let Some(env) = room.environment { if let Some(env) = room.environment {
if &env != environment { if &env != environment {
Err(anyhow!("must join using the {} release", env))?; Err(ErrorCode::WrongReleaseChannel
.with_tag("required", &env)
.anyhow())?;
} }
} }
room.id room.id

View file

@ -10,7 +10,7 @@ use crate::{
User, UserId, User, UserId,
}, },
executor::Executor, executor::Executor,
AppState, Result, AppState, Error, Result,
}; };
use anyhow::anyhow; use anyhow::anyhow;
use async_tungstenite::tungstenite::{ use async_tungstenite::tungstenite::{
@ -44,7 +44,7 @@ use rpc::{
self, Ack, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo, self, Ack, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo,
RequestMessage, ShareProject, UpdateChannelBufferCollaborators, RequestMessage, ShareProject, UpdateChannelBufferCollaborators,
}, },
Connection, ConnectionId, Peer, Receipt, TypedEnvelope, Connection, ConnectionId, ErrorCode, ErrorCodeExt, ErrorExt, Peer, Receipt, TypedEnvelope,
}; };
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use std::{ use std::{
@ -543,12 +543,11 @@ impl Server {
} }
} }
Err(error) => { Err(error) => {
peer.respond_with_error( let proto_err = match &error {
receipt, Error::Internal(err) => err.to_proto(),
proto::Error { _ => ErrorCode::Internal.message(format!("{}", error)).to_proto(),
message: error.to_string(), };
}, peer.respond_with_error(receipt, proto_err)?;
)?;
Err(error) Err(error)
} }
} }

View file

@ -22,7 +22,10 @@ use gpui::{
}; };
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev}; use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev};
use project::{Fs, Project}; use project::{Fs, Project};
use rpc::proto::{self, PeerId}; use rpc::{
proto::{self, PeerId},
ErrorCode, ErrorExt,
};
use serde_derive::{Deserialize, Serialize}; use serde_derive::{Deserialize, Serialize};
use settings::Settings; use settings::Settings;
use smallvec::SmallVec; use smallvec::SmallVec;
@ -35,7 +38,7 @@ use ui::{
use util::{maybe, ResultExt, TryFutureExt}; use util::{maybe, ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
notifications::{NotifyResultExt, NotifyTaskExt}, notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
Workspace, Workspace,
}; };
@ -879,7 +882,7 @@ impl CollabPanel {
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
let app_state = workspace.app_state().clone(); let app_state = workspace.app_state().clone();
workspace::join_remote_project(project_id, host_user_id, app_state, cx) 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(); .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 { } 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 { } else {
@ -1258,7 +1266,11 @@ impl CollabPanel {
app_state, app_state,
cx, cx,
) )
.detach_and_log_err(cx); .detach_and_prompt_err(
"Failed to join project",
cx,
|_, _| None,
);
} }
} }
ListEntry::ParticipantScreen { peer_id, .. } => { ListEntry::ParticipantScreen { peer_id, .. } => {
@ -1432,7 +1444,7 @@ impl CollabPanel {
fn leave_call(cx: &mut WindowContext) { fn leave_call(cx: &mut WindowContext) {
ActiveCall::global(cx) ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(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>) { fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
@ -1534,11 +1546,11 @@ impl CollabPanel {
cx: &mut ViewContext<CollabPanel>, cx: &mut ViewContext<CollabPanel>,
) { ) {
if let Some(clipboard) = self.channel_clipboard.take() { if let Some(clipboard) = self.channel_clipboard.take() {
self.channel_store.update(cx, |channel_store, cx| { self.channel_store
channel_store .update(cx, |channel_store, cx| {
.move_channel(clipboard.channel_id, Some(to_channel_id), cx) channel_store.move_channel(clipboard.channel_id, Some(to_channel_id), cx)
.detach_and_log_err(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 \"{}\"?", "Are you sure you want to remove the channel \"{}\"?",
channel.name 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 { cx.spawn(|this, mut cx| async move {
if answer.await? == 0 { if answer.await? == 0 {
channel_store channel_store
@ -1631,7 +1648,12 @@ impl CollabPanel {
"Are you sure you want to remove \"{}\" from your contacts?", "Are you sure you want to remove \"{}\" from your contacts?",
github_login 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 { cx.spawn(|_, mut cx| async move {
if answer.await? == 0 { if answer.await? == 0 {
user_store user_store
@ -1641,7 +1663,7 @@ impl CollabPanel {
} }
anyhow::Ok(()) anyhow::Ok(())
}) })
.detach_and_log_err(cx); .detach_and_prompt_err("Failed to remove contact", cx, |_, _| None);
} }
fn respond_to_contact_request( fn respond_to_contact_request(
@ -1654,7 +1676,7 @@ impl CollabPanel {
.update(cx, |store, cx| { .update(cx, |store, cx| {
store.respond_to_contact_request(user_id, accept, 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( fn respond_to_channel_invite(
@ -1675,7 +1697,7 @@ impl CollabPanel {
.update(cx, |call, cx| { .update(cx, |call, cx| {
call.invite(recipient_user_id, Some(self.project.clone()), 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>) { fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
@ -1691,7 +1713,7 @@ impl CollabPanel {
Some(handle), Some(handle),
cx, 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>) { 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.update(cx, |panel, cx| {
panel panel
.select_channel(channel_id, None, cx) .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| { .update(cx, |channel_store, cx| {
channel_store.move_channel(dragged_channel.id, None, 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| { .update(cx, |channel_store, cx| {
channel_store.move_channel(dragged_channel.id, Some(channel_id), 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( .child(
ListItem::new(channel_id as usize) ListItem::new(channel_id as usize)

View file

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

View file

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

View file

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

View file

@ -150,7 +150,13 @@ pub(crate) trait PlatformWindow {
fn as_any_mut(&mut self) -> &mut dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any;
fn set_input_handler(&mut self, input_handler: PlatformInputHandler); fn set_input_handler(&mut self, input_handler: PlatformInputHandler);
fn take_input_handler(&mut self) -> Option<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 activate(&self);
fn set_title(&mut self, title: &str); fn set_title(&mut self, title: &str);
fn set_edited(&mut self, edited: bool); fn set_edited(&mut self, edited: bool);

View file

@ -772,7 +772,13 @@ impl PlatformWindow for MacWindow {
self.0.as_ref().lock().input_handler.take() 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. // macOs applies overrides to modal window buttons after they are added.
// Two most important for this logic are: // Two most important for this logic are:
// * Buttons with "Cancel" title will be displayed as the last buttons in the modal // * 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, setAlertStyle: alert_style];
let _: () = msg_send![alert, setMessageText: ns_string(msg)]; 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 for (ix, answer) in answers
.iter() .iter()

View file

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

View file

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

View file

@ -778,6 +778,7 @@ impl ProjectPanel {
let answer = cx.prompt( let answer = cx.prompt(
PromptLevel::Info, PromptLevel::Info,
&format!("Delete {file_name:?}?"), &format!("Delete {file_name:?}?"),
None,
&["Delete", "Cancel"], &["Delete", "Cancel"],
); );

View file

@ -197,6 +197,19 @@ message Ack {}
message Error { message Error {
string message = 1; string message = 1;
ErrorCode code = 2;
repeated string tags = 3;
}
enum ErrorCode {
Internal = 0;
NoSuchChannel = 1;
Disconnected = 2;
SignedOut = 3;
UpgradeRequired = 4;
Forbidden = 5;
WrongReleaseChannel = 6;
NeedsCla = 7;
} }
message Test { message Test {

223
crates/rpc/src/error.rs Normal file
View file

@ -0,0 +1,223 @@
/// Some helpers for structured error handling.
///
/// The helpers defined here allow you to pass type-safe error codes from
/// the collab server to the client; and provide a mechanism for additional
/// structured data alongside the message.
///
/// When returning an error, it can be as simple as:
///
/// `return Err(Error::Forbidden.into())`
///
/// If you'd like to log more context, you can set a message. These messages
/// show up in our logs, but are not shown visibly to users.
///
/// `return Err(Error::Forbidden.message("not an admin").into())`
///
/// If you'd like to provide enough context that the UI can render a good error
/// message (or would be helpful to see in a structured format in the logs), you
/// can use .with_tag():
///
/// `return Err(Error::WrongReleaseChannel.with_tag("required", "stable").into())`
///
/// When handling an error you can use .error_code() to match which error it was
/// and .error_tag() to read any tags.
///
/// ```
/// match err.error_code() {
/// ErrorCode::Forbidden => alert("I'm sorry I can't do that.")
/// ErrorCode::WrongReleaseChannel =>
/// alert(format!("You need to be on the {} release channel.", err.error_tag("required").unwrap()))
/// ErrorCode::Internal => alert("Sorry, something went wrong")
/// }
/// ```
///
use crate::proto;
pub use proto::ErrorCode;
/// ErrorCodeExt provides some helpers for structured error handling.
///
/// The primary implementation is on the proto::ErrorCode to easily convert
/// that into an anyhow::Error, which we use pervasively.
///
/// The RpcError struct provides support for further metadata if needed.
pub trait ErrorCodeExt {
/// Return an anyhow::Error containing this.
/// (useful in places where .into() doesn't have enough type information)
fn anyhow(self) -> anyhow::Error;
/// Add a message to the error (by default the error code is used)
fn message(self, msg: String) -> RpcError;
/// Add a tag to the error. Tags are key value pairs that can be used
/// to send semi-structured data along with the error.
fn with_tag(self, k: &str, v: &str) -> RpcError;
}
impl ErrorCodeExt for proto::ErrorCode {
fn anyhow(self) -> anyhow::Error {
self.into()
}
fn message(self, msg: String) -> RpcError {
let err: RpcError = self.into();
err.message(msg)
}
fn with_tag(self, k: &str, v: &str) -> RpcError {
let err: RpcError = self.into();
err.with_tag(k, v)
}
}
/// ErrorExt provides helpers for structured error handling.
///
/// The primary implementation is on the anyhow::Error, which is
/// what we use throughout our codebase. Though under the hood this
pub trait ErrorExt {
/// error_code() returns the ErrorCode (or ErrorCode::Internal if there is none)
fn error_code(&self) -> proto::ErrorCode;
/// error_tag() returns the value of the tag with the given key, if any.
fn error_tag(&self, k: &str) -> Option<&str>;
/// to_proto() converts the error into a proto::Error
fn to_proto(&self) -> proto::Error;
}
impl ErrorExt for anyhow::Error {
fn error_code(&self) -> proto::ErrorCode {
if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
rpc_error.code
} else {
proto::ErrorCode::Internal
}
}
fn error_tag(&self, k: &str) -> Option<&str> {
if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
rpc_error.error_tag(k)
} else {
None
}
}
fn to_proto(&self) -> proto::Error {
if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
rpc_error.to_proto()
} else {
ErrorCode::Internal.message(format!("{}", self)).to_proto()
}
}
}
impl From<proto::ErrorCode> for anyhow::Error {
fn from(value: proto::ErrorCode) -> Self {
RpcError {
request: None,
code: value,
msg: format!("{:?}", value).to_string(),
tags: Default::default(),
}
.into()
}
}
#[derive(Clone, Debug)]
pub struct RpcError {
request: Option<String>,
msg: String,
code: proto::ErrorCode,
tags: Vec<String>,
}
/// RpcError is a structured error type that is returned by the collab server.
/// In addition to a message, it lets you set a specific ErrorCode, and attach
/// small amounts of metadata to help the client handle the error appropriately.
///
/// This struct is not typically used directly, as we pass anyhow::Error around
/// in the app; however it is useful for chaining .message() and .with_tag() on
/// ErrorCode.
impl RpcError {
/// from_proto converts a proto::Error into an anyhow::Error containing
/// an RpcError.
pub fn from_proto(error: &proto::Error, request: &str) -> anyhow::Error {
RpcError {
request: Some(request.to_string()),
code: error.code(),
msg: error.message.clone(),
tags: error.tags.clone(),
}
.into()
}
}
impl ErrorCodeExt for RpcError {
fn message(mut self, msg: String) -> RpcError {
self.msg = msg;
self
}
fn with_tag(mut self, k: &str, v: &str) -> RpcError {
self.tags.push(format!("{}={}", k, v));
self
}
fn anyhow(self) -> anyhow::Error {
self.into()
}
}
impl ErrorExt for RpcError {
fn error_tag(&self, k: &str) -> Option<&str> {
for tag in &self.tags {
let mut parts = tag.split('=');
if let Some(key) = parts.next() {
if key == k {
return parts.next();
}
}
}
None
}
fn error_code(&self) -> proto::ErrorCode {
self.code
}
fn to_proto(&self) -> proto::Error {
proto::Error {
code: self.code as i32,
message: self.msg.clone(),
tags: self.tags.clone(),
}
}
}
impl std::error::Error for RpcError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
impl std::fmt::Display for RpcError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(request) = &self.request {
write!(f, "RPC request {} failed: {}", request, self.msg)?
} else {
write!(f, "{}", self.msg)?
}
for tag in &self.tags {
write!(f, " {}", tag)?
}
Ok(())
}
}
impl From<proto::ErrorCode> for RpcError {
fn from(code: proto::ErrorCode) -> Self {
RpcError {
request: None,
code,
msg: format!("{:?}", code).to_string(),
tags: Default::default(),
}
}
}

View file

@ -1,3 +1,5 @@
use crate::{ErrorCode, ErrorCodeExt, ErrorExt, RpcError};
use super::{ use super::{
proto::{self, AnyTypedEnvelope, EnvelopedMessage, MessageStream, PeerId, RequestMessage}, proto::{self, AnyTypedEnvelope, EnvelopedMessage, MessageStream, PeerId, RequestMessage},
Connection, Connection,
@ -423,11 +425,7 @@ impl Peer {
let (response, _barrier) = rx.await.map_err(|_| anyhow!("connection was closed"))?; let (response, _barrier) = rx.await.map_err(|_| anyhow!("connection was closed"))?;
if let Some(proto::envelope::Payload::Error(error)) = &response.payload { if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
Err(anyhow!( Err(RpcError::from_proto(&error, T::NAME))
"RPC request {} failed - {}",
T::NAME,
error.message
))
} else { } else {
Ok(TypedEnvelope { Ok(TypedEnvelope {
message_id: response.id, message_id: response.id,
@ -516,9 +514,12 @@ impl Peer {
envelope: Box<dyn AnyTypedEnvelope>, envelope: Box<dyn AnyTypedEnvelope>,
) -> Result<()> { ) -> Result<()> {
let connection = self.connection_state(envelope.sender_id())?; let connection = self.connection_state(envelope.sender_id())?;
let response = proto::Error { let response = ErrorCode::Internal
message: format!("message {} was not handled", envelope.payload_type_name()), .message(format!(
}; "message {} was not handled",
envelope.payload_type_name()
))
.to_proto();
let message_id = connection let message_id = connection
.next_message_id .next_message_id
.fetch_add(1, atomic::Ordering::SeqCst); .fetch_add(1, atomic::Ordering::SeqCst);
@ -692,17 +693,17 @@ mod tests {
server server
.send( .send(
server_to_client_conn_id, server_to_client_conn_id,
proto::Error { ErrorCode::Internal
message: "message 1".to_string(), .message("message 1".to_string())
}, .to_proto(),
) )
.unwrap(); .unwrap();
server server
.send( .send(
server_to_client_conn_id, server_to_client_conn_id,
proto::Error { ErrorCode::Internal
message: "message 2".to_string(), .message("message 2".to_string())
}, .to_proto(),
) )
.unwrap(); .unwrap();
server.respond(request.receipt(), proto::Ack {}).unwrap(); server.respond(request.receipt(), proto::Ack {}).unwrap();
@ -797,17 +798,17 @@ mod tests {
server server
.send( .send(
server_to_client_conn_id, server_to_client_conn_id,
proto::Error { ErrorCode::Internal
message: "message 1".to_string(), .message("message 1".to_string())
}, .to_proto(),
) )
.unwrap(); .unwrap();
server server
.send( .send(
server_to_client_conn_id, server_to_client_conn_id,
proto::Error { ErrorCode::Internal
message: "message 2".to_string(), .message("message 2".to_string())
}, .to_proto(),
) )
.unwrap(); .unwrap();
server.respond(request1.receipt(), proto::Ack {}).unwrap(); server.respond(request1.receipt(), proto::Ack {}).unwrap();

View file

@ -1,10 +1,12 @@
pub mod auth; pub mod auth;
mod conn; mod conn;
mod error;
mod notification; mod notification;
mod peer; mod peer;
pub mod proto; pub mod proto;
pub use conn::Connection; pub use conn::Connection;
pub use error::*;
pub use notification::*; pub use notification::*;
pub use peer::*; pub use peer::*;
mod macros; mod macros;

View file

@ -746,6 +746,7 @@ impl ProjectSearchView {
cx.prompt( cx.prompt(
PromptLevel::Info, PromptLevel::Info,
prompt_text.as_str(), prompt_text.as_str(),
None,
&["Continue", "Cancel"], &["Continue", "Cancel"],
) )
})?; })?;

View file

@ -1,8 +1,8 @@
use crate::{Toast, Workspace}; use crate::{Toast, Workspace};
use collections::HashMap; use collections::HashMap;
use gpui::{ use gpui::{
AnyView, AppContext, AsyncWindowContext, DismissEvent, Entity, EntityId, EventEmitter, Render, AnyView, AppContext, AsyncWindowContext, DismissEvent, Entity, EntityId, EventEmitter,
Task, View, ViewContext, VisualContext, WindowContext, PromptLevel, Render, Task, View, ViewContext, VisualContext, WindowContext,
}; };
use std::{any::TypeId, ops::DerefMut}; use std::{any::TypeId, ops::DerefMut};
@ -299,7 +299,7 @@ pub trait NotifyTaskExt {
impl<R, E> NotifyTaskExt for Task<Result<R, E>> impl<R, E> NotifyTaskExt for Task<Result<R, E>>
where where
E: std::fmt::Debug + 'static, E: std::fmt::Debug + Sized + 'static,
R: 'static, R: 'static,
{ {
fn detach_and_notify_err(self, cx: &mut WindowContext) { fn detach_and_notify_err(self, cx: &mut WindowContext) {
@ -307,3 +307,39 @@ where
.detach(); .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();
}
}

View file

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

View file

@ -14,8 +14,8 @@ mod workspace_settings;
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use call::ActiveCall; use call::ActiveCall;
use client::{ use client::{
proto::{self, PeerId}, proto::{self, ErrorCode, PeerId},
Client, Status, TypedEnvelope, UserStore, Client, ErrorExt, Status, TypedEnvelope, UserStore,
}; };
use collections::{hash_map, HashMap, HashSet}; use collections::{hash_map, HashMap, HashSet};
use dock::{Dock, DockPosition, Panel, PanelButtons, PanelHandle}; use dock::{Dock, DockPosition, Panel, PanelButtons, PanelHandle};
@ -30,8 +30,8 @@ use gpui::{
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle, DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
FocusableView, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId, FocusableView, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId,
ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
Render, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext,
WindowBounds, WindowContext, WindowHandle, WindowOptions, WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
}; };
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem}; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
use itertools::Itertools; use itertools::Itertools;
@ -1159,6 +1159,7 @@ impl Workspace {
cx.prompt( cx.prompt(
PromptLevel::Warning, PromptLevel::Warning,
"Do you want to leave the current call?", "Do you want to leave the current call?",
None,
&["Close window and hang up", "Cancel"], &["Close window and hang up", "Cancel"],
) )
})?; })?;
@ -1214,7 +1215,7 @@ impl Workspace {
// Override save mode and display "Save all files" prompt // Override save mode and display "Save all files" prompt
if save_intent == SaveIntent::Close && dirty_items.len() > 1 { if save_intent == SaveIntent::Close && dirty_items.len() > 1 {
let answer = workspace.update(&mut cx, |_, cx| { 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), &mut dirty_items.iter().map(|(_, handle)| handle),
dirty_items.len(), dirty_items.len(),
cx, cx,
@ -1222,6 +1223,7 @@ impl Workspace {
cx.prompt( cx.prompt(
PromptLevel::Warning, PromptLevel::Warning,
&prompt, &prompt,
Some(&detail),
&["Save all", "Discard all", "Cancel"], &["Save all", "Discard all", "Cancel"],
) )
})?; })?;
@ -3887,13 +3889,16 @@ async fn join_channel_internal(
if should_prompt { if should_prompt {
if let Some(workspace) = requesting_window { if let Some(workspace) = requesting_window {
let answer = workspace.update(cx, |_, cx| { let answer = workspace
cx.prompt( .update(cx, |_, cx| {
PromptLevel::Warning, cx.prompt(
"Leaving this call will unshare your current project.\nDo you want to switch channels?", PromptLevel::Warning,
&["Yes, Join Channel", "Cancel"], "Do you want to switch channels?",
) Some("Leaving this call will unshare your current project."),
})?.await; &["Yes, Join Channel", "Cancel"],
)
})?
.await;
if answer == Ok(1) { if answer == Ok(1) {
return Ok(false); return Ok(false);
@ -3919,10 +3924,10 @@ async fn join_channel_internal(
| Status::Reconnecting | Status::Reconnecting
| Status::Reauthenticating => continue, | Status::Reauthenticating => continue,
Status::Connected { .. } => break 'outer, Status::Connected { .. } => break 'outer,
Status::SignedOut => return Err(anyhow!("not signed in")), Status::SignedOut => return Err(ErrorCode::SignedOut.into()),
Status::UpgradeRequired => return Err(anyhow!("zed is out of date")), Status::UpgradeRequired => return Err(ErrorCode::UpgradeRequired.into()),
Status::ConnectionError | Status::ConnectionLost | Status::ReconnectionError { .. } => { Status::ConnectionError | Status::ConnectionLost | Status::ReconnectionError { .. } => {
return Err(anyhow!("zed is offline")) return Err(ErrorCode::Disconnected.into())
} }
} }
} }
@ -3995,9 +4000,27 @@ pub fn join_channel(
if let Some(active_window) = active_window { if let Some(active_window) = active_window {
active_window active_window
.update(&mut cx, |_, cx| { .update(&mut cx, |_, cx| {
let detail: SharedString = match err.error_code() {
ErrorCode::SignedOut => {
"Please sign in to continue.".into()
},
ErrorCode::UpgradeRequired => {
"Your are running an unsupported version of Zed. Please update to continue.".into()
},
ErrorCode::NoSuchChannel => {
"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 => "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( cx.prompt(
PromptLevel::Critical, PromptLevel::Critical,
&format!("Failed to join channel: {}", err), "Failed to join channel",
Some(&detail),
&["Ok"], &["Ok"],
) )
})? })?
@ -4224,6 +4247,7 @@ pub fn restart(_: &Restart, cx: &mut AppContext) {
cx.prompt( cx.prompt(
PromptLevel::Info, PromptLevel::Info,
"Are you sure you want to restart?", "Are you sure you want to restart?",
None,
&["Restart", "Cancel"], &["Restart", "Cancel"],
) )
}) })

View file

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