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

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>
This commit is contained in:
Mikayla Maki
2025-03-03 09:20:15 +00:00
committed by GitHub
co-authored by julia
parent 508b9d3b5d
commit 73ac19958a
38 changed files with 713 additions and 192 deletions
+1 -1
View File
@@ -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;
+72 -17
View File
@@ -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)
})
}
});
+17 -12
View File
@@ -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);
+12 -6
View File
@@ -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))