Detailed changes
@@ -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",
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>
@@ -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 }],
@@ -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);
+ })
})
},
);
@@ -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, || {
@@ -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<Arc<User>>,
text: String,
workspace: WeakEntity<Workspace>,
+ 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<Self>) {
let workspace = self.workspace.clone();
@@ -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()
@@ -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
@@ -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| {
(
@@ -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()
@@ -168,11 +168,14 @@ pub(crate) fn suggest(buffer: Entity<Buffer>, 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)
@@ -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)
}
}
@@ -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
@@ -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<UpstreamTrackingStatus> 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<UpstreamTrackingStatus> 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<PushOptions>,
- ) -> Result<()>;
- fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<()>;
+ ) -> Result<RemoteCommandOutput>;
+ fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<RemoteCommandOutput>;
fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
- fn fetch(&self) -> Result<()>;
+ fn fetch(&self) -> Result<RemoteCommandOutput>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
@@ -611,19 +625,30 @@ impl GitRepository for RealGitRepository {
branch_name: &str,
remote_name: &str,
options: Option<PushOptions>,
- ) -> Result<()> {
+ ) -> Result<RemoteCommandOutput> {
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<RemoteCommandOutput> {
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<RemoteCommandOutput> {
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<Mutex<FakeGitRepositoryState>>,
@@ -899,15 +967,20 @@ impl GitRepository for FakeGitRepository {
unimplemented!()
}
- fn push(&self, _branch: &str, _remote: &str, _options: Option<PushOptions>) -> Result<()> {
+ fn push(
+ &self,
+ _branch: &str,
+ _remote: &str,
+ _options: Option<PushOptions>,
+ ) -> Result<RemoteCommandOutput> {
unimplemented!()
}
- fn pull(&self, _branch: &str, _remote: &str) -> Result<()> {
+ fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
unimplemented!()
}
- fn fetch(&self) -> Result<()> {
+ fn fetch(&self) -> Result<RemoteCommandOutput> {
unimplemented!()
}
@@ -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
@@ -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<Self>) {
+ 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<Self>) {
+ 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<Self>,
- ) -> impl Future<Output = Result<Remote>> {
+ ) -> impl Future<Output = Result<Option<Remote>>> {
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<impl IntoElement> {
+ 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<impl IntoElement> {
(!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(
@@ -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) {
@@ -26,7 +26,7 @@ pub fn prompt(
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
-) -> Task<Result<usize>> {
+) -> Task<Result<Option<usize>>> {
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
+ }
})
}
@@ -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<Range<usize>>,
+}
+
+pub struct RemoteOutputToast {
+ _workspace: WeakEntity<Workspace>,
+ _id: NotificationId,
+ message: SharedString,
+ remote_info: Option<InfoFromRemote>,
+ _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<Workspace>,
+ cx: &mut Context<Self>,
+ ) -> 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<DismissEvent> 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::<Vec<_>>()
+ .join("\n")
+}
@@ -137,6 +137,7 @@ impl IntoElement for SharedString {
pub struct StyledText {
text: SharedString,
runs: Option<Vec<TextRun>>,
+ delayed_highlights: Option<Vec<(Range<usize>, 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<Item = (Range<usize>, 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<Item = (Range<usize>, 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::<Vec<_>>());
+ self
+ }
+
+ fn compute_runs(
+ text: &str,
+ default_style: &TextStyle,
+ highlights: impl IntoIterator<Item = (Range<usize>, HighlightStyle)>,
+ ) -> Vec<TextRun> {
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, ())
}
@@ -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
@@ -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();
@@ -359,7 +359,7 @@ pub fn render_item<T>(
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)]
@@ -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<Self>,
envelope: TypedEnvelope<proto::Fetch>,
mut cx: AsyncApp,
- ) -> Result<proto::Ack> {
+ ) -> Result<proto::RemoteMessageResponse> {
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<Self>,
envelope: TypedEnvelope<proto::Push>,
mut cx: AsyncApp,
- ) -> Result<proto::Ack> {
+ ) -> Result<proto::RemoteMessageResponse> {
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<Self>,
envelope: TypedEnvelope<proto::Pull>,
mut cx: AsyncApp,
- ) -> Result<proto::Ack> {
+ ) -> Result<proto::RemoteMessageResponse> {
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<Result<()>> {
+ pub fn fetch(&self) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
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<PushOptions>,
- ) -> oneshot::Receiver<Result<()>> {
+ ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
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<Result<()>> {
+ ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
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,
+ })
}
}
})
@@ -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)),
@@ -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;
+}
@@ -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!(
@@ -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)| {
(
@@ -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(),
[
(
@@ -237,6 +237,7 @@ pub enum IconName {
Menu,
MessageBubbles,
MessageCircle,
+ Cloud,
Mic,
MicMute,
Microscope,
@@ -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))
}
}
@@ -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;
@@ -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)
})
}
});
@@ -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);
@@ -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))
@@ -1114,11 +1114,14 @@ fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Contex
NotificationId::unique::<OpenLogError>(),
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<F>(
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<anyhow::Error>, 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::<NoOpenFolders>(), cx, |cx| {
- cx.new(|_| MessageNotification::new("This project has no folders open."))
+ cx.new(|cx| MessageNotification::new("This project has no folders open.", cx))
})
}
}
@@ -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 {
@@ -489,8 +489,8 @@ impl Zeta {
NotificationId::unique::<ZedUpdateRequiredError>(),
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",