Enable configurable dismissal of language server notifications that do not require user interaction (#46708)

Closes #38769

Release Notes:

- Dismiss server notifications automatically with
`"global_lsp_settings": { "notifications": { "dismiss_timeout_ms": 5000
} }` settings defaults.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Jens Kouros 2026-01-22 16:12:21 +01:00 committed by GitHub
parent e9aadaf0af
commit 4bc3b710ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 364 additions and 42 deletions

View file

@ -2236,6 +2236,11 @@
"global_lsp_settings": {
// Whether to show the LSP servers button in the status bar.
"button": true,
"notifications": {
// Timeout in milliseconds for automatically dismissing language server notifications.
// Set to 0 to disable auto-dismiss.
"dismiss_timeout_ms": 5000,
},
},
// Jupyter settings
"jupyter": {

View file

@ -145,6 +145,7 @@ const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5
pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100);
const WORKSPACE_DIAGNOSTICS_TOKEN_START: &str = "id:";
const SERVER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10);
static NEXT_PROMPT_REQUEST_ID: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub enum ProgressToken {
@ -1095,18 +1096,19 @@ impl LocalLspStore {
async move {
let actions = params.actions.unwrap_or_default();
let message = params.message.clone();
let (tx, rx) = smol::channel::bounded(1);
let request = LanguageServerPromptRequest {
level: match params.typ {
lsp::MessageType::ERROR => PromptLevel::Critical,
lsp::MessageType::WARNING => PromptLevel::Warning,
_ => PromptLevel::Info,
},
message: params.message,
actions,
response_channel: tx,
lsp_name: name.clone(),
let (tx, rx) = smol::channel::bounded::<MessageActionItem>(1);
let level = match params.typ {
lsp::MessageType::ERROR => PromptLevel::Critical,
lsp::MessageType::WARNING => PromptLevel::Warning,
_ => PromptLevel::Info,
};
let request = LanguageServerPromptRequest::new(
level,
params.message,
actions,
name.clone(),
tx,
);
let did_update = this
.update(&mut cx, |_, cx| {
@ -1141,17 +1143,13 @@ impl LocalLspStore {
let mut cx = cx.clone();
let (tx, _) = smol::channel::bounded(1);
let request = LanguageServerPromptRequest {
level: match params.typ {
lsp::MessageType::ERROR => PromptLevel::Critical,
lsp::MessageType::WARNING => PromptLevel::Warning,
_ => PromptLevel::Info,
},
message: params.message,
actions: vec![],
response_channel: tx,
lsp_name: name,
let level = match params.typ {
lsp::MessageType::ERROR => PromptLevel::Critical,
lsp::MessageType::WARNING => PromptLevel::Warning,
_ => PromptLevel::Info,
};
let request =
LanguageServerPromptRequest::new(level, params.message, vec![], name, tx);
let _ = this.update(&mut cx, |_, cx| {
cx.emit(LspStoreEvent::LanguageServerPrompt(request));
@ -13755,6 +13753,7 @@ struct LspBufferSnapshot {
/// A prompt requested by LSP server.
#[derive(Clone, Debug)]
pub struct LanguageServerPromptRequest {
pub id: usize,
pub level: PromptLevel,
pub message: String,
pub actions: Vec<MessageActionItem>,
@ -13763,6 +13762,23 @@ pub struct LanguageServerPromptRequest {
}
impl LanguageServerPromptRequest {
pub fn new(
level: PromptLevel,
message: String,
actions: Vec<MessageActionItem>,
lsp_name: String,
response_channel: smol::channel::Sender<MessageActionItem>,
) -> Self {
let id = NEXT_PROMPT_REQUEST_ID.fetch_add(1, atomic::Ordering::AcqRel);
LanguageServerPromptRequest {
id,
level,
message,
actions,
lsp_name,
response_channel,
}
}
pub async fn respond(self, index: usize) -> Option<()> {
if let Some(response) = self.actions.into_iter().nth(index) {
self.response_channel.send(response).await.ok()
@ -13770,6 +13786,17 @@ impl LanguageServerPromptRequest {
None
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn test(
level: PromptLevel,
message: String,
actions: Vec<MessageActionItem>,
lsp_name: String,
) -> Self {
let (tx, _rx) = smol::channel::unbounded();
LanguageServerPromptRequest::new(level, message, actions, lsp_name, tx)
}
}
impl PartialEq for LanguageServerPromptRequest {
fn eq(&self, other: &Self) -> bool {

View file

@ -4913,13 +4913,15 @@ impl Project {
})
.collect();
this.update(&mut cx, |_, cx| {
cx.emit(Event::LanguageServerPrompt(LanguageServerPromptRequest {
level: proto_to_prompt(envelope.payload.level.context("Invalid prompt level")?),
message: envelope.payload.message,
actions: actions.clone(),
lsp_name: envelope.payload.lsp_name,
response_channel: tx,
}));
cx.emit(Event::LanguageServerPrompt(
LanguageServerPromptRequest::new(
proto_to_prompt(envelope.payload.level.context("Invalid prompt level")?),
envelope.payload.message,
actions.clone(),
envelope.payload.lsp_name,
tx,
),
));
anyhow::Ok(())
})?;

View file

@ -123,6 +123,17 @@ pub struct GlobalLspSettings {
///
/// Default: `true`
pub button: bool,
pub notifications: LspNotificationSettings,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
#[serde(tag = "source", rename_all = "snake_case")]
pub struct LspNotificationSettings {
/// Timeout in milliseconds for automatically dismissing language server notifications.
/// Set to 0 to disable auto-dismiss.
///
/// Default: 5000
pub dismiss_timeout_ms: Option<u64>,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
@ -614,6 +625,16 @@ impl Settings for ProjectSettings {
.unwrap()
.button
.unwrap(),
notifications: LspNotificationSettings {
dismiss_timeout_ms: content
.global_lsp_settings
.as_ref()
.unwrap()
.notifications
.as_ref()
.unwrap()
.dismiss_timeout_ms,
},
},
dap: project
.dap

View file

@ -199,6 +199,18 @@ pub struct GlobalLspSettingsContent {
///
/// Default: `true`
pub button: Option<bool>,
/// Settings for language server notifications
pub notifications: Option<LspNotificationSettingsContent>,
}
#[with_fallible_options]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct LspNotificationSettingsContent {
/// Timeout in milliseconds for automatically dismissing language server notifications.
/// Set to 0 to disable auto-dismiss.
///
/// Default: 5000
pub dismiss_timeout_ms: Option<u64>,
}
#[with_fallible_options]

View file

@ -1,12 +1,13 @@
use crate::{SuppressNotification, Toast, Workspace};
use anyhow::Context as _;
use gpui::{
AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task,
TextStyleRefinement, UnderlineStyle, svg,
AnyEntity, AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context,
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
Task, TextStyleRefinement, UnderlineStyle, svg,
};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
use project::project_settings::ProjectSettings;
use settings::Settings;
use theme::ThemeSettings;
@ -99,6 +100,40 @@ impl Workspace {
}
})
.detach();
if let Ok(prompt) =
AnyEntity::from(notification.clone()).downcast::<LanguageServerPrompt>()
{
let is_prompt_without_actions = prompt
.read(cx)
.request
.as_ref()
.is_some_and(|request| request.actions.is_empty());
let dismiss_timeout_ms = ProjectSettings::get_global(cx)
.global_lsp_settings
.notifications
.dismiss_timeout_ms;
if is_prompt_without_actions {
if let Some(dismiss_duration_ms) = dismiss_timeout_ms.filter(|&ms| ms > 0) {
let task = cx.spawn({
let id = id.clone();
async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(dismiss_duration_ms))
.await;
let _ = this.update(cx, |workspace, cx| {
workspace.dismiss_notification(&id, cx);
});
}
});
prompt.update(cx, |prompt, _| {
prompt.dismiss_task = Some(task);
});
}
}
}
notification.into()
});
}
@ -220,6 +255,7 @@ pub struct LanguageServerPrompt {
request: Option<project::LanguageServerPromptRequest>,
scroll_handle: ScrollHandle,
markdown: Entity<Markdown>,
dismiss_task: Option<Task<()>>,
}
impl Focusable for LanguageServerPrompt {
@ -239,6 +275,7 @@ impl LanguageServerPrompt {
request: Some(request),
scroll_handle: ScrollHandle::new(),
markdown,
dismiss_task: None,
}
}
@ -253,13 +290,20 @@ impl LanguageServerPrompt {
.await
.context("Stream already closed")?;
this.update(cx, |_, cx| cx.emit(DismissEvent));
this.update(cx, |this, cx| {
this.dismiss_notification(cx);
});
anyhow::Ok(())
})
.await
.log_err();
}
fn dismiss_notification(&mut self, cx: &mut Context<Self>) {
self.dismiss_task = None;
cx.emit(DismissEvent);
}
}
impl Render for LanguageServerPrompt {
@ -334,11 +378,11 @@ impl Render for LanguageServerPrompt {
}
})
.on_click(cx.listener(
move |_, _: &ClickEvent, _, cx| {
move |this, _: &ClickEvent, _, cx| {
if suppress {
cx.emit(SuppressEvent);
} else {
cx.emit(DismissEvent);
this.dismiss_notification(cx);
}
},
)),
@ -1161,3 +1205,211 @@ where
self.prompt_err(msg, window, cx, f).detach();
}
}
#[cfg(test)]
mod tests {
use fs::FakeFs;
use gpui::TestAppContext;
use project::{LanguageServerPromptRequest, Project};
use crate::tests::init_test;
use super::*;
#[gpui::test]
async fn test_notification_auto_dismiss_with_notifications_from_multiple_language_servers(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
};
let show_notification = |workspace: &Entity<Workspace>,
cx: &mut TestAppContext,
lsp_name: &str| {
workspace.update(cx, |workspace, cx| {
let request = LanguageServerPromptRequest::test(
gpui::PromptLevel::Warning,
"Test notification".to_string(),
vec![], // Empty actions triggers auto-dismiss
lsp_name.to_string(),
);
let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
workspace.show_notification(notification_id, cx, |cx| {
cx.new(|cx| LanguageServerPrompt::new(request, cx))
});
})
};
show_notification(&workspace, cx, "Lsp1");
assert_eq!(count_notifications(&workspace, cx), 1);
cx.executor().advance_clock(Duration::from_millis(1000));
show_notification(&workspace, cx, "Lsp2");
assert_eq!(count_notifications(&workspace, cx), 2);
cx.executor().advance_clock(Duration::from_millis(1000));
show_notification(&workspace, cx, "Lsp3");
assert_eq!(count_notifications(&workspace, cx), 3);
cx.executor().advance_clock(Duration::from_millis(3000));
assert_eq!(count_notifications(&workspace, cx), 2);
cx.executor().advance_clock(Duration::from_millis(1000));
assert_eq!(count_notifications(&workspace, cx), 1);
cx.executor().advance_clock(Duration::from_millis(1000));
assert_eq!(count_notifications(&workspace, cx), 0);
}
#[gpui::test]
async fn test_notification_auto_dismiss_with_multiple_notifications_from_single_language_server(
cx: &mut TestAppContext,
) {
init_test(cx);
let lsp_name = "server1";
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
};
let show_notification = |lsp_name: &str,
workspace: &Entity<Workspace>,
cx: &mut TestAppContext| {
workspace.update(cx, |workspace, cx| {
let lsp_name = lsp_name.to_string();
let request = LanguageServerPromptRequest::test(
gpui::PromptLevel::Warning,
"Test notification".to_string(),
vec![], // Empty actions triggers auto-dismiss
lsp_name,
);
let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
workspace.show_notification(notification_id, cx, |cx| {
cx.new(|cx| LanguageServerPrompt::new(request, cx))
});
})
};
show_notification(lsp_name, &workspace, cx);
assert_eq!(count_notifications(&workspace, cx), 1);
cx.executor().advance_clock(Duration::from_millis(1000));
show_notification(lsp_name, &workspace, cx);
assert_eq!(count_notifications(&workspace, cx), 2);
cx.executor().advance_clock(Duration::from_millis(4000));
assert_eq!(count_notifications(&workspace, cx), 1);
cx.executor().advance_clock(Duration::from_millis(1000));
assert_eq!(count_notifications(&workspace, cx), 0);
}
#[gpui::test]
async fn test_notification_auto_dismiss_turned_off(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = ProjectSettings::get_global(cx).clone();
settings
.global_lsp_settings
.notifications
.dismiss_timeout_ms = Some(0);
ProjectSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
};
workspace.update(cx, |workspace, cx| {
let request = LanguageServerPromptRequest::test(
gpui::PromptLevel::Warning,
"Test notification".to_string(),
vec![], // Empty actions would trigger auto-dismiss if enabled
"test_server".to_string(),
);
let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
workspace.show_notification(notification_id, cx, |cx| {
cx.new(|cx| LanguageServerPrompt::new(request, cx))
});
});
assert_eq!(count_notifications(&workspace, cx), 1);
// Advance time beyond the default auto-dismiss duration
cx.executor().advance_clock(Duration::from_millis(10000));
assert_eq!(count_notifications(&workspace, cx), 1);
}
#[gpui::test]
async fn test_notification_auto_dismiss_with_custom_duration(cx: &mut TestAppContext) {
init_test(cx);
let custom_duration_ms: u64 = 2000;
cx.update(|cx| {
let mut settings = ProjectSettings::get_global(cx).clone();
settings
.global_lsp_settings
.notifications
.dismiss_timeout_ms = Some(custom_duration_ms);
ProjectSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
};
workspace.update(cx, |workspace, cx| {
let request = LanguageServerPromptRequest::test(
gpui::PromptLevel::Warning,
"Test notification".to_string(),
vec![], // Empty actions triggers auto-dismiss
"test_server".to_string(),
);
let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
workspace.show_notification(notification_id, cx, |cx| {
cx.new(|cx| LanguageServerPrompt::new(request, cx))
});
});
assert_eq!(count_notifications(&workspace, cx), 1);
// Advance time less than custom duration
cx.executor()
.advance_clock(Duration::from_millis(custom_duration_ms - 500));
assert_eq!(count_notifications(&workspace, cx), 1);
// Advance time past the custom duration
cx.executor().advance_clock(Duration::from_millis(1000));
assert_eq!(count_notifications(&workspace, cx), 0);
}
}

View file

@ -104,9 +104,9 @@ use std::{
borrow::Cow,
cell::RefCell,
cmp,
collections::{VecDeque, hash_map::DefaultHasher},
collections::VecDeque,
env,
hash::{Hash, Hasher},
hash::Hash,
path::{Path, PathBuf},
process::ExitStatus,
rc::Rc,
@ -1359,12 +1359,8 @@ impl Workspace {
project::Event::LanguageServerPrompt(request) => {
struct LanguageServerPrompt;
let mut hasher = DefaultHasher::new();
request.lsp_name.as_str().hash(&mut hasher);
let id = hasher.finish();
this.show_notification(
NotificationId::composite::<LanguageServerPrompt>(id as usize),
NotificationId::composite::<LanguageServerPrompt>(request.id),
cx,
|cx| {
cx.new(|cx| {

View file

@ -1598,7 +1598,12 @@ While other options may be changed at a runtime and should be placed under `sett
```json [settings]
{
"global_lsp_settings": {
"button": true
"button": true,
"notifications": {
// Timeout in milliseconds for automatically dismissing language server notifications.
// Set to 0 to disable auto-dismiss.
"dismiss_timeout_ms": 5000
}
}
}
```
@ -1606,6 +1611,8 @@ While other options may be changed at a runtime and should be placed under `sett
**Options**
- `button`: Whether to show the LSP status button in the status bar
- `notifications`: Notification-related settings.
- `dismiss_timeout_ms`: Timeout in milliseconds for automatically dismissing language server notifications. Set to 0 to disable auto-dismiss.
## LSP Highlight Debounce