From 4bc3b710ee556bd20d4a92b6565b22a8fca8102c Mon Sep 17 00:00:00 2001 From: Jens Kouros Date: Thu, 22 Jan 2026 16:12:21 +0100 Subject: [PATCH] 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 --- assets/settings/default.json | 5 + crates/project/src/lsp_store.rs | 69 +++++-- crates/project/src/project.rs | 16 +- crates/project/src/project_settings.rs | 21 ++ crates/settings_content/src/project.rs | 12 ++ crates/workspace/src/notifications.rs | 264 ++++++++++++++++++++++++- crates/workspace/src/workspace.rs | 10 +- docs/src/reference/all-settings.md | 9 +- 8 files changed, 364 insertions(+), 42 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 9a8a3da63308e02e7fe34aca0f0527291bec07c1..75669235af0a4a0cd1128b84a0537ecc6b17b9b1 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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": { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index f9b45a0d98282edee3013b4db4550bb8eea8e004..61c8ec10b2e66c98dbb9a4adf68dbc67c03caead 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -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::(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, @@ -13763,6 +13762,23 @@ pub struct LanguageServerPromptRequest { } impl LanguageServerPromptRequest { + pub fn new( + level: PromptLevel, + message: String, + actions: Vec, + lsp_name: String, + response_channel: smol::channel::Sender, + ) -> 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, + 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 { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9a8f9e5ee40b6d9be1db3fc00e093fe4804a9d97..85717785c50151b40d9ca13c5bcfa2bc603124c6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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(()) })?; diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 493f6da8948ae3e63664ea79d721fbd1417d7748..af730c6e86290dc6bf7a6dd6a2c430e4c95224c7 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -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, } #[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 diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 38c8eb89b4f102672ba3582f5ecb88b89df17983..0273724c9e19cd1a88081b5deec7438a8e08041a 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -199,6 +199,18 @@ pub struct GlobalLspSettingsContent { /// /// Default: `true` pub button: Option, + /// Settings for language server notifications + pub notifications: Option, +} + +#[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, } #[with_fallible_options] diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 03c7f624de381a3d4ca871eeba3f1d01577207af..8480d1276705c50b824b878cf8626e85ea9fcdbe 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -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::() + { + 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, scroll_handle: ScrollHandle, markdown: Entity, + dismiss_task: Option>, } 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.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, cx: &mut TestAppContext| { + workspace.read_with(cx, |workspace, _| workspace.notification_ids().len()) + }; + + let show_notification = |workspace: &Entity, + 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::(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, cx: &mut TestAppContext| { + workspace.read_with(cx, |workspace, _| workspace.notification_ids().len()) + }; + + let show_notification = |lsp_name: &str, + workspace: &Entity, + 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::(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, 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::(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, 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::(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); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4aaff0529cf38f16e4f7e08263291168013ebb05..5e418071a500947554e1b8e4b00b75205193d73a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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::(id as usize), + NotificationId::composite::(request.id), cx, |cx| { cx.new(|cx| { diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 15e322147e62bf2ac34f4bf4f45416eabb34f753..5e510b1a2df61273ea962c5059f110ab582af823 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -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