diff --git a/Cargo.lock b/Cargo.lock index 2eed789433..e188d536b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,8 +341,9 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.9.0" -source = "git+https://github.com/bilelmoussaoui/ashpd?rev=29f2e1a#29f2e1a6f4b0911f504658f5f4630c02e01b13f2" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe7e0dd0ac5a401dc116ed9f9119cf9decc625600474cb41f0fc0a0050abc9a" dependencies = [ "async-fs 2.1.1", "async-net 2.0.0", diff --git a/Cargo.toml b/Cargo.toml index 3e46369976..1c83a0d7fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -274,7 +274,7 @@ zed_actions = { path = "crates/zed_actions" } alacritty_terminal = "0.23" any_vec = "0.13" anyhow = "1.0.57" -ashpd = { git = "https://github.com/bilelmoussaoui/ashpd", rev = "29f2e1a" } +ashpd = "0.9.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = { version = "0.1" } async-fs = "1.6" diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index ce13e7a9e6..e67f69901b 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -12,7 +12,7 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle, + actions, uniform_list, AnyElement, AppContext, EventEmitter, Flatten, FocusableView, FontStyle, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext, }; @@ -24,7 +24,6 @@ use std::time::Duration; use std::{ops::Range, sync::Arc}; use theme::ThemeSettings; use ui::{prelude::*, ContextMenu, PopoverMenu, ToggleButton, Tooltip}; -use util::ResultExt as _; use workspace::item::TabContentParams; use workspace::{ item::{Item, ItemEvent}, @@ -58,9 +57,23 @@ pub fn init(cx: &mut AppContext) { multiple: false, }); + let workspace_handle = cx.view().downgrade(); cx.deref_mut() .spawn(|mut cx| async move { - let extension_path = prompt.await.log_err()??.pop()?; + let extension_path = + match Flatten::flatten(prompt.await.map_err(|e| e.into())) { + Ok(Some(mut paths)) => paths.pop()?, + Ok(None) => return None, + Err(err) => { + workspace_handle + .update(&mut cx, |workspace, cx| { + workspace.show_portal_error(err.to_string(), cx); + }) + .ok(); + return None; + } + }; + store .update(&mut cx, |store, cx| { store diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index c0804169f4..adf512bc6a 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -612,10 +612,11 @@ impl AppContext { /// Displays a platform modal for selecting paths. /// When one or more paths are selected, they'll be relayed asynchronously via the returned oneshot channel. /// If cancelled, a `None` will be relayed instead. + /// May return an error on Linux if the file picker couldn't be opened. pub fn prompt_for_paths( &self, options: PathPromptOptions, - ) -> oneshot::Receiver>> { + ) -> oneshot::Receiver>>> { self.platform.prompt_for_paths(options) } @@ -623,7 +624,11 @@ impl AppContext { /// The provided directory will be used to set the initial location. /// When a path is selected, it is relayed asynchronously via the returned oneshot channel. /// If cancelled, a `None` will be relayed instead. - pub fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver> { + /// May return an error on Linux if the file picker couldn't be opened. + pub fn prompt_for_new_path( + &self, + directory: &Path, + ) -> oneshot::Receiver>> { self.platform.prompt_for_new_path(directory) } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 0181e0565b..4b479111ae 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -137,8 +137,8 @@ pub(crate) trait Platform: 'static { fn prompt_for_paths( &self, options: PathPromptOptions, - ) -> oneshot::Receiver>>; - fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver>; + ) -> oneshot::Receiver>>>; + fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver>>; fn reveal_path(&self, path: &Path); fn on_quit(&self, callback: Box); diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 9f613d1ca5..9b5b218ff8 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -21,6 +21,7 @@ use std::{ use anyhow::anyhow; use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest}; use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest as OpenUriRequest}; +use ashpd::desktop::ResponseError; use ashpd::{url, ActivationToken}; use async_task::Runnable; use calloop::channel::Channel; @@ -54,6 +55,9 @@ pub(crate) const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(400); pub(crate) const DOUBLE_CLICK_DISTANCE: Pixels = px(5.0); pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; +const FILE_PICKER_PORTAL_MISSING: &str = + "Couldn't open file picker due to missing xdg-desktop-portal implementation."; + pub trait LinuxClient { fn compositor_name(&self) -> &'static str; fn with_common(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R; @@ -256,7 +260,7 @@ impl Platform for P { fn prompt_for_paths( &self, options: PathPromptOptions, - ) -> oneshot::Receiver>> { + ) -> oneshot::Receiver>>> { let (done_tx, done_rx) = oneshot::channel(); self.foreground_executor() .spawn(async move { @@ -274,7 +278,7 @@ impl Platform for P { } }; - let result = OpenFileRequest::default() + let request = match OpenFileRequest::default() .modal(true) .title(title) .accept_label("Select") @@ -282,49 +286,68 @@ impl Platform for P { .directory(options.directories) .send() .await - .ok() - .and_then(|request| request.response().ok()) - .and_then(|response| { + { + Ok(request) => request, + Err(err) => { + let result = match err { + ashpd::Error::PortalNotFound(_) => anyhow!(FILE_PICKER_PORTAL_MISSING), + err => err.into(), + }; + done_tx.send(Err(result)); + return; + } + }; + + let result = match request.response() { + Ok(response) => Ok(Some( response .uris() .iter() - .map(|uri| uri.to_file_path().ok()) - .collect() - }); - + .filter_map(|uri| uri.to_file_path().ok()) + .collect::>(), + )), + Err(ashpd::Error::Response(ResponseError::Cancelled)) => Ok(None), + Err(e) => Err(e.into()), + }; done_tx.send(result); }) .detach(); done_rx } - fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver> { + fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver>> { let (done_tx, done_rx) = oneshot::channel(); let directory = directory.to_owned(); self.foreground_executor() .spawn(async move { - let request = SaveFileRequest::default() + let request = match SaveFileRequest::default() .modal(true) .title("Select new path") .accept_label("Accept") - .current_folder(directory); - - let result = if let Ok(request) = request { - request - .send() - .await - .ok() - .and_then(|request| request.response().ok()) - .and_then(|response| { - response - .uris() - .first() - .and_then(|uri| uri.to_file_path().ok()) - }) - } else { - None + .current_folder(directory) + .expect("pathbuf should not be nul terminated") + .send() + .await + { + Ok(request) => request, + Err(err) => { + let result = match err { + ashpd::Error::PortalNotFound(_) => anyhow!(FILE_PICKER_PORTAL_MISSING), + err => err.into(), + }; + done_tx.send(Err(result)); + return; + } }; + let result = match request.response() { + Ok(response) => Ok(response + .uris() + .first() + .and_then(|uri| uri.to_file_path().ok())), + Err(ashpd::Error::Response(ResponseError::Cancelled)) => Ok(None), + Err(e) => Err(e.into()), + }; done_tx.send(result); }) .detach(); diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 6d89e8ee49..396797053a 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -602,7 +602,7 @@ impl Platform for MacPlatform { fn prompt_for_paths( &self, options: PathPromptOptions, - ) -> oneshot::Receiver>> { + ) -> oneshot::Receiver>>> { let (done_tx, done_rx) = oneshot::channel(); self.foreground_executor() .spawn(async move { @@ -632,7 +632,7 @@ impl Platform for MacPlatform { }; if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(result); + let _ = done_tx.send(Ok(result)); } }); let block = block.copy(); @@ -643,7 +643,7 @@ impl Platform for MacPlatform { done_rx } - fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver> { + fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver>> { let directory = directory.to_owned(); let (done_tx, done_rx) = oneshot::channel(); self.foreground_executor() @@ -665,7 +665,7 @@ impl Platform for MacPlatform { } if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(result); + let _ = done_tx.send(Ok(result)); } }); let block = block.copy(); diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index f73ec5216a..d13006aace 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -34,7 +34,7 @@ pub(crate) struct TestPlatform { #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque>, - new_path: VecDeque<(PathBuf, oneshot::Sender>)>, + new_path: VecDeque<(PathBuf, oneshot::Sender>>)>, } impl TestPlatform { @@ -80,7 +80,7 @@ impl TestPlatform { .new_path .pop_front() .expect("no pending new path prompt"); - tx.send(select_path(&path)).ok(); + tx.send(Ok(select_path(&path))).ok(); } pub(crate) fn simulate_prompt_answer(&self, response_ix: usize) { @@ -216,14 +216,14 @@ impl Platform for TestPlatform { fn prompt_for_paths( &self, _options: crate::PathPromptOptions, - ) -> oneshot::Receiver>> { + ) -> oneshot::Receiver>>> { unimplemented!() } fn prompt_for_new_path( &self, directory: &std::path::Path, - ) -> oneshot::Receiver> { + ) -> oneshot::Receiver>> { let (tx, rx) = oneshot::channel(); self.prompts .borrow_mut() diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index dae72cba75..60b06747db 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -335,7 +335,10 @@ impl Platform for WindowsPlatform { self.state.borrow_mut().callbacks.open_urls = Some(callback); } - fn prompt_for_paths(&self, options: PathPromptOptions) -> Receiver>> { + fn prompt_for_paths( + &self, + options: PathPromptOptions, + ) -> Receiver>>> { let (tx, rx) = oneshot::channel(); self.foreground_executor() @@ -374,7 +377,7 @@ impl Platform for WindowsPlatform { if hr.unwrap_err().code() == HRESULT(0x800704C7u32 as i32) { // user canceled error if let Some(tx) = tx.take() { - tx.send(None).unwrap(); + tx.send(Ok(None)).unwrap(); } return; } @@ -393,10 +396,10 @@ impl Platform for WindowsPlatform { } if let Some(tx) = tx.take() { - if paths.len() == 0 { - tx.send(None).unwrap(); + if paths.is_empty() { + tx.send(Ok(None)).unwrap(); } else { - tx.send(Some(paths)).unwrap(); + tx.send(Ok(Some(paths))).unwrap(); } } }) @@ -405,27 +408,27 @@ impl Platform for WindowsPlatform { rx } - fn prompt_for_new_path(&self, directory: &Path) -> Receiver> { + fn prompt_for_new_path(&self, directory: &Path) -> Receiver>> { let directory = directory.to_owned(); let (tx, rx) = oneshot::channel(); self.foreground_executor() .spawn(async move { unsafe { let Ok(dialog) = show_savefile_dialog(directory) else { - let _ = tx.send(None); + let _ = tx.send(Ok(None)); return; }; let Ok(_) = dialog.Show(None) else { - let _ = tx.send(None); // user cancel + let _ = tx.send(Ok(None)); // user cancel return; }; if let Ok(shell_item) = dialog.GetResult() { if let Ok(file) = shell_item.GetDisplayName(SIGDN_FILESYSPATH) { - let _ = tx.send(Some(PathBuf::from(file.to_string().unwrap()))); + let _ = tx.send(Ok(Some(PathBuf::from(file.to_string().unwrap())))); return; } } - let _ = tx.send(None); + let _ = tx.send(Ok(None)); } }) .detach(); diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index f5935d4973..453cee987f 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -160,14 +160,23 @@ impl Workspace { self.show_notification( NotificationId::unique::(), cx, - |cx| { - cx.new_view(|_cx| { - simple_message_notification::MessageNotification::new(format!("Error: {err:#}")) - }) - }, + |cx| cx.new_view(|_cx| ErrorMessagePrompt::new(format!("Error: {err:#}"))), ); } + pub fn show_portal_error(&mut self, err: String, cx: &mut ViewContext) { + struct PortalError; + + self.show_notification(NotificationId::unique::(), cx, |cx| { + cx.new_view(|_cx| { + ErrorMessagePrompt::new(err.to_string()).with_link_button( + "See docs", + "https://zed.dev/docs/linux#i-cant-open-any-files", + ) + }) + }); + } + pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut ViewContext) { self.dismiss_notification_internal(id, cx) } @@ -362,6 +371,84 @@ impl Render for LanguageServerPrompt { impl EventEmitter for LanguageServerPrompt {} +pub struct ErrorMessagePrompt { + message: SharedString, + label_and_url_button: Option<(SharedString, SharedString)>, +} + +impl ErrorMessagePrompt { + pub fn new(message: S) -> Self + where + S: Into, + { + Self { + message: message.into(), + label_and_url_button: None, + } + } + + pub fn with_link_button(mut self, label: S, url: S) -> Self + where + S: Into, + { + self.label_and_url_button = Some((label.into(), url.into())); + self + } +} + +impl Render for ErrorMessagePrompt { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + h_flex() + .id("error_message_prompt_notification") + .occlude() + .elevation_3(cx) + .items_start() + .justify_between() + .p_2() + .gap_2() + .w_full() + .child( + v_flex() + .w_full() + .child( + h_flex() + .w_full() + .justify_between() + .child( + svg() + .size(cx.text_style().font_size) + .flex_none() + .mr_2() + .mt(px(-2.0)) + .map(|icon| { + icon.path(IconName::ExclamationTriangle.path()) + .text_color(Color::Error.color(cx)) + }), + ) + .child( + ui::IconButton::new("close", ui::IconName::Close) + .on_click(cx.listener(|_, _, cx| cx.emit(gpui::DismissEvent))), + ), + ) + .child( + div() + .max_w_80() + .child(Label::new(self.message.clone()).size(LabelSize::Small)), + ) + .when_some(self.label_and_url_button.clone(), |elm, (label, url)| { + elm.child( + div().mt_2().child( + ui::Button::new("error_message_prompt_notification_button", label) + .on_click(move |_, cx| cx.open_url(&url)), + ), + ) + }), + ) + } +} + +impl EventEmitter for ErrorMessagePrompt {} + pub mod simple_message_notification { use gpui::{ div, DismissEvent, EventEmitter, InteractiveElement, ParentElement, Render, SharedString, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 573a08a0cd..c3c230bd9a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -30,10 +30,10 @@ use gpui::{ action_as, actions, canvas, impl_action_as, impl_actions, point, relative, size, transparent_black, Action, AnyElement, AnyView, AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, CursorStyle, Decorations, DragMoveEvent, Entity as _, EntityId, - EventEmitter, FocusHandle, FocusableView, Global, Hsla, KeyContext, Keystroke, ManagedView, - Model, ModelContext, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, - Size, Stateful, Subscription, Task, Tiling, View, WeakView, WindowBounds, WindowHandle, - WindowOptions, + EventEmitter, Flatten, FocusHandle, FocusableView, Global, Hsla, KeyContext, Keystroke, + ManagedView, Model, ModelContext, MouseButton, PathPromptOptions, Point, PromptLevel, Render, + ResizeEdge, Size, Stateful, Subscription, Task, Tiling, View, WeakView, WindowBounds, + WindowHandle, WindowOptions, }; use item::{ FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings, @@ -305,13 +305,31 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { if let Some(app_state) = app_state.upgrade() { cx.spawn(move |cx| async move { - if let Some(paths) = paths.await.log_err().flatten() { - cx.update(|cx| { - open_paths(&paths, app_state, OpenOptions::default(), cx) - .detach_and_log_err(cx) - }) - .ok(); - } + match Flatten::flatten(paths.await.map_err(|e| e.into())) { + Ok(Some(paths)) => { + cx.update(|cx| { + open_paths(&paths, app_state, OpenOptions::default(), cx) + .detach_and_log_err(cx) + }) + .ok(); + } + Ok(None) => {} + Err(err) => { + cx.update(|cx| { + if let Some(workspace_window) = cx + .active_window() + .and_then(|window| window.downcast::()) + { + workspace_window + .update(cx, |workspace, cx| { + workspace.show_portal_error(err.to_string(), cx); + }) + .ok(); + } + }) + .ok(); + } + }; }) .detach(); } @@ -1321,7 +1339,15 @@ impl Workspace { let (tx, rx) = oneshot::channel(); let abs_path = cx.prompt_for_new_path(&start_abs_path); cx.spawn(|this, mut cx| async move { - let abs_path = abs_path.await?; + let abs_path: Option = + Flatten::flatten(abs_path.await.map_err(|e| e.into())).map_err(|err| { + this.update(&mut cx, |this, cx| { + this.show_portal_error(err.to_string(), cx); + }) + .ok(); + err + })?; + let project_path = abs_path.and_then(|abs_path| { this.update(&mut cx, |this, cx| { this.project.update(cx, |project, cx| { @@ -1610,8 +1636,16 @@ impl Workspace { }); cx.spawn(|this, mut cx| async move { - let Some(paths) = paths.await.log_err().flatten() else { - return; + let paths = match Flatten::flatten(paths.await.map_err(|e| e.into())) { + Ok(Some(paths)) => paths, + Ok(None) => return, + Err(err) => { + this.update(&mut cx, |this, cx| { + this.show_portal_error(err.to_string(), cx); + }) + .ok(); + return; + } }; if let Some(task) = this @@ -1773,7 +1807,14 @@ impl Workspace { multiple: true, }); cx.spawn(|this, mut cx| async move { - if let Some(paths) = paths.await.log_err().flatten() { + let paths = Flatten::flatten(paths.await.map_err(|e| e.into())).map_err(|err| { + this.update(&mut cx, |this, cx| { + this.show_portal_error(err.to_string(), cx); + }) + .ok(); + err + })?; + if let Some(paths) = paths { let results = this .update(&mut cx, |this, cx| { this.open_paths(paths, OpenVisible::All, None, cx)