diff --git a/book/src/editor.md b/book/src/editor.md index af44d059..872fa8a6 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -18,6 +18,7 @@ - [`[editor.gutters.diagnostics]` Section](#editorguttersdiagnostics-section) - [`[editor.gutters.diff]` Section](#editorguttersdiff-section) - [`[editor.gutters.spacer]` Section](#editorguttersspacer-section) + - [`[editor.gutters.code-action-hint]` Section](#editorgutterscode-action-hint-section) - [`[editor.soft-wrap]` Section](#editorsoft-wrap-section) - [`[editor.smart-tab]` Section](#editorsmart-tab-section) - [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section) @@ -39,7 +40,7 @@ | `cursorline` | Highlight all lines with a cursor | `false` | | `cursorcolumn` | Highlight all columns with a cursor | `false` | | `continue-comments` | if helix should automatically add a line comment token if you create a new line inside a comment. | `true` | -| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | +| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer` and `code-action-hint`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | | `auto-completion` | Enable automatic pop up of auto-completion | `true` | | `path-completion` | Enable filepath completion. Show files and directories if an existing path at the cursor was recognized, either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). Defaults to true. | `true` | | `auto-format` | Enable automatic formatting on save[^3] | `true` | @@ -158,6 +159,7 @@ The following statusline elements can be configured: | `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) | | `version-control` | The current branch name or detached commit hash of the opened workspace | | `register` | The current selected register | +| `code-action-hint` | Indicator for when code actions are available | ### `[editor.lsp]` Section @@ -434,6 +436,12 @@ There are currently no options for this section. Currently unused +#### `[editor.gutters.code-action-hint]` Section + +The `code-action-hint` gutter option displays an indicator for whether a code action is available at current selection. + +There are currently no options for this section. + ### `[editor.soft-wrap]` Section Options for soft wrapping lines that exceed the view width: diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index bfb9bb94..95201fbf 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -600,28 +600,29 @@ pub fn code_action(cx: &mut Context) { let selection_range = doc.selection(view.id).primary(); - let mut futures: FuturesUnordered<_> = code_actions_for_range(doc, selection_range, None) - .into_iter() - .map(|(request, ls_id)| async move { - let Some(mut actions) = request.await? else { - return anyhow::Ok(Vec::new()); - }; + let mut futures: FuturesUnordered<_> = + code_actions_for_range(doc, selection_range, None, CodeActionTriggerKind::INVOKED) + .into_iter() + .map(|(request, ls_id)| async move { + let Some(mut actions) = request.await? else { + return anyhow::Ok(Vec::new()); + }; - // remove disabled code actions - actions.retain(|action| { - matches!( - action, - CodeActionOrCommand::Command(_) - | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) - ) - }); + // remove disabled code actions + actions.retain(|action| { + matches!( + action, + CodeActionOrCommand::Command(_) + | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) + ) + }); - Ok(actions - .into_iter() - .map(|lsp_item| CodeActionItem::lsp(ls_id, lsp_item)) - .collect()) - }) - .collect(); + Ok(actions + .into_iter() + .map(|lsp_item| CodeActionItem::lsp(ls_id, lsp_item)) + .collect()) + }) + .collect(); if futures.is_empty() { cx.editor @@ -670,10 +671,11 @@ pub fn code_action(cx: &mut Context) { // Extracting this to a type alias would require boxing this future #[allow(clippy::type_complexity)] -fn code_actions_for_range( +pub(crate) fn code_actions_for_range( doc: &Document, range: helix_core::Range, only: Option>, + trigger_kind: CodeActionTriggerKind, ) -> Vec<( impl Future>, helix_lsp::Error>>, LanguageServerId, @@ -698,7 +700,7 @@ fn code_actions_for_range( .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) .collect(), only: only.clone(), - trigger_kind: Some(CodeActionTriggerKind::INVOKED), + trigger_kind: Some(trigger_kind), }; let code_action_request = language_server.code_actions(doc.identifier(), lsp_range, code_action_context)?; @@ -745,11 +747,14 @@ fn code_action_on_save_step( let doc = doc!(editor, &doc_id); let version = doc.version(); let full_range = helix_core::Range::new(0, doc.text().len_chars()); - let Some((request, ls_id)) = - code_actions_for_range(doc, full_range, Some(vec![kind.clone()])) - .into_iter() - .next() - else { + let Some((request, ls_id)) = code_actions_for_range( + doc, + full_range, + Some(vec![kind.clone()]), + CodeActionTriggerKind::INVOKED, + ) + .into_iter() + .next() else { // No server offers this kind for the document: skip to the next. return code_action_on_save_step(doc_id, kinds, tail); }; diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index c421c49f..46afc4ce 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -16,6 +16,7 @@ use self::document_colors::DocumentColorsHandler; use self::document_links::DocumentLinksHandler; mod auto_save; +mod code_action_hint; pub mod completion; pub mod diagnostics; mod document_colors; @@ -32,6 +33,7 @@ pub fn setup(config: Arc>) -> Handlers { let event_tx = completion::CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); + let code_action_hint = code_action_hint::Handler::default().spawn(); let document_colors = DocumentColorsHandler::default().spawn(); let document_links = DocumentLinksHandler::default().spawn(); let word_index = word_index::Handler::spawn(); @@ -47,12 +49,14 @@ pub fn setup(config: Arc>) -> Handlers { word_index, pull_diagnostics, pull_all_documents_diagnostics, + code_action_hint, }; helix_view::handlers::register_hooks(&handlers); completion::register_hooks(&handlers); signature_help::register_hooks(&handlers); document_highlight::register_hooks(&handlers); + code_action_hint::register_hooks(&handlers); auto_save::register_hooks(&handlers); diagnostics::register_hooks(&handlers); snippet::register_hooks(&handlers); diff --git a/helix-term/src/handlers/code_action_hint.rs b/helix-term/src/handlers/code_action_hint.rs new file mode 100644 index 00000000..bf19c965 --- /dev/null +++ b/helix-term/src/handlers/code_action_hint.rs @@ -0,0 +1,259 @@ +use std::{collections::HashSet, time::Duration}; + +use futures_util::stream::FuturesUnordered; +use helix_event::{cancelable_future, register_hook, send_blocking, AsyncHook}; +use helix_lsp::lsp::{CodeAction, CodeActionOrCommand, CodeActionTriggerKind}; +use helix_view::{ + events::{ + ConfigDidChange, DiagnosticsDidChange, DocumentDidChange, DocumentDidOpen, + LanguageServerExited, LanguageServerInitialized, SelectionDidChange, + }, + handlers::{lsp::CodeActionHintEvent, Handlers}, + DocumentId, Editor, ViewId, +}; +use tokio::time::Instant; +use tokio_stream::StreamExt; + +use crate::{commands::code_actions_for_range, job}; + +#[derive(Debug, Default)] +pub(super) struct Handler { + doc_ids: HashSet<(DocumentId, ViewId)>, +} + +impl AsyncHook for Handler { + type Event = CodeActionHintEvent; + + fn handle_event( + &mut self, + event: Self::Event, + _timeout: Option, + ) -> Option { + self.doc_ids.insert((event.document_id, event.view_id)); + Some(Instant::now() + Duration::from_millis(200)) + } + + fn finish_debounce(&mut self) { + let ids = std::mem::take(&mut self.doc_ids); + job::dispatch_blocking(move |editor, _| { + for (doc_id, view_id) in ids { + request_code_action_hint(editor, doc_id, view_id); + } + }) + } +} + +fn request_code_action_hint(editor: &mut Editor, doc_id: DocumentId, view_id: ViewId) { + if !editor.config().code_action_hint() { + return; + } + + let Some(doc) = editor.document_mut(doc_id) else { + return; + }; + + doc.ensure_view_init(view_id); + + let selection_range = doc.selection(view_id).primary(); + let mut futures: FuturesUnordered<_> = + code_actions_for_range(doc, selection_range, None, CodeActionTriggerKind::AUTOMATIC) + .into_iter() + .map(|(request, _)| async move { + let Some(mut actions) = request.await? else { + return anyhow::Ok(Vec::new()); + }; + + // remove disabled code actions + actions.retain(|action| { + matches!( + action, + CodeActionOrCommand::Command(_) + | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) + ) + }); + + Ok(actions) + }) + .collect(); + + if futures.is_empty() { + doc.clear_code_action_hints(view_id); + return; + }; + + let cancel = doc.code_action_controller(view_id).restart(); + + tokio::spawn(async move { + let mut actions = Vec::new(); + + loop { + match cancelable_future(futures.next(), &cancel).await { + Some(output) => match output { + Some(Ok(mut lsp_items)) => actions.append(&mut lsp_items), + Some(Err(err)) => log::error!("while gathering code actions: {err}"), + None => break, + }, + // The request was cancelled. + None => return, + } + } + + job::dispatch(move |editor, _| { + apply_code_action_hint(editor, doc_id, view_id, actions); + }) + .await; + }); +} + +fn apply_code_action_hint( + editor: &mut Editor, + doc_id: DocumentId, + view_id: ViewId, + code_actions: Vec, +) { + let Some(doc) = editor.document_mut(doc_id) else { + return; + }; + if code_actions.is_empty() { + doc.clear_code_action_hints(view_id); + return; + } + doc.set_code_action_hints(view_id); +} + +pub(super) fn register_hooks(handlers: &Handlers) { + let tx = handlers.code_action_hint.clone(); + register_hook!(move |event: &mut SelectionDidChange<'_>| { + if event.doc.config.load().code_action_hint() { + let doc_id = event.doc.id(); + let view_id = event.view; + send_blocking( + &tx, + CodeActionHintEvent { + document_id: doc_id, + view_id, + }, + ); + } + Ok(()) + }); + + let tx = handlers.code_action_hint.clone(); + register_hook!(move |event: &mut DocumentDidOpen<'_>| { + if !event.editor.config().code_action_hint() { + return Ok(()); + } + let view_id = event.editor.tree.focus; + if event.editor.tree.try_get(view_id).is_none() { + return Ok(()); + } + send_blocking( + &tx, + CodeActionHintEvent { + document_id: event.doc, + view_id, + }, + ); + Ok(()) + }); + + let tx = handlers.code_action_hint.clone(); + register_hook!(move |event: &mut DiagnosticsDidChange<'_>| { + if event.editor.config().code_action_hint() { + let doc_id = event.doc; + let views: Vec<_> = event + .editor + .tree + .views() + .map(|(view, _)| (view.id, view.doc)) + .collect(); + for (view_id, view_doc) in views { + if view_doc == doc_id { + send_blocking( + &tx, + CodeActionHintEvent { + document_id: doc_id, + view_id, + }, + ); + } + } + } + Ok(()) + }); + + let tx = handlers.code_action_hint.clone(); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + if event.doc.config.load().code_action_hint() && !event.ghost_transaction { + let doc_id = event.doc.id(); + let view_id = event.view; + send_blocking( + &tx, + CodeActionHintEvent { + document_id: doc_id, + view_id, + }, + ); + } + Ok(()) + }); + + let tx = handlers.code_action_hint.clone(); + register_hook!(move |event: &mut LanguageServerInitialized<'_>| { + if !event.editor.config().code_action_hint() { + return Ok(()); + } + let view_id = event.editor.tree.focus; + let Some(view) = event.editor.tree.try_get(view_id) else { + return Ok(()); + }; + let doc_id = view.doc; + send_blocking( + &tx, + CodeActionHintEvent { + document_id: doc_id, + view_id, + }, + ); + Ok(()) + }); + + register_hook!(move |event: &mut LanguageServerExited<'_>| { + for doc in event.editor.documents_mut() { + if doc.supports_language_server(event.server_id) { + doc.clear_all_code_action_hints(); + } + } + Ok(()) + }); + + let tx = handlers.code_action_hint.clone(); + register_hook!(move |event: &mut ConfigDidChange<'_>| { + // When code action hints are turned on, request them immediately + // for the focused view instead of waiting for the next selection change. + if !event.old.code_action_hint() && event.new.code_action_hint() { + let view_id = event.editor.tree.focus; + let Some(view) = event.editor.tree.try_get(view_id) else { + return Ok(()); + }; + + send_blocking( + &tx, + CodeActionHintEvent { + document_id: view.doc, + view_id, + }, + ); + return Ok(()); + } + + // When code action hints are turned off, clear any that were + // previously rendered across open documents. + if event.old.code_action_hint() && !event.new.code_action_hint() { + for doc in event.editor.documents_mut() { + doc.clear_all_code_action_hints(); + } + } + Ok(()) + }); +} diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index ac542137..3c6cfce8 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -157,6 +157,7 @@ where helix_view::editor::StatusLineElement::VersionControl => render_version_control, helix_view::editor::StatusLineElement::Register => render_register, helix_view::editor::StatusLineElement::CurrentWorkingDirectory => render_cwd, + helix_view::editor::StatusLineElement::CodeActionHint => render_code_action_hint, } } @@ -583,3 +584,12 @@ where .to_string(); write(context, cwd.into()) } + +fn render_code_action_hint<'a, F>(context: &mut RenderContext<'a>, write: F) +where + F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy, +{ + if context.focused && context.doc.code_action_hints(context.view.id) { + write(context, " ⋮ ".into()) + } +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index e5011d9f..311188eb 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -24,7 +24,7 @@ use serde::de::{self, Deserialize, Deserializer}; use serde::Serialize; use std::borrow::Cow; use std::cell::Cell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Display; use std::future::Future; use std::io; @@ -153,6 +153,8 @@ pub struct Document { pub(crate) jump_labels: HashMap>, /// LSP document highlights for each view, stored as char ranges. pub(crate) document_highlights: HashMap, + /// LSP code action hints for each view. + pub(crate) code_action_hints: HashSet, /// Set to `true` when the document is updated, reset to `false` on the next inlay hints /// update from the LSP pub inlay_hints_oudated: bool, @@ -223,6 +225,8 @@ pub struct Document { pub color_swatch_controller: TaskController, /// Per-view task controllers for canceling in-flight document highlight requests. pub document_highlight_controllers: HashMap, + /// Per-view task controllers for canceling in-flight code action requests. + pub code_action_controllers: HashMap, pub pull_diagnostic_controller: TaskController, pub document_link_controller: TaskController, @@ -762,10 +766,12 @@ impl Document { readonly: false, jump_labels: HashMap::new(), document_highlights: HashMap::new(), + code_action_hints: HashSet::new(), color_swatches: None, document_links: Vec::new(), color_swatch_controller: TaskController::new(), document_highlight_controllers: HashMap::new(), + code_action_controllers: HashMap::new(), syn_loader, previous_diagnostic_ids: HashMap::new(), pull_diagnostic_controller: TaskController::new(), @@ -1438,6 +1444,8 @@ impl Document { self.jump_labels.remove(&view_id); self.document_highlights.remove(&view_id); self.document_highlight_controllers.remove(&view_id); + self.code_action_hints.remove(&view_id); + self.code_action_controllers.remove(&view_id); } /// Apply a [`Transaction`] to the [`Document`] to change its text. @@ -2442,6 +2450,27 @@ impl Document { .or_default() } + pub fn set_code_action_hints(&mut self, view_id: ViewId) { + self.code_action_hints.insert(view_id); + } + + pub fn clear_code_action_hints(&mut self, view_id: ViewId) { + self.code_action_hints.remove(&view_id); + } + + pub fn clear_all_code_action_hints(&mut self) { + self.code_action_hints.clear(); + self.code_action_controllers.clear(); + } + + pub fn code_action_hints(&self, view_id: ViewId) -> bool { + self.code_action_hints.contains(&view_id) + } + + pub fn code_action_controller(&mut self, view_id: ViewId) -> &mut TaskController { + self.code_action_controllers.entry(view_id).or_default() + } + /// Get the inlay hints for this document and `view_id`. pub fn inlay_hints(&self, view_id: ViewId) -> Option<&DocumentInlayHints> { self.inlay_hints.get(&view_id) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 46fe099a..adced188 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -495,6 +495,24 @@ impl From<&WorkspaceTrustConfig> for helix_loader::workspace_trust::Config { } } +impl Config { + pub fn code_action_hint(&self) -> bool { + self.gutters.layout.contains(&GutterType::CodeActionHint) + || self + .statusline + .left + .contains(&StatusLineElement::CodeActionHint) + || self + .statusline + .center + .contains(&StatusLineElement::CodeActionHint) + || self + .statusline + .right + .contains(&StatusLineElement::CodeActionHint) + } +} + #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)] #[serde(rename_all = "kebab-case")] pub struct BufferPickerConfig { @@ -783,6 +801,9 @@ pub enum StatusLineElement { /// The base of current working directory CurrentWorkingDirectory, + + /// Indicator for when code actions are available + CodeActionHint, } // Cursor shape is read and used on every rendered frame and so needs @@ -886,6 +907,8 @@ pub enum GutterType { Spacer, /// Highlight local changes Diff, + /// Indicator for when code actions are available + CodeActionHint, } impl std::str::FromStr for GutterType { @@ -897,6 +920,7 @@ impl std::str::FromStr for GutterType { "spacer" => Ok(Self::Spacer), "line-numbers" => Ok(Self::LineNumbers), "diff" => Ok(Self::Diff), + "code-action-hint" => Ok(Self::CodeActionHint), _ => anyhow::bail!( "Gutter type can only be `diagnostics`, `spacer`, `line-numbers` or `diff`." ), diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index f4a57f5f..176890ca 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -32,6 +32,7 @@ impl GutterType { GutterType::LineNumbers => line_numbers(editor, doc, view, theme, is_focused), GutterType::Spacer => padding(editor, doc, view, theme, is_focused), GutterType::Diff => diff(editor, doc, view, theme, is_focused), + GutterType::CodeActionHint => code_action_hint(editor, doc, view, theme, is_focused), } } @@ -41,6 +42,7 @@ impl GutterType { GutterType::LineNumbers => line_numbers_width(view, doc), GutterType::Spacer => 1, GutterType::Diff => 1, + GutterType::CodeActionHint => 1, } } } @@ -325,6 +327,30 @@ pub fn diagnostics_or_breakpoints<'doc>( }) } +pub fn code_action_hint<'doc>( + _editor: &'doc Editor, + doc: &'doc Document, + view: &View, + theme: &Theme, + is_focused: bool, +) -> GutterFn<'doc> { + let style = theme.get("ui.text"); + let text = doc.text().slice(..); + let show_hint = doc.code_action_hints(view.id); + let current_line = doc + .text() + .char_to_line(doc.selection(view.id).primary().cursor(text)); + + Box::new( + move |line: usize, _selected: bool, first_visual_line: bool, out: &mut String| { + (is_focused && show_hint && current_line == line && first_visual_line).then(|| { + write!(out, "⋮").unwrap(); + style + }) + }, + ) +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index e269a695..6ead32f5 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -27,6 +27,7 @@ pub struct Handlers { pub word_index: word_index::Handler, pub pull_diagnostics: Sender, pub pull_all_documents_diagnostics: Sender, + pub code_action_hint: Sender, } impl Handlers { diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index ecdbfdf7..fa51126d 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -6,7 +6,7 @@ use crate::editor::Action; use crate::events::{ DiagnosticsDidChange, DocumentDidChange, DocumentDidClose, LanguageServerInitialized, }; -use crate::{DocumentId, Editor}; +use crate::{DocumentId, Editor, ViewId}; use helix_core::diagnostic::DiagnosticProvider; use helix_core::Uri; use helix_event::register_hook; @@ -40,6 +40,11 @@ pub struct PullAllDocumentsDiagnosticsEvent { pub language_servers: HashSet, } +pub struct CodeActionHintEvent { + pub document_id: DocumentId, + pub view_id: ViewId, +} + #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind,