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

This PR adds toasts for reporting success and errors from remote git
operations. This PR also adds a focus handle to notifications, in
anticipation of making them keyboard accessible.

Release Notes:

- N/A

---------

Co-authored-by: julia <julia@zed.dev>
This commit is contained in:
Mikayla Maki 2025-03-03 01:20:15 -08:00 committed by GitHub
parent 508b9d3b5d
commit 73ac19958a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 713 additions and 192 deletions

3
Cargo.lock generated
View file

@ -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",

1
assets/icons/cloud.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 279 B

View file

@ -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 }],

View file

@ -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);
})
})
},
);

View file

@ -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, || {

View file

@ -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();

View file

@ -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()

View file

@ -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

View file

@ -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| {
(

View file

@ -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()

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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

View file

@ -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!()
}

View file

@ -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

View file

@ -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 Some(branch) = repo.read(cx).current_branch() else {
return Err(anyhow::anyhow!("No active branch"));
};
Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
})??
.await??;
let pull = repo.update(&mut cx, |repo, _cx| {
repo.pull(branch.name.clone(), remote.name.clone())
})?;
let remote_message = pull.await?;
drop(guard);
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();
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?;
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 push = repo.update(&mut cx, |repo, _cx| {
repo.push(branch.name.clone(), remote.name.clone(), options)
})?;
let Some(branch) = repo.read(cx).current_branch() else {
return Err(anyhow::anyhow!("No active branch"));
};
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(

View file

@ -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) {

View file

@ -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
}
})
}

View file

@ -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")
}

View file

@ -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, ())
}

View file

@ -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

View file

@ -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();

View file

@ -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)]

View file

@ -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,
})
}
}
})

View file

@ -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)),

View file

@ -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;
}

View file

@ -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!(

View file

@ -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)| {
(

View file

@ -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(),
[
(

View file

@ -237,6 +237,7 @@ pub enum IconName {
Menu,
MessageBubbles,
MessageCircle,
Cloud,
Mic,
MicMute,
Microscope,

View file

@ -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))
}
}

View file

@ -112,7 +112,7 @@ impl ModalLayer {
cx.notify();
}
fn hide_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
pub fn hide_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(active_modal) = self.active_modal.as_mut() else {
self.dismiss_on_focus_lost = false;
return false;

View file

@ -1,14 +1,34 @@
use crate::{Toast, Workspace};
use gpui::{
svg, AnyView, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
Entity, EventEmitter, PromptLevel, Render, ScrollHandle, Task,
Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task,
};
use parking_lot::Mutex;
use std::ops::Deref;
use std::sync::{Arc, LazyLock};
use std::{any::TypeId, time::Duration};
use ui::{prelude::*, Tooltip};
use util::ResultExt;
#[derive(Default)]
pub struct Notifications {
notifications: Vec<(NotificationId, AnyView)>,
}
impl Deref for Notifications {
type Target = Vec<(NotificationId, AnyView)>;
fn deref(&self) -> &Self::Target {
&self.notifications
}
}
impl std::ops::DerefMut for Notifications {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.notifications
}
}
#[derive(Debug, PartialEq, Clone)]
pub enum NotificationId {
Unique(TypeId),
@ -34,9 +54,7 @@ impl NotificationId {
}
}
pub trait Notification: EventEmitter<DismissEvent> + Render {}
impl<V: EventEmitter<DismissEvent> + Render> Notification for V {}
pub trait Notification: EventEmitter<DismissEvent> + Focusable + Render {}
impl Workspace {
#[cfg(any(test, feature = "test-support"))]
@ -89,7 +107,7 @@ impl Workspace {
E: std::fmt::Debug + std::fmt::Display,
{
self.show_notification(workspace_error_notification_id(), cx, |cx| {
cx.new(|_| ErrorMessagePrompt::new(format!("Error: {err}")))
cx.new(|cx| ErrorMessagePrompt::new(format!("Error: {err}"), cx))
});
}
@ -97,8 +115,8 @@ impl Workspace {
struct PortalError;
self.show_notification(NotificationId::unique::<PortalError>(), cx, |cx| {
cx.new(|_| {
ErrorMessagePrompt::new(err.to_string()).with_link_button(
cx.new(|cx| {
ErrorMessagePrompt::new(err.to_string(), cx).with_link_button(
"See docs",
"https://zed.dev/docs/linux#i-cant-open-any-files",
)
@ -120,14 +138,16 @@ impl Workspace {
pub fn show_toast(&mut self, toast: Toast, cx: &mut Context<Self>) {
self.dismiss_notification(&toast.id, cx);
self.show_notification(toast.id.clone(), cx, |cx| {
cx.new(|_| match toast.on_click.as_ref() {
cx.new(|cx| match toast.on_click.as_ref() {
Some((click_msg, on_click)) => {
let on_click = on_click.clone();
simple_message_notification::MessageNotification::new(toast.msg.clone())
simple_message_notification::MessageNotification::new(toast.msg.clone(), cx)
.primary_message(click_msg.clone())
.primary_on_click(move |window, cx| on_click(window, cx))
}
None => simple_message_notification::MessageNotification::new(toast.msg.clone()),
None => {
simple_message_notification::MessageNotification::new(toast.msg.clone(), cx)
}
})
});
if toast.autohide {
@ -171,13 +191,23 @@ impl Workspace {
}
pub struct LanguageServerPrompt {
focus_handle: FocusHandle,
request: Option<project::LanguageServerPromptRequest>,
scroll_handle: ScrollHandle,
}
impl Focusable for LanguageServerPrompt {
fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Notification for LanguageServerPrompt {}
impl LanguageServerPrompt {
pub fn new(request: project::LanguageServerPromptRequest) -> Self {
pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
request: Some(request),
scroll_handle: ScrollHandle::new(),
}
@ -286,16 +316,18 @@ fn workspace_error_notification_id() -> NotificationId {
#[derive(Debug, Clone)]
pub struct ErrorMessagePrompt {
message: SharedString,
focus_handle: gpui::FocusHandle,
label_and_url_button: Option<(SharedString, SharedString)>,
}
impl ErrorMessagePrompt {
pub fn new<S>(message: S) -> Self
pub fn new<S>(message: S, cx: &mut App) -> Self
where
S: Into<SharedString>,
{
Self {
message: message.into(),
focus_handle: cx.focus_handle(),
label_and_url_button: None,
}
}
@ -364,17 +396,29 @@ impl Render for ErrorMessagePrompt {
}
}
impl Focusable for ErrorMessagePrompt {
fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
impl Notification for ErrorMessagePrompt {}
pub mod simple_message_notification {
use std::sync::Arc;
use gpui::{
div, AnyElement, DismissEvent, EventEmitter, ParentElement, Render, SharedString, Styled,
div, AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render,
SharedString, Styled,
};
use ui::prelude::*;
use super::Notification;
pub struct MessageNotification {
focus_handle: FocusHandle,
build_content: Box<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
primary_message: Option<SharedString>,
primary_icon: Option<IconName>,
@ -390,18 +434,28 @@ pub mod simple_message_notification {
title: Option<SharedString>,
}
impl Focusable for MessageNotification {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for MessageNotification {}
impl Notification for MessageNotification {}
impl MessageNotification {
pub fn new<S>(message: S) -> MessageNotification
pub fn new<S>(message: S, cx: &mut App) -> MessageNotification
where
S: Into<SharedString>,
{
let message = message.into();
Self::new_from_builder(move |_, _| Label::new(message.clone()).into_any_element())
Self::new_from_builder(cx, move |_, _| {
Label::new(message.clone()).into_any_element()
})
}
pub fn new_from_builder<F>(content: F) -> MessageNotification
pub fn new_from_builder<F>(cx: &mut App, content: F) -> MessageNotification
where
F: 'static + Fn(&mut Window, &mut Context<Self>) -> AnyElement,
{
@ -419,6 +473,7 @@ pub mod simple_message_notification {
more_info_url: None,
show_close_button: true,
title: None,
focus_handle: cx.focus_handle(),
}
}
@ -769,7 +824,7 @@ where
move |cx| {
cx.new({
let message = message.clone();
move |_cx| ErrorMessagePrompt::new(message)
move |cx| ErrorMessagePrompt::new(message, cx)
})
}
});

View file

@ -1156,11 +1156,14 @@ impl WorkspaceDb {
#[cfg(test)]
mod tests {
use std::thread;
use std::time::Duration;
use super::*;
use crate::persistence::model::SerializedWorkspace;
use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
use db::open_test_db;
use gpui::{self};
use gpui;
#[gpui::test]
async fn test_next_id_stability() {
@ -1556,31 +1559,33 @@ mod tests {
};
db.save_workspace(workspace_1.clone()).await;
thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
db.save_workspace(workspace_2.clone()).await;
db.save_workspace(workspace_3.clone()).await;
thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
db.save_workspace(workspace_4.clone()).await;
db.save_workspace(workspace_5.clone()).await;
db.save_workspace(workspace_6.clone()).await;
let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].0, LocalPaths::new(["/tmp1"]));
assert_eq!(locations[0].0, LocalPaths::new(["/tmp2"]));
assert_eq!(locations[0].1, LocalPathsOrder::new([0]));
assert_eq!(locations[0].2, Some(10));
assert_eq!(locations[1].0, LocalPaths::new(["/tmp2"]));
assert_eq!(locations[0].2, Some(20));
assert_eq!(locations[1].0, LocalPaths::new(["/tmp1"]));
assert_eq!(locations[1].1, LocalPathsOrder::new([0]));
assert_eq!(locations[1].2, Some(20));
assert_eq!(locations[1].2, Some(10));
let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].0, LocalPaths::new(["/tmp3"]));
assert_eq!(locations[0].1, LocalPathsOrder::new([0]));
assert_eq!(locations[0].2, Some(30));
let empty_paths: Vec<&str> = Vec::new();
assert_eq!(locations[1].0, LocalPaths::new(empty_paths.iter()));
assert_eq!(locations[1].1, LocalPathsOrder::new([]));
assert_eq!(locations[1].2, Some(50));
assert_eq!(locations[1].3, Some(ssh_project.id.0));
assert_eq!(locations[0].0, LocalPaths::new(empty_paths.iter()));
assert_eq!(locations[0].1, LocalPathsOrder::new([]));
assert_eq!(locations[0].2, Some(50));
assert_eq!(locations[0].3, Some(ssh_project.id.0));
assert_eq!(locations[1].0, LocalPaths::new(["/tmp3"]));
assert_eq!(locations[1].1, LocalPathsOrder::new([0]));
assert_eq!(locations[1].2, Some(30));
let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
assert_eq!(locations.len(), 1);

View file

@ -47,7 +47,9 @@ use itertools::Itertools;
use language::{LanguageRegistry, Rope};
pub use modal_layer::*;
use node_runtime::NodeRuntime;
use notifications::{simple_message_notification::MessageNotification, DetachAndPromptErr};
use notifications::{
simple_message_notification::MessageNotification, DetachAndPromptErr, Notifications,
};
pub use pane::*;
pub use pane_group::*;
pub use persistence::{
@ -815,7 +817,7 @@ pub struct Workspace {
status_bar: Entity<StatusBar>,
modal_layer: Entity<ModalLayer>,
titlebar_item: Option<AnyView>,
notifications: Vec<(NotificationId, AnyView)>,
notifications: Notifications,
project: Entity<Project>,
follower_states: HashMap<PeerId, FollowerState>,
last_leaders_by_pane: HashMap<WeakEntity<Pane>, PeerId>,
@ -920,7 +922,7 @@ impl Workspace {
} => this.show_notification(
NotificationId::named(notification_id.clone()),
cx,
|cx| cx.new(|_| MessageNotification::new(message.clone())),
|cx| cx.new(|cx| MessageNotification::new(message.clone(), cx)),
),
project::Event::HideToast { notification_id } => {
@ -937,7 +939,11 @@ impl Workspace {
this.show_notification(
NotificationId::composite::<LanguageServerPrompt>(id as usize),
cx,
|cx| cx.new(|_| notifications::LanguageServerPrompt::new(request.clone())),
|cx| {
cx.new(|cx| {
notifications::LanguageServerPrompt::new(request.clone(), cx)
})
},
);
}
@ -5223,8 +5229,8 @@ fn notify_if_database_failed(workspace: WindowHandle<Workspace>, cx: &mut AsyncA
NotificationId::unique::<DatabaseFailedNotification>(),
cx,
|cx| {
cx.new(|_| {
MessageNotification::new("Failed to load the database file.")
cx.new(|cx| {
MessageNotification::new("Failed to load the database file.", cx)
.primary_message("File an Issue")
.primary_icon(IconName::Plus)
.primary_on_click(|_window, cx| cx.open_url(REPORT_ISSUE_URL))

View file

@ -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))
})
}
}

View file

@ -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 {

View file

@ -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",