From 73ac19958a6219ea8300304535427ed0dc0e4bfa Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 3 Mar 2025 01:20:15 -0800 Subject: [PATCH] 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 --- Cargo.lock | 3 + assets/icons/cloud.svg | 1 + assets/keymaps/default-macos.json | 2 +- crates/auto_update_ui/src/auto_update_ui.rs | 27 +-- crates/breadcrumbs/src/breadcrumbs.rs | 2 +- crates/collab_ui/src/notification_panel.rs | 14 +- crates/diagnostics/src/diagnostics.rs | 2 +- crates/editor/src/code_context_menus.rs | 2 +- crates/editor/src/editor.rs | 4 +- crates/editor/src/signature_help.rs | 2 +- crates/extensions_ui/src/extension_suggest.rs | 13 +- crates/file_finder/src/new_path_prompt.rs | 2 +- crates/git/Cargo.toml | 1 + crates/git/src/repository.rs | 125 +++++++--- crates/git_ui/Cargo.toml | 2 + crates/git_ui/src/git_panel.rs | 162 +++++++++---- crates/git_ui/src/git_ui.rs | 1 + crates/git_ui/src/picker_prompt.rs | 7 +- crates/git_ui/src/remote_output_toast.rs | 214 ++++++++++++++++++ crates/gpui/src/elements/text.rs | 51 ++++- crates/language/src/buffer.rs | 2 +- .../markdown_preview/src/markdown_renderer.rs | 4 +- crates/outline/src/outline.rs | 2 +- crates/project/src/git.rs | 58 +++-- crates/project_symbols/src/project_symbols.rs | 6 +- crates/proto/proto/zed.proto | 9 +- crates/proto/src/proto.rs | 7 +- crates/rich_text/src/rich_text.rs | 2 +- crates/storybook/src/stories/text.rs | 2 +- crates/ui/src/components/icon.rs | 1 + .../src/components/label/highlighted_label.rs | 2 +- crates/workspace/src/modal_layer.rs | 2 +- crates/workspace/src/notifications.rs | 89 ++++++-- crates/workspace/src/persistence.rs | 29 ++- crates/workspace/src/workspace.rs | 18 +- crates/zed/src/zed.rs | 27 ++- crates/zeta/src/completion_diff_element.rs | 2 +- crates/zeta/src/zeta.rs | 4 +- 38 files changed, 712 insertions(+), 191 deletions(-) create mode 100644 assets/icons/cloud.svg create mode 100644 crates/git_ui/src/remote_output_toast.rs diff --git a/Cargo.lock b/Cargo.lock index d1e3de16896d8f0283a7c4fca15003154e97ed2f..8637865620d3173c6a81007776197d5f6f18b96d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5353,6 +5353,7 @@ dependencies = [ "serde_json", "smol", "sum_tree", + "tempfile", "text", "time", "unindent", @@ -5408,7 +5409,9 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", + "linkify", "linkme", + "log", "menu", "multi_buffer", "panel", diff --git a/assets/icons/cloud.svg b/assets/icons/cloud.svg new file mode 100644 index 0000000000000000000000000000000000000000..73a9618067bf933814e2dc2e9c53f56432e07839 --- /dev/null +++ b/assets/icons/cloud.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index c4cf22570916f09451467ddc705bab4a81f02a23..7f538bae11ab46528dba4e4a6361aebf40cb31a3 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -32,12 +32,12 @@ "ctrl-enter": "menu::SecondaryConfirm", "cmd-enter": "menu::SecondaryConfirm", "ctrl-escape": "menu::Cancel", - "cmd-escape": "menu::Cancel", "ctrl-c": "menu::Cancel", "escape": "menu::Cancel", "alt-shift-enter": "menu::Restart", "cmd-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", + "cmd-escape": "menu::Cancel", "cmd-o": "workspace::Open", "cmd-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "cmd-+": ["zed::IncreaseBufferFontSize", { "persist": false }], diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index 79ebdf9ce511aca0b0113042d74e52eefcf9f10e..adf55f17731db091c3bf792ef75cd6de2a6569b5 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -141,19 +141,20 @@ pub fn notify_if_app_was_updated(cx: &mut App) { cx, move |cx| { let workspace_handle = cx.entity().downgrade(); - cx.new(|_cx| { - MessageNotification::new(format!("Updated to {app_name} {}", version)) - .primary_message("View Release Notes") - .primary_on_click(move |window, cx| { - if let Some(workspace) = workspace_handle.upgrade() { - workspace.update(cx, |workspace, cx| { - crate::view_release_notes_locally( - workspace, window, cx, - ); - }) - } - cx.emit(DismissEvent); - }) + cx.new(|cx| { + MessageNotification::new( + format!("Updated to {app_name} {}", version), + cx, + ) + .primary_message("View Release Notes") + .primary_on_click(move |window, cx| { + if let Some(workspace) = workspace_handle.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::view_release_notes_locally(workspace, window, cx); + }) + } + cx.emit(DismissEvent); + }) }) }, ); diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index 24203ec98b1bf9f622d1ebeb14255d11ff61e360..53c8ce73173a6976452cffa4b21de813ea4bfbdf 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -82,7 +82,7 @@ impl Render for Breadcrumbs { text_style.color = Color::Muted.color(cx); StyledText::new(segment.text.replace('\n', "⏎")) - .with_highlights(&text_style, segment.highlights.unwrap_or_default()) + .with_default_highlights(&text_style, segment.highlights.unwrap_or_default()) .into_any() }); let breadcrumbs = Itertools::intersperse_with(highlighted_segments, || { diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 50845af5ba63bd0b44d4166caefea179b04e886f..2abded65e81195d473c7e5a84f68d8d153bc2c2e 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -22,7 +22,7 @@ use ui::{ h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, }; use util::{ResultExt, TryFutureExt}; -use workspace::notifications::NotificationId; +use workspace::notifications::{Notification as WorkspaceNotification, NotificationId}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace, @@ -570,11 +570,12 @@ impl NotificationPanel { workspace.dismiss_notification(&id, cx); workspace.show_notification(id, cx, |cx| { let workspace = cx.entity().downgrade(); - cx.new(|_| NotificationToast { + cx.new(|cx| NotificationToast { notification_id, actor, text, workspace, + focus_handle: cx.focus_handle(), }) }) }) @@ -771,8 +772,17 @@ pub struct NotificationToast { actor: Option>, text: String, workspace: WeakEntity, + focus_handle: FocusHandle, +} + +impl Focusable for NotificationToast { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } } +impl WorkspaceNotification for NotificationToast {} + impl NotificationToast { fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context) { let workspace = self.workspace.clone(); diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 42b1800fe7e2f5a18355c02c8cb1ca7baf511b78..defe2950acae97274da50bb4c1f390927576e528 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -995,7 +995,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { h_flex() .gap_1() .child( - StyledText::new(message.clone()).with_highlights( + StyledText::new(message.clone()).with_default_highlights( &cx.window.text_style(), code_ranges .iter() diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index bfb59699452d67dce82214fdad4ce950d69f4160..3155e6f3463b52a21e02cfaa2be83b50a37cec80 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -514,7 +514,7 @@ impl CompletionsMenu { ); let completion_label = StyledText::new(completion.label.text.clone()) - .with_highlights(&style.text, highlights); + .with_default_highlights(&style.text, highlights); let documentation_label = if let Some( CompletionDocumentation::SingleLine(text), ) = documentation diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index fcc06861fc60022dde6b3168e511f4fb8aa7c632..a92265010e47e1b1e6c86303283c338ca5e16521 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6781,7 +6781,7 @@ impl Editor { .first_line_preview(); let styled_text = gpui::StyledText::new(highlighted_edits.text) - .with_highlights(&style.text, highlighted_edits.highlights); + .with_default_highlights(&style.text, highlighted_edits.highlights); let preview = h_flex() .gap_1() @@ -18007,7 +18007,7 @@ pub fn diagnostic_block_renderer( ) .child(buttons(&diagnostic)) .child(div().flex().flex_shrink_0().child( - StyledText::new(text_without_backticks.clone()).with_highlights( + StyledText::new(text_without_backticks.clone()).with_default_highlights( &text_style, code_ranges.iter().map(|range| { ( diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index dbad782766caf073b73c74254d2b517c7881c716..1e1dd15aa92bfce935a08b5ea8670afc10f43571 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -310,7 +310,7 @@ impl SignatureHelpPopover { .child( div().px_4().pb_1().child( StyledText::new(self.label.clone()) - .with_highlights(&self.style, self.highlights.iter().cloned()), + .with_default_highlights(&self.style, self.highlights.iter().cloned()), ), ) .into_any_element() diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 4844dce7558837de1dd98776467d299e0af44b1e..32e61de6eff396f7c1dc63a3823ebc87c7e7b455 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -168,11 +168,14 @@ pub(crate) fn suggest(buffer: Entity, window: &mut Window, cx: &mut Cont ); workspace.show_notification(notification_id, cx, |cx| { - cx.new(move |_cx| { - MessageNotification::new(format!( - "Do you want to install the recommended '{}' extension for '{}' files?", - extension_id, file_name_or_extension - )) + cx.new(move |cx| { + MessageNotification::new( + format!( + "Do you want to install the recommended '{}' extension for '{}' files?", + extension_id, file_name_or_extension + ), + cx, + ) .primary_message("Yes, install extension") .primary_icon(IconName::Check) .primary_icon_color(Color::Success) diff --git a/crates/file_finder/src/new_path_prompt.rs b/crates/file_finder/src/new_path_prompt.rs index 420238493d3d33f9abb40de39f5ced7e28cbbcf6..05b336fc45909e4348aa50f6a86c6af1a3946b06 100644 --- a/crates/file_finder/src/new_path_prompt.rs +++ b/crates/file_finder/src/new_path_prompt.rs @@ -192,7 +192,7 @@ impl Match { } } - StyledText::new(text).with_highlights(&window.text_style().clone(), highlights) + StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights) } } diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 0473b1dd57d26907465dce7e82477ea22858c47d..f32d704b3e043d1e98f11fbaf6a454d01a679f21 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -34,6 +34,7 @@ text.workspace = true time.workspace = true url.workspace = true util.workspace = true +tempfile.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index b690887ca1fa392de24dc851f027c6e254ed313f..b7ef907b575aed890d85278455799213711a130c 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -11,6 +11,8 @@ use schemars::JsonSchema; use serde::Deserialize; use std::borrow::Borrow; use std::io::Write as _; +#[cfg(not(windows))] +use std::os::unix::fs::PermissionsExt; use std::process::Stdio; use std::sync::LazyLock; use std::{ @@ -61,6 +63,12 @@ pub enum UpstreamTracking { Tracked(UpstreamTrackingStatus), } +impl From for UpstreamTracking { + fn from(status: UpstreamTrackingStatus) -> Self { + UpstreamTracking::Tracked(status) + } +} + impl UpstreamTracking { pub fn is_gone(&self) -> bool { matches!(self, UpstreamTracking::Gone) @@ -74,9 +82,15 @@ impl UpstreamTracking { } } -impl From for UpstreamTracking { - fn from(status: UpstreamTrackingStatus) -> Self { - UpstreamTracking::Tracked(status) +#[derive(Debug)] +pub struct RemoteCommandOutput { + pub stdout: String, + pub stderr: String, +} + +impl RemoteCommandOutput { + pub fn is_empty(&self) -> bool { + self.stdout.is_empty() && self.stderr.is_empty() } } @@ -185,10 +199,10 @@ pub trait GitRepository: Send + Sync { branch_name: &str, upstream_name: &str, options: Option, - ) -> Result<()>; - fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<()>; + ) -> Result; + fn pull(&self, branch_name: &str, upstream_name: &str) -> Result; fn get_remotes(&self, branch_name: Option<&str>) -> Result>; - fn fetch(&self) -> Result<()>; + fn fetch(&self) -> Result; } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] @@ -611,19 +625,30 @@ impl GitRepository for RealGitRepository { branch_name: &str, remote_name: &str, options: Option, - ) -> Result<()> { + ) -> Result { let working_directory = self.working_directory()?; - let output = new_std_command("git") + // We do this on every operation to ensure that the askpass script exists and is executable. + #[cfg(not(windows))] + let (askpass_script_path, _temp_dir) = setup_askpass()?; + + let mut command = new_std_command("git"); + command .current_dir(&working_directory) - .args(["push", "--quiet"]) + .args(["push"]) .args(options.map(|option| match option { PushOptions::SetUpstream => "--set-upstream", PushOptions::Force => "--force-with-lease", })) .arg(remote_name) - .arg(format!("{}:{}", branch_name, branch_name)) - .output()?; + .arg(format!("{}:{}", branch_name, branch_name)); + + #[cfg(not(windows))] + { + command.env("GIT_ASKPASS", askpass_script_path); + } + + let output = command.output()?; if !output.status.success() { return Err(anyhow!( @@ -631,19 +656,33 @@ impl GitRepository for RealGitRepository { String::from_utf8_lossy(&output.stderr) )); } else { - Ok(()) + return Ok(RemoteCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); } } - fn pull(&self, branch_name: &str, remote_name: &str) -> Result<()> { + fn pull(&self, branch_name: &str, remote_name: &str) -> Result { let working_directory = self.working_directory()?; - let output = new_std_command("git") + // We do this on every operation to ensure that the askpass script exists and is executable. + #[cfg(not(windows))] + let (askpass_script_path, _temp_dir) = setup_askpass()?; + + let mut command = new_std_command("git"); + command .current_dir(&working_directory) - .args(["pull", "--quiet"]) + .args(["pull"]) .arg(remote_name) - .arg(branch_name) - .output()?; + .arg(branch_name); + + #[cfg(not(windows))] + { + command.env("GIT_ASKPASS", askpass_script_path); + } + + let output = command.output()?; if !output.status.success() { return Err(anyhow!( @@ -651,17 +690,31 @@ impl GitRepository for RealGitRepository { String::from_utf8_lossy(&output.stderr) )); } else { - return Ok(()); + return Ok(RemoteCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); } } - fn fetch(&self) -> Result<()> { + fn fetch(&self) -> Result { let working_directory = self.working_directory()?; - let output = new_std_command("git") + // We do this on every operation to ensure that the askpass script exists and is executable. + #[cfg(not(windows))] + let (askpass_script_path, _temp_dir) = setup_askpass()?; + + let mut command = new_std_command("git"); + command .current_dir(&working_directory) - .args(["fetch", "--quiet", "--all"]) - .output()?; + .args(["fetch", "--all"]); + + #[cfg(not(windows))] + { + command.env("GIT_ASKPASS", askpass_script_path); + } + + let output = command.output()?; if !output.status.success() { return Err(anyhow!( @@ -669,7 +722,10 @@ impl GitRepository for RealGitRepository { String::from_utf8_lossy(&output.stderr) )); } else { - return Ok(()); + return Ok(RemoteCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); } } @@ -716,6 +772,18 @@ impl GitRepository for RealGitRepository { } } +#[cfg(not(windows))] +fn setup_askpass() -> Result<(PathBuf, tempfile::TempDir), anyhow::Error> { + let temp_dir = tempfile::Builder::new() + .prefix("zed-git-askpass") + .tempdir()?; + let askpass_script = "#!/bin/sh\necho ''"; + let askpass_script_path = temp_dir.path().join("git-askpass.sh"); + std::fs::write(&askpass_script_path, askpass_script)?; + std::fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755))?; + Ok((askpass_script_path, temp_dir)) +} + #[derive(Debug, Clone)] pub struct FakeGitRepository { state: Arc>, @@ -899,15 +967,20 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn push(&self, _branch: &str, _remote: &str, _options: Option) -> Result<()> { + fn push( + &self, + _branch: &str, + _remote: &str, + _options: Option, + ) -> Result { unimplemented!() } - fn pull(&self, _branch: &str, _remote: &str) -> Result<()> { + fn pull(&self, _branch: &str, _remote: &str) -> Result { unimplemented!() } - fn fetch(&self) -> Result<()> { + fn fetch(&self) -> Result { unimplemented!() } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 66845c939f8df2cd0c8768bf775e0a5355684866..f5b86abd15d6aff6dde6bdcd014f1e8429d177b8 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -30,7 +30,9 @@ git.workspace = true gpui.workspace = true itertools.workspace = true language.workspace = true +linkify.workspace = true linkme.workspace = true +log.workspace = true menu.workspace = true multi_buffer.workspace = true panel.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index dd2082fcc83f78236eb8613be1e79eeea3992b85..6fe12b3c382b63334ff03e83fc8a8910e0cd1c96 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,5 +1,6 @@ use crate::branch_picker::{self, BranchList}; use crate::git_panel_settings::StatusStyle; +use crate::remote_output_toast::{RemoteAction, RemoteOutputToast}; use crate::repository_selector::RepositorySelectorPopoverMenu; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, @@ -12,8 +13,8 @@ use editor::{ ShowScrollbar, }; use git::repository::{ - Branch, CommitDetails, CommitSummary, PushOptions, Remote, ResetMode, Upstream, - UpstreamTracking, UpstreamTrackingStatus, + Branch, CommitDetails, CommitSummary, PushOptions, Remote, RemoteCommandOutput, ResetMode, + Upstream, UpstreamTracking, UpstreamTrackingStatus, }; use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged}; use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; @@ -43,6 +44,7 @@ use ui::{ PopoverButton, PopoverMenu, Scrollbar, ScrollbarState, Tooltip, }; use util::{maybe, post_inc, ResultExt, TryFutureExt}; + use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotificationId}, @@ -283,6 +285,7 @@ impl GitPanel { let commit_editor = cx.new(|cx| { commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx) }); + commit_editor.update(cx, |editor, cx| { editor.clear(window, cx); }); @@ -1330,62 +1333,114 @@ impl GitPanel { }; let guard = self.start_remote_operation(); let fetch = repo.read(cx).fetch(); - cx.spawn(|_, _| async move { - fetch.await??; + cx.spawn(|this, mut cx| async move { + let remote_message = fetch.await?; drop(guard); + this.update(&mut cx, |this, cx| { + match remote_message { + Ok(remote_message) => { + this.show_remote_output(RemoteAction::Fetch, remote_message, cx); + } + Err(e) => { + this.show_err_toast(e, cx); + } + } + + anyhow::Ok(()) + }) + .ok(); anyhow::Ok(()) }) .detach_and_log_err(cx); } fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active_repository.clone() else { + return; + }; + let Some(branch) = repo.read(cx).current_branch() else { + return; + }; + let branch = branch.clone(); let guard = self.start_remote_operation(); let remote = self.get_current_remote(window, cx); cx.spawn(move |this, mut cx| async move { - let remote = remote.await?; + let remote = match remote.await { + Ok(Some(remote)) => remote, + Ok(None) => { + return Ok(()); + } + Err(e) => { + log::error!("Failed to get current remote: {}", e); + this.update(&mut cx, |this, cx| this.show_err_toast(e, cx)) + .ok(); + return Ok(()); + } + }; - this.update(&mut cx, |this, cx| { - let Some(repo) = this.active_repository.clone() else { - return Err(anyhow::anyhow!("No active repository")); - }; + let pull = repo.update(&mut cx, |repo, _cx| { + repo.pull(branch.name.clone(), remote.name.clone()) + })?; - let Some(branch) = repo.read(cx).current_branch() else { - return Err(anyhow::anyhow!("No active branch")); - }; + let remote_message = pull.await?; + drop(guard); - Ok(repo.read(cx).pull(branch.name.clone(), remote.name)) - })?? - .await??; + this.update(&mut cx, |this, cx| match remote_message { + Ok(remote_message) => { + this.show_remote_output(RemoteAction::Pull, remote_message, cx) + } + Err(err) => this.show_err_toast(err, cx), + }) + .ok(); - drop(guard); anyhow::Ok(()) }) .detach_and_log_err(cx); } fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active_repository.clone() else { + return; + }; + let Some(branch) = repo.read(cx).current_branch() else { + return; + }; + let branch = branch.clone(); let guard = self.start_remote_operation(); let options = action.options; let remote = self.get_current_remote(window, cx); - cx.spawn(move |this, mut cx| async move { - let remote = remote.await?; - this.update(&mut cx, |this, cx| { - let Some(repo) = this.active_repository.clone() else { - return Err(anyhow::anyhow!("No active repository")); - }; + cx.spawn(move |this, mut cx| async move { + let remote = match remote.await { + Ok(Some(remote)) => remote, + Ok(None) => { + return Ok(()); + } + Err(e) => { + log::error!("Failed to get current remote: {}", e); + this.update(&mut cx, |this, cx| this.show_err_toast(e, cx)) + .ok(); + return Ok(()); + } + }; - let Some(branch) = repo.read(cx).current_branch() else { - return Err(anyhow::anyhow!("No active branch")); - }; + let push = repo.update(&mut cx, |repo, _cx| { + repo.push(branch.name.clone(), remote.name.clone(), options) + })?; - Ok(repo - .read(cx) - .push(branch.name.clone(), remote.name, options)) - })?? - .await??; + let remote_output = push.await?; drop(guard); + + this.update(&mut cx, |this, cx| match remote_output { + Ok(remote_message) => { + this.show_remote_output(RemoteAction::Push(remote), remote_message, cx); + } + Err(e) => { + this.show_err_toast(e, cx); + } + })?; + anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -1395,7 +1450,7 @@ impl GitPanel { &mut self, window: &mut Window, cx: &mut Context, - ) -> impl Future> { + ) -> impl Future>> { let repo = self.active_repository.clone(); let workspace = self.workspace.clone(); let mut cx = window.to_async(cx); @@ -1418,7 +1473,7 @@ impl GitPanel { if current_remotes.len() == 0 { return Err(anyhow::anyhow!("No active remote")); } else if current_remotes.len() == 1 { - return Ok(current_remotes.pop().unwrap()); + return Ok(Some(current_remotes.pop().unwrap())); } else { let current_remotes: Vec<_> = current_remotes .into_iter() @@ -1436,9 +1491,9 @@ impl GitPanel { })? .await?; - return Ok(Remote { + Ok(selection.map(|selection| Remote { name: current_remotes[selection].clone(), - }); + })) } } } @@ -1789,16 +1844,40 @@ impl GitPanel { }; let notif_id = NotificationId::Named("git-operation-error".into()); - let message = e.to_string(); - workspace.update(cx, |workspace, cx| { - let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| { + let mut message = e.to_string().trim().to_string(); + let toast; + if message.matches("Authentication failed").count() >= 1 { + message = format!( + "{}\n\n{}", + message, "Please set your credentials via the CLI" + ); + toast = Toast::new(notif_id, message); + } else { + toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| { window.dispatch_action(workspace::OpenLog.boxed_clone(), cx); }); + } + workspace.update(cx, |workspace, cx| { workspace.show_toast(toast, cx); }); } - fn render_spinner(&self) -> Option { + fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let notification_id = NotificationId::Named("git-remote-info".into()); + + workspace.update(cx, |workspace, cx| { + workspace.show_notification(notification_id.clone(), cx, |cx| { + let workspace = cx.weak_entity(); + cx.new(|cx| RemoteOutputToast::new(action, info, notification_id, workspace, cx)) + }); + }); + } + + pub fn render_spinner(&self) -> Option { (!self.pending_remote_operations.borrow().is_empty()).then(|| { Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) @@ -2274,9 +2353,10 @@ impl GitPanel { let Some(repo) = self.active_repository.clone() else { return Task::ready(Err(anyhow::anyhow!("no active repo"))); }; - - let show = repo.read(cx).show(sha); - cx.spawn(|_, _| async move { show.await? }) + repo.update(cx, |repo, cx| { + let show = repo.show(sha); + cx.spawn(|_, _| async move { show.await? }) + }) } fn deploy_entry_context_menu( diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 7e74fa788ce600a5d83810af49fcda9c2b26033a..3015143902656a4a76a202d08ea45941b2b8c37b 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -11,6 +11,7 @@ pub mod git_panel; mod git_panel_settings; pub mod picker_prompt; pub mod project_diff; +mod remote_output_toast; pub mod repository_selector; pub fn init(cx: &mut App) { diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index f565b1a768fec53ea0662d6fd833eaf590203615..3723dfe9f508b93b3ee855c363576565312a70ca 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -26,7 +26,7 @@ pub fn prompt( workspace: WeakEntity, window: &mut Window, cx: &mut App, -) -> Task> { +) -> Task>> { if options.is_empty() { return Task::ready(Err(anyhow!("No options"))); } @@ -43,7 +43,10 @@ pub fn prompt( }) })?; - rx.await? + match rx.await { + Ok(selection) => Some(selection).transpose(), + Err(_) => anyhow::Ok(None), // User cancelled + } }) } diff --git a/crates/git_ui/src/remote_output_toast.rs b/crates/git_ui/src/remote_output_toast.rs new file mode 100644 index 0000000000000000000000000000000000000000..c156dcc0e7f9c37fd27bf0f72e385104062fc5b7 --- /dev/null +++ b/crates/git_ui/src/remote_output_toast.rs @@ -0,0 +1,214 @@ +use std::{ops::Range, time::Duration}; + +use git::repository::{Remote, RemoteCommandOutput}; +use gpui::{ + DismissEvent, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveText, + StyledText, Task, UnderlineStyle, WeakEntity, +}; +use itertools::Itertools; +use linkify::{LinkFinder, LinkKind}; +use ui::{ + div, h_flex, px, v_flex, vh, Clickable, Color, Context, FluentBuilder, Icon, IconButton, + IconName, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement, + Render, SharedString, Styled, StyledExt, Window, +}; +use workspace::{ + notifications::{Notification, NotificationId}, + Workspace, +}; + +pub enum RemoteAction { + Fetch, + Pull, + Push(Remote), +} + +struct InfoFromRemote { + name: SharedString, + remote_text: SharedString, + links: Vec>, +} + +pub struct RemoteOutputToast { + _workspace: WeakEntity, + _id: NotificationId, + message: SharedString, + remote_info: Option, + _dismiss_task: Task<()>, + focus_handle: FocusHandle, +} + +impl Focusable for RemoteOutputToast { + fn focus_handle(&self, _cx: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Notification for RemoteOutputToast {} + +const REMOTE_OUTPUT_TOAST_SECONDS: u64 = 5; + +impl RemoteOutputToast { + pub fn new( + action: RemoteAction, + output: RemoteCommandOutput, + id: NotificationId, + workspace: WeakEntity, + cx: &mut Context, + ) -> Self { + let task = cx.spawn({ + let workspace = workspace.clone(); + let id = id.clone(); + |_, mut cx| async move { + cx.background_executor() + .timer(Duration::from_secs(REMOTE_OUTPUT_TOAST_SECONDS)) + .await; + workspace + .update(&mut cx, |workspace, cx| { + workspace.dismiss_notification(&id, cx); + }) + .ok(); + } + }); + + let message; + let remote; + + match action { + RemoteAction::Fetch | RemoteAction::Pull => { + if output.is_empty() { + message = "Up to date".into(); + } else { + message = output.stderr.into(); + } + remote = None; + } + + RemoteAction::Push(remote_ref) => { + message = output.stdout.trim().to_string().into(); + let remote_message = get_remote_lines(&output.stderr); + let finder = LinkFinder::new(); + let links = finder + .links(&remote_message) + .filter(|link| *link.kind() == LinkKind::Url) + .map(|link| link.start()..link.end()) + .collect_vec(); + + remote = Some(InfoFromRemote { + name: remote_ref.name, + remote_text: remote_message.into(), + links, + }); + } + } + + Self { + _workspace: workspace, + _id: id, + message, + remote_info: remote, + _dismiss_task: task, + focus_handle: cx.focus_handle(), + } + } +} + +impl Render for RemoteOutputToast { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + div() + .occlude() + .w_full() + .max_h(vh(0.8, window)) + .elevation_3(cx) + .child( + v_flex() + .p_3() + .overflow_hidden() + .child( + h_flex() + .justify_between() + .items_start() + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::GitBranch).color(Color::Default)) + .child(Label::new("Git")), + ) + .child(h_flex().child( + IconButton::new("close", IconName::Close).on_click( + cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)), + ), + )), + ) + .child(Label::new(self.message.clone()).size(LabelSize::Default)) + .when_some(self.remote_info.as_ref(), |this, remote_info| { + this.child( + div() + .border_1() + .border_color(Color::Muted.color(cx)) + .rounded_lg() + .text_sm() + .mt_1() + .p_1() + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Cloud).color(Color::Default)) + .child( + Label::new(remote_info.name.clone()) + .size(LabelSize::Default), + ), + ) + .map(|div| { + let styled_text = + StyledText::new(remote_info.remote_text.clone()) + .with_highlights(remote_info.links.iter().map( + |link| { + ( + link.clone(), + HighlightStyle { + underline: Some(UnderlineStyle { + thickness: px(1.0), + ..Default::default() + }), + ..Default::default() + }, + ) + }, + )); + let this = cx.weak_entity(); + let text = InteractiveText::new("remote-message", styled_text) + .on_click( + remote_info.links.clone(), + move |ix, _window, cx| { + this.update(cx, |this, cx| { + if let Some(remote_info) = &this.remote_info { + cx.open_url( + &remote_info.remote_text + [remote_info.links[ix].clone()], + ) + } + }) + .ok(); + }, + ); + + div.child(text) + }), + ) + }), + ) + } +} + +impl EventEmitter for RemoteOutputToast {} + +fn get_remote_lines(output: &str) -> String { + output + .lines() + .filter_map(|line| line.strip_prefix("remote:")) + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 132135d4d604bb7927025c4df4f54a6fe31cfe88..4175dcb03a4fe566d9fecae9ba74d5b2fa23da59 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -137,6 +137,7 @@ impl IntoElement for SharedString { pub struct StyledText { text: SharedString, runs: Option>, + delayed_highlights: Option, HighlightStyle)>>, layout: TextLayout, } @@ -146,6 +147,7 @@ impl StyledText { StyledText { text: text.into(), runs: None, + delayed_highlights: None, layout: TextLayout::default(), } } @@ -157,11 +159,39 @@ impl StyledText { /// Set the styling attributes for the given text, as well as /// as any ranges of text that have had their style customized. - pub fn with_highlights( + pub fn with_default_highlights( mut self, default_style: &TextStyle, highlights: impl IntoIterator, HighlightStyle)>, ) -> Self { + debug_assert!( + self.delayed_highlights.is_none(), + "Can't use `with_default_highlights` and `with_highlights`" + ); + let runs = Self::compute_runs(&self.text, default_style, highlights); + self.runs = Some(runs); + self + } + + /// Set the styling attributes for the given text, as well as + /// as any ranges of text that have had their style customized. + pub fn with_highlights( + mut self, + highlights: impl IntoIterator, HighlightStyle)>, + ) -> Self { + debug_assert!( + self.runs.is_none(), + "Can't use `with_highlights` and `with_default_highlights`" + ); + self.delayed_highlights = Some(highlights.into_iter().collect::>()); + self + } + + fn compute_runs( + text: &str, + default_style: &TextStyle, + highlights: impl IntoIterator, HighlightStyle)>, + ) -> Vec { let mut runs = Vec::new(); let mut ix = 0; for (range, highlight) in highlights { @@ -176,11 +206,10 @@ impl StyledText { ); ix = range.end; } - if ix < self.text.len() { - runs.push(default_style.to_run(self.text.len() - ix)); + if ix < text.len() { + runs.push(default_style.to_run(text.len() - ix)); } - self.runs = Some(runs); - self + runs } /// Set the text runs for this piece of text. @@ -200,15 +229,17 @@ impl Element for StyledText { fn request_layout( &mut self, - _id: Option<&GlobalElementId>, - window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let layout_id = self - .layout - .layout(self.text.clone(), self.runs.take(), window, cx); + let runs = self.runs.take().or_else(|| { + self.delayed_highlights.take().map(|delayed_highlights| { + Self::compute_runs(&self.text, &window.text_style(), delayed_highlights) + }) + }); + + let layout_id = self.layout.layout(self.text.clone(), runs, window, cx); (layout_id, ()) } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index aaf1014d05a09d815073d4068a3142cbbfc433c3..274ed6b48f582664c1cca125385df14a36a5d015 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -590,7 +590,7 @@ impl HighlightedText { pub fn to_styled_text(&self, default_style: &TextStyle) -> StyledText { gpui::StyledText::new(self.text.clone()) - .with_highlights(default_style, self.highlights.iter().cloned()) + .with_default_highlights(default_style, self.highlights.iter().cloned()) } /// Returns the first line without leading whitespace unless highlighted diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 408dc682e5d67596050e0b904933b56579d8d3af..d45795796327a894f78eab0944444ea95f01b2ed 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -370,7 +370,7 @@ fn render_markdown_code_block( cx: &mut RenderContext, ) -> AnyElement { let body = if let Some(highlights) = parsed.highlights.as_ref() { - StyledText::new(parsed.contents.clone()).with_highlights( + StyledText::new(parsed.contents.clone()).with_default_highlights( &cx.buffer_text_style, highlights.iter().filter_map(|(range, highlight_id)| { highlight_id @@ -468,7 +468,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) InteractiveText::new( element_id, StyledText::new(parsed.contents.clone()) - .with_highlights(&text_style, highlights), + .with_default_highlights(&text_style, highlights), ) .tooltip({ let links = links.clone(); diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 3d119f9b887bd24294d23e4be94f3688f726a317..49333396c1b4c8df4063477e8f1d1377fb58973a 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -359,7 +359,7 @@ pub fn render_item( outline_item.highlight_ranges.iter().cloned(), ); - StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights) + StyledText::new(outline_item.text.clone()).with_default_highlights(&text_style, highlights) } #[cfg(test)] diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 8dda0f8cec2f8f3913c49d3bb41bcfe73ce5ae90..43b69ccdb2fe941a496b842472f7bcdedcde8e32 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -5,7 +5,7 @@ use anyhow::{Context as _, Result}; use client::ProjectId; use futures::channel::{mpsc, oneshot}; use futures::StreamExt as _; -use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode}; +use git::repository::{Branch, CommitDetails, PushOptions, Remote, RemoteCommandOutput, ResetMode}; use git::{ repository::{GitRepository, RepoPath}, status::{GitSummary, TrackedSummary}, @@ -265,23 +265,27 @@ impl GitStore { this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, - ) -> Result { + ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); let repository_handle = Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - repository_handle + let remote_output = repository_handle .update(&mut cx, |repository_handle, _cx| repository_handle.fetch())? .await??; - Ok(proto::Ack {}) + + Ok(proto::RemoteMessageResponse { + stdout: remote_output.stdout, + stderr: remote_output.stderr, + }) } async fn handle_push( this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, - ) -> Result { + ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); let repository_handle = @@ -299,19 +303,22 @@ impl GitStore { let branch_name = envelope.payload.branch_name.into(); let remote_name = envelope.payload.remote_name.into(); - repository_handle + let remote_output = repository_handle .update(&mut cx, |repository_handle, _cx| { repository_handle.push(branch_name, remote_name, options) })? .await??; - Ok(proto::Ack {}) + Ok(proto::RemoteMessageResponse { + stdout: remote_output.stdout, + stderr: remote_output.stderr, + }) } async fn handle_pull( this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, - ) -> Result { + ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); let repository_handle = @@ -320,12 +327,15 @@ impl GitStore { let branch_name = envelope.payload.branch_name.into(); let remote_name = envelope.payload.remote_name.into(); - repository_handle + let remote_message = repository_handle .update(&mut cx, |repository_handle, _cx| { repository_handle.pull(branch_name, remote_name) })? .await??; - Ok(proto::Ack {}) + Ok(proto::RemoteMessageResponse { + stdout: remote_message.stdout, + stderr: remote_message.stderr, + }) } async fn handle_stage( @@ -1086,7 +1096,7 @@ impl Repository { }) } - pub fn fetch(&self) -> oneshot::Receiver> { + pub fn fetch(&self) -> oneshot::Receiver> { self.send_job(|git_repo| async move { match git_repo { GitRepo::Local(git_repository) => git_repository.fetch(), @@ -1096,7 +1106,7 @@ impl Repository { worktree_id, work_directory_id, } => { - client + let response = client .request(proto::Fetch { project_id: project_id.0, worktree_id: worktree_id.to_proto(), @@ -1105,7 +1115,10 @@ impl Repository { .await .context("sending fetch request")?; - Ok(()) + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) } } }) @@ -1116,7 +1129,7 @@ impl Repository { branch: SharedString, remote: SharedString, options: Option, - ) -> oneshot::Receiver> { + ) -> oneshot::Receiver> { self.send_job(move |git_repo| async move { match git_repo { GitRepo::Local(git_repository) => git_repository.push(&branch, &remote, options), @@ -1126,7 +1139,7 @@ impl Repository { worktree_id, work_directory_id, } => { - client + let response = client .request(proto::Push { project_id: project_id.0, worktree_id: worktree_id.to_proto(), @@ -1141,7 +1154,10 @@ impl Repository { .await .context("sending push request")?; - Ok(()) + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) } } }) @@ -1151,7 +1167,7 @@ impl Repository { &self, branch: SharedString, remote: SharedString, - ) -> oneshot::Receiver> { + ) -> oneshot::Receiver> { self.send_job(|git_repo| async move { match git_repo { GitRepo::Local(git_repository) => git_repository.pull(&branch, &remote), @@ -1161,7 +1177,7 @@ impl Repository { worktree_id, work_directory_id, } => { - client + let response = client .request(proto::Pull { project_id: project_id.0, worktree_id: worktree_id.to_proto(), @@ -1172,8 +1188,10 @@ impl Repository { .await .context("sending pull request")?; - // TODO: wire through remote - Ok(()) + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) } } }) diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 7ae87aeff2a8fc119a9952d8a72137aacfd0a670..c22710731dc23caa63c734c8d0ad984c40ee2383 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -252,8 +252,10 @@ impl PickerDelegate for ProjectSymbolsDelegate { v_flex() .child( LabelLike::new().child( - StyledText::new(label) - .with_highlights(&window.text_style().clone(), highlights), + StyledText::new(label).with_default_highlights( + &window.text_style().clone(), + highlights, + ), ), ) .child(Label::new(path).color(Color::Muted)), diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index a7aaaef070b2a542c35729ef8c10111daba5735d..9716fafcd45f828fdf8b6e439221eb7007610742 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -330,7 +330,9 @@ message Envelope { Pull pull = 308; ApplyCodeActionKind apply_code_action_kind = 309; - ApplyCodeActionKindResponse apply_code_action_kind_response = 310; // current max + ApplyCodeActionKindResponse apply_code_action_kind_response = 310; + + RemoteMessageResponse remote_message_response = 311; // current max } reserved 87 to 88; @@ -2834,3 +2836,8 @@ message Pull { string remote_name = 4; string branch_name = 5; } + +message RemoteMessageResponse { + string stdout = 1; + string stderr = 2; +} diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 42713cf71c6ad42c49128509a8814cee887ba4dd..e2cd46c4b0d1d3adf10ba8a18ff86dbc532b931d 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -451,6 +451,7 @@ messages!( (GetRemotes, Background), (GetRemotesResponse, Background), (Pull, Background), + (RemoteMessageResponse, Background), ); request_messages!( @@ -589,10 +590,10 @@ request_messages!( (GitReset, Ack), (GitCheckoutFiles, Ack), (SetIndexText, Ack), - (Push, Ack), - (Fetch, Ack), + (Push, RemoteMessageResponse), + (Fetch, RemoteMessageResponse), (GetRemotes, GetRemotesResponse), - (Pull, Ack), + (Pull, RemoteMessageResponse), ); entity_messages!( diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs index 3f0e0cf4482c5d8ccf07a9ee38c3a60609cd3c31..0da84bbfecd49878beb2e1fbcd0cc1df9cf68de3 100644 --- a/crates/rich_text/src/rich_text.rs +++ b/crates/rich_text/src/rich_text.rs @@ -96,7 +96,7 @@ impl RichText { InteractiveText::new( id, - StyledText::new(self.text.clone()).with_highlights( + StyledText::new(self.text.clone()).with_default_highlights( &window.text_style(), self.highlights.iter().map(|(range, highlight)| { ( diff --git a/crates/storybook/src/stories/text.rs b/crates/storybook/src/stories/text.rs index f79ac0e6dd5bfafd7faae437f7db098dbe56e6cf..0743a8b6c3d33df765e59d5df23fb48a044efc81 100644 --- a/crates/storybook/src/stories/text.rs +++ b/crates/storybook/src/stories/text.rs @@ -81,7 +81,7 @@ impl Render for TextStory { "Interactive Text", InteractiveText::new( "interactive", - StyledText::new("Hello world, how is it going?").with_highlights( + StyledText::new("Hello world, how is it going?").with_default_highlights( &window.text_style(), [ ( diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index f5bd6ed7aa0f73e71295f2eb1d7b838cffbf8d06..738d45ae268abcc140472e258c66d158f03648ea 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -237,6 +237,7 @@ pub enum IconName { Menu, MessageBubbles, MessageCircle, + Cloud, Mic, MicMute, Microscope, diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index df6866aee8fad0f3fc485a7693b42b4a048581f3..464d29e5a928e70b25daf0be85ecd73bf911b6d4 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -126,6 +126,6 @@ impl RenderOnce for HighlightedLabel { text_style.color = self.base.color.color(cx); self.base - .child(StyledText::new(self.label).with_highlights(&text_style, highlights)) + .child(StyledText::new(self.label).with_default_highlights(&text_style, highlights)) } } diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index 8197fab4f8303be0e38a1b2cfc2acbd9befd2c87..96062d9ed4d2609488bffa19cf52c3c449b63e7f 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -112,7 +112,7 @@ impl ModalLayer { cx.notify(); } - fn hide_modal(&mut self, window: &mut Window, cx: &mut Context) -> bool { + pub fn hide_modal(&mut self, window: &mut Window, cx: &mut Context) -> bool { let Some(active_modal) = self.active_modal.as_mut() else { self.dismiss_on_focus_lost = false; return false; diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 63dea1db0bc1e1b798cb258b70bf35c0e259e52b..43d012e4184e9b9c756ff1ca3afaceee03bee746 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -1,14 +1,34 @@ use crate::{Toast, Workspace}; use gpui::{ svg, AnyView, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, - Entity, EventEmitter, PromptLevel, Render, ScrollHandle, Task, + Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task, }; use parking_lot::Mutex; +use std::ops::Deref; use std::sync::{Arc, LazyLock}; use std::{any::TypeId, time::Duration}; use ui::{prelude::*, Tooltip}; use util::ResultExt; +#[derive(Default)] +pub struct Notifications { + notifications: Vec<(NotificationId, AnyView)>, +} + +impl Deref for Notifications { + type Target = Vec<(NotificationId, AnyView)>; + + fn deref(&self) -> &Self::Target { + &self.notifications + } +} + +impl std::ops::DerefMut for Notifications { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.notifications + } +} + #[derive(Debug, PartialEq, Clone)] pub enum NotificationId { Unique(TypeId), @@ -34,9 +54,7 @@ impl NotificationId { } } -pub trait Notification: EventEmitter + Render {} - -impl + Render> Notification for V {} +pub trait Notification: EventEmitter + 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::(), 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.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, 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(message: S) -> Self + pub fn new(message: S, cx: &mut App) -> Self where S: Into, { 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 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) -> AnyElement>, primary_message: Option, primary_icon: Option, @@ -390,18 +434,28 @@ pub mod simple_message_notification { title: Option, } + impl Focusable for MessageNotification { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + impl EventEmitter for MessageNotification {} + impl Notification for MessageNotification {} + impl MessageNotification { - pub fn new(message: S) -> MessageNotification + pub fn new(message: S, cx: &mut App) -> MessageNotification where S: Into, { 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(content: F) -> MessageNotification + pub fn new_from_builder(cx: &mut App, content: F) -> MessageNotification where F: 'static + Fn(&mut Window, &mut Context) -> 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) }) } }); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index a89c1a7766c4b482033bccc727dafe7a87052782..94c28855bafce305e2854da2589561522b4d2e06 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1156,11 +1156,14 @@ impl WorkspaceDb { #[cfg(test)] mod tests { + use std::thread; + use std::time::Duration; + use super::*; use crate::persistence::model::SerializedWorkspace; use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup}; use db::open_test_db; - use gpui::{self}; + use gpui; #[gpui::test] async fn test_next_id_stability() { @@ -1556,31 +1559,33 @@ mod tests { }; db.save_workspace(workspace_1.clone()).await; + thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment db.save_workspace(workspace_2.clone()).await; db.save_workspace(workspace_3.clone()).await; + thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment db.save_workspace(workspace_4.clone()).await; db.save_workspace(workspace_5.clone()).await; db.save_workspace(workspace_6.clone()).await; let locations = db.session_workspaces("session-id-1".to_owned()).unwrap(); assert_eq!(locations.len(), 2); - assert_eq!(locations[0].0, LocalPaths::new(["/tmp1"])); + assert_eq!(locations[0].0, LocalPaths::new(["/tmp2"])); assert_eq!(locations[0].1, LocalPathsOrder::new([0])); - assert_eq!(locations[0].2, Some(10)); - assert_eq!(locations[1].0, LocalPaths::new(["/tmp2"])); + assert_eq!(locations[0].2, Some(20)); + assert_eq!(locations[1].0, LocalPaths::new(["/tmp1"])); assert_eq!(locations[1].1, LocalPathsOrder::new([0])); - assert_eq!(locations[1].2, Some(20)); + assert_eq!(locations[1].2, Some(10)); let locations = db.session_workspaces("session-id-2".to_owned()).unwrap(); assert_eq!(locations.len(), 2); - assert_eq!(locations[0].0, LocalPaths::new(["/tmp3"])); - assert_eq!(locations[0].1, LocalPathsOrder::new([0])); - assert_eq!(locations[0].2, Some(30)); let empty_paths: Vec<&str> = Vec::new(); - assert_eq!(locations[1].0, LocalPaths::new(empty_paths.iter())); - assert_eq!(locations[1].1, LocalPathsOrder::new([])); - assert_eq!(locations[1].2, Some(50)); - assert_eq!(locations[1].3, Some(ssh_project.id.0)); + assert_eq!(locations[0].0, LocalPaths::new(empty_paths.iter())); + assert_eq!(locations[0].1, LocalPathsOrder::new([])); + assert_eq!(locations[0].2, Some(50)); + assert_eq!(locations[0].3, Some(ssh_project.id.0)); + assert_eq!(locations[1].0, LocalPaths::new(["/tmp3"])); + assert_eq!(locations[1].1, LocalPathsOrder::new([0])); + assert_eq!(locations[1].2, Some(30)); let locations = db.session_workspaces("session-id-3".to_owned()).unwrap(); assert_eq!(locations.len(), 1); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f28561a002a1d6f77b3a1c4720d2a48cd02f88bc..d8eed0ecda4aeeff0c169a18a62c39003aa1ac99 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -47,7 +47,9 @@ use itertools::Itertools; use language::{LanguageRegistry, Rope}; pub use modal_layer::*; use node_runtime::NodeRuntime; -use notifications::{simple_message_notification::MessageNotification, DetachAndPromptErr}; +use notifications::{ + simple_message_notification::MessageNotification, DetachAndPromptErr, Notifications, +}; pub use pane::*; pub use pane_group::*; pub use persistence::{ @@ -815,7 +817,7 @@ pub struct Workspace { status_bar: Entity, modal_layer: Entity, titlebar_item: Option, - notifications: Vec<(NotificationId, AnyView)>, + notifications: Notifications, project: Entity, follower_states: HashMap, last_leaders_by_pane: HashMap, 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::(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, cx: &mut AsyncA NotificationId::unique::(), 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)) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6d90bd7893cf8f1b271103f74a15956b28d28fac..bb970611a29a479502f614e66df23d7ecdeae268 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1114,11 +1114,14 @@ fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Contex NotificationId::unique::(), cx, |cx| { - cx.new(|_| { - MessageNotification::new(format!( - "Unable to access/open log file at path {:?}", - paths::log_file().as_path() - )) + cx.new(|cx| { + MessageNotification::new( + format!( + "Unable to access/open log file at path {:?}", + paths::log_file().as_path() + ), + cx, + ) }) }, ); @@ -1323,8 +1326,8 @@ fn show_keymap_file_json_error( let message: SharedString = format!("JSON parse error in keymap file. Bindings not reloaded.\n\n{error}").into(); show_app_notification(notification_id, cx, move |cx| { - cx.new(|_cx| { - MessageNotification::new(message.clone()) + cx.new(|cx| { + MessageNotification::new(message.clone(), cx) .primary_message("Open Keymap File") .primary_on_click(|window, cx| { window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); @@ -1381,8 +1384,8 @@ fn show_markdown_app_notification( let parsed_markdown = parsed_markdown.clone(); let primary_button_message = primary_button_message.clone(); let primary_button_on_click = primary_button_on_click.clone(); - cx.new(move |_cx| { - MessageNotification::new_from_builder(move |window, cx| { + cx.new(move |cx| { + MessageNotification::new_from_builder(cx, move |window, cx| { gpui::div() .text_xs() .child(markdown_preview::markdown_renderer::render_parsed_markdown( @@ -1441,8 +1444,8 @@ pub fn handle_settings_changed(error: Option, cx: &mut App) { return; } show_app_notification(id, cx, move |cx| { - cx.new(|_cx| { - MessageNotification::new(format!("Invalid user settings file\n{error}")) + cx.new(|cx| { + MessageNotification::new(format!("Invalid user settings file\n{error}"), cx) .primary_message("Open Settings File") .primary_icon(IconName::Settings) .primary_on_click(|window, cx| { @@ -1580,7 +1583,7 @@ fn open_local_file( struct NoOpenFolders; workspace.show_notification(NotificationId::unique::(), cx, |cx| { - cx.new(|_| MessageNotification::new("This project has no folders open.")) + cx.new(|cx| MessageNotification::new("This project has no folders open.", cx)) }) } } diff --git a/crates/zeta/src/completion_diff_element.rs b/crates/zeta/src/completion_diff_element.rs index e56dca5f9026f6d27d28e2d8374ad75890836a9e..46c8f2c06eb9703071e98a74d7f3441128fa7559 100644 --- a/crates/zeta/src/completion_diff_element.rs +++ b/crates/zeta/src/completion_diff_element.rs @@ -78,7 +78,7 @@ impl CompletionDiffElement { font_style: settings.buffer_font.style, ..Default::default() }; - let element = StyledText::new(diff).with_highlights(&text_style, diff_highlights); + let element = StyledText::new(diff).with_default_highlights(&text_style, diff_highlights); let text_layout = element.layout().clone(); CompletionDiffElement { diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index e2342f9e36a94112776633d98f286ca3e83eacf8..ab38e36c18499d236b19f943b004df932d0ce3bc 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -489,8 +489,8 @@ impl Zeta { NotificationId::unique::(), cx, |cx| { - cx.new(|_| { - ErrorMessagePrompt::new(err.to_string()) + cx.new(|cx| { + ErrorMessagePrompt::new(err.to_string(), cx) .with_link_button( "Update Zed", "https://zed.dev/releases",