Add code actions (Phase 2): quick fixes and refactorings via LSP
- CodeActionsState machine: Idle → Requesting → Available (with menu) - 200ms debounced fetch on cursor/selection change - Cmd+. opens the code actions menu - Menu renders as positioned overlay with action titles and kind labels - apply_workspace_edit helper applies LSP WorkspaceEdits to the editor - Wired into SelectionEnd event for automatic background fetching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d421b9583f
commit
331fbeeb18
@@ -0,0 +1,463 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::AbortHandle;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
|
||||
MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext};
|
||||
use lsp::CodeActionData;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use string_offset::CharOffset;
|
||||
use vec1::Vec1;
|
||||
|
||||
use super::local_code_editor::LocalCodeEditorView;
|
||||
|
||||
pub const CODE_ACTIONS_DEBOUNCE_PERIOD: Duration = Duration::from_millis(200);
|
||||
const CODE_ACTIONS_MENU_WIDTH: f32 = 380.;
|
||||
const CODE_ACTIONS_MENU_MAX_HEIGHT: f32 = 250.;
|
||||
const MAX_VISIBLE_ACTIONS: usize = 12;
|
||||
|
||||
/// State machine for code actions.
|
||||
pub(super) enum CodeActionsState {
|
||||
/// No code actions active.
|
||||
Idle,
|
||||
/// Fetching code actions from the LSP.
|
||||
Requesting {
|
||||
abort_handle: AbortHandle,
|
||||
anchor_offset: CharOffset,
|
||||
},
|
||||
/// Code actions are available (shown via lightbulb or menu).
|
||||
Available {
|
||||
actions: Vec<CodeActionData>,
|
||||
anchor_offset: CharOffset,
|
||||
menu_open: bool,
|
||||
selected_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for CodeActionsState {
|
||||
fn default() -> Self {
|
||||
Self::Idle
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeActionsState {
|
||||
pub fn dismiss(&mut self) -> bool {
|
||||
if matches!(self, Self::Idle) {
|
||||
return false;
|
||||
}
|
||||
if let Self::Requesting { abort_handle, .. } = self {
|
||||
abort_handle.abort();
|
||||
}
|
||||
*self = Self::Idle;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn has_actions(&self) -> bool {
|
||||
matches!(self, Self::Available { actions, .. } if !actions.is_empty())
|
||||
}
|
||||
|
||||
pub fn is_menu_open(&self) -> bool {
|
||||
matches!(self, Self::Available { menu_open: true, .. })
|
||||
}
|
||||
|
||||
pub fn open_menu(&mut self) {
|
||||
if let Self::Available { menu_open, .. } = self {
|
||||
*menu_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_menu(&mut self) {
|
||||
if let Self::Available { menu_open, .. } = self {
|
||||
*menu_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_selection(&mut self, delta: i32) {
|
||||
if let Self::Available {
|
||||
actions,
|
||||
selected_index,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
if actions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let len = actions.len() as i32;
|
||||
let new_idx = (*selected_index as i32 + delta).rem_euclid(len);
|
||||
*selected_index = new_idx as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalCodeEditorView {
|
||||
pub(super) fn is_code_actions_enabled() -> bool {
|
||||
FeatureFlag::LspCodeActions.is_enabled()
|
||||
}
|
||||
|
||||
/// Trigger code actions fetch after selection/cursor change (debounced).
|
||||
pub(super) fn on_selection_changed_for_code_actions(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !Self::is_code_actions_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.lsp_server.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
|
||||
let _ = self.code_actions_debounce_tx.try_send(cursor_offset);
|
||||
}
|
||||
|
||||
/// Handle the debounced code actions request.
|
||||
pub(super) fn request_code_actions_debounced(
|
||||
&mut self,
|
||||
offset: CharOffset,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !Self::is_code_actions_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(file_path) = self.file_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(lsp_server) = &self.lsp_server else {
|
||||
return;
|
||||
};
|
||||
|
||||
let editor = self.editor().as_ref(ctx);
|
||||
let lsp_position = editor.offset_to_lsp_position(offset, ctx);
|
||||
|
||||
// Build a zero-width range at cursor for code action request
|
||||
let lsp_range = lsp_types::Range {
|
||||
start: lsp_types::Position {
|
||||
line: lsp_position.line as u32,
|
||||
character: lsp_position.column as u32,
|
||||
},
|
||||
end: lsp_types::Position {
|
||||
line: lsp_position.line as u32,
|
||||
character: lsp_position.column as u32,
|
||||
},
|
||||
};
|
||||
|
||||
// Include diagnostics at cursor position for context
|
||||
let diagnostics_at_cursor: Vec<lsp_types::Diagnostic> = self
|
||||
.processed_diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.start <= offset && offset <= d.end)
|
||||
.filter_map(|d| {
|
||||
// Convert back to LSP diagnostic (we only need message + severity)
|
||||
Some(lsp_types::Diagnostic {
|
||||
range: lsp_range,
|
||||
severity: Some(d.severity),
|
||||
message: d.message.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let future = match lsp_server
|
||||
.as_ref(ctx)
|
||||
.code_actions(file_path.to_path_buf(), lsp_range, diagnostics_at_cursor)
|
||||
{
|
||||
Ok(future) => future,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to call lsp.code_actions: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.code_actions_state.dismiss();
|
||||
|
||||
let abort_handle = ctx
|
||||
.spawn(future, move |me, result, ctx| {
|
||||
me.handle_code_actions_response(result, offset, ctx);
|
||||
})
|
||||
.abort_handle();
|
||||
|
||||
self.code_actions_state = CodeActionsState::Requesting {
|
||||
abort_handle,
|
||||
anchor_offset: offset,
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_code_actions_response(
|
||||
&mut self,
|
||||
result: anyhow::Result<Vec<CodeActionData>>,
|
||||
anchor_offset: CharOffset,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let actions = match result {
|
||||
Ok(actions) if !actions.is_empty() => actions,
|
||||
_ => {
|
||||
self.code_actions_state = CodeActionsState::Idle;
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.code_actions_state = CodeActionsState::Available {
|
||||
actions,
|
||||
anchor_offset,
|
||||
menu_open: false,
|
||||
selected_index: 0,
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Open the code actions menu (triggered by Cmd+. or clicking the lightbulb).
|
||||
pub(super) fn open_code_actions_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !Self::is_code_actions_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.code_actions_state {
|
||||
CodeActionsState::Available { actions, .. } if !actions.is_empty() => {
|
||||
self.code_actions_state.open_menu();
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {
|
||||
// No actions available yet — force a fetch and open when ready
|
||||
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
|
||||
self.request_code_actions_debounced(cursor_offset, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the selected code action.
|
||||
pub(super) fn confirm_code_action(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
let (raw_action, anchor_offset) = match &self.code_actions_state {
|
||||
CodeActionsState::Available {
|
||||
actions,
|
||||
selected_index,
|
||||
anchor_offset,
|
||||
..
|
||||
} => {
|
||||
let Some(action) = actions.get(*selected_index) else {
|
||||
return false;
|
||||
};
|
||||
(action.raw.clone(), *anchor_offset)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
self.code_actions_state = CodeActionsState::Idle;
|
||||
|
||||
// Apply the code action based on its type
|
||||
match raw_action {
|
||||
lsp_types::CodeActionOrCommand::CodeAction(action) => {
|
||||
if let Some(edit) = action.edit {
|
||||
self.apply_workspace_edit(edit, ctx);
|
||||
}
|
||||
// TODO: handle action.command (execute command on server)
|
||||
}
|
||||
lsp_types::CodeActionOrCommand::Command(_cmd) => {
|
||||
// TODO: execute command on LSP server
|
||||
log::info!("Code action command execution not yet implemented");
|
||||
}
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply a workspace edit (shared between code actions and rename).
|
||||
pub(super) fn apply_workspace_edit(
|
||||
&mut self,
|
||||
edit: lsp_types::WorkspaceEdit,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let mut edits_for_current_file: Vec<(String, std::ops::Range<CharOffset>)> = Vec::new();
|
||||
|
||||
if let Some(changes) = edit.changes {
|
||||
for (_uri, text_edits) in changes {
|
||||
// For now, apply all edits assuming they're for the current file.
|
||||
// TODO: support multi-file edits by checking URI against open files.
|
||||
for text_edit in text_edits {
|
||||
let start = self.editor().as_ref(ctx).lsp_location_to_offset(
|
||||
&lsp::types::Location {
|
||||
line: text_edit.range.start.line as usize,
|
||||
column: text_edit.range.start.character as usize,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
let end = self.editor().as_ref(ctx).lsp_location_to_offset(
|
||||
&lsp::types::Location {
|
||||
line: text_edit.range.end.line as usize,
|
||||
column: text_edit.range.end.character as usize,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
edits_for_current_file.push((text_edit.new_text, start..end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if edits_for_current_file.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort in reverse order to avoid offset shifting
|
||||
edits_for_current_file.sort_by(|a, b| b.1.start.cmp(&a.1.start));
|
||||
|
||||
if let Ok(edits) = Vec1::try_from_vec(edits_for_current_file) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.apply_edits(edits, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the code actions menu overlay.
|
||||
pub(super) fn render_code_actions_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let CodeActionsState::Available {
|
||||
actions,
|
||||
menu_open: true,
|
||||
selected_index,
|
||||
..
|
||||
} = &self.code_actions_state
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if actions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let visible_count = actions.len().min(MAX_VISIBLE_ACTIONS);
|
||||
|
||||
let mut content_column = Flex::column();
|
||||
|
||||
for (idx, action) in actions.iter().enumerate().take(visible_count) {
|
||||
let is_selected = idx == *selected_index;
|
||||
content_column.add_child(render_code_action_item(action, is_selected, appearance));
|
||||
}
|
||||
|
||||
let constrained_content = ConstrainedBox::new(content_column.finish())
|
||||
.with_width(CODE_ACTIONS_MENU_WIDTH)
|
||||
.with_max_height(CODE_ACTIONS_MENU_MAX_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let menu = Container::new(constrained_content)
|
||||
.with_background(theme.background())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme)))
|
||||
.finish();
|
||||
|
||||
Some(menu)
|
||||
}
|
||||
|
||||
/// Compute positioning for the code actions menu.
|
||||
pub(super) fn code_actions_menu_positioning(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<OffsetPositioning> {
|
||||
let anchor_offset = match &self.code_actions_state {
|
||||
CodeActionsState::Available {
|
||||
anchor_offset,
|
||||
menu_open: true,
|
||||
..
|
||||
} => *anchor_offset,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let bounds = self
|
||||
.editor()
|
||||
.as_ref(app)
|
||||
.character_bounds_in_viewport(anchor_offset, app)?;
|
||||
|
||||
Some(OffsetPositioning::offset_from_parent(
|
||||
Vector2F::new(bounds.origin_x(), bounds.max_y()),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_code_action_item(
|
||||
action: &CodeActionData,
|
||||
is_selected: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let title = action.title.clone();
|
||||
|
||||
let kind_label: &'static str = match action.kind.as_deref() {
|
||||
Some("quickfix") => "fix",
|
||||
Some("refactor") => "ref",
|
||||
Some("refactor.extract") => "ext",
|
||||
Some("refactor.inline") => "inl",
|
||||
Some("source.organizeImports") => "imp",
|
||||
_ => " ",
|
||||
};
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
row.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
kind_label,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() * 0.85,
|
||||
)
|
||||
.with_color(theme.disabled_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(4.)
|
||||
.with_padding_right(6.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Text::new(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if action.is_preferred {
|
||||
row.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
"*",
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.ui_warning_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut container = Container::new(row.finish())
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(6.);
|
||||
|
||||
if is_selected {
|
||||
container = container.with_background(internal_colors::neutral_2(theme));
|
||||
} else {
|
||||
container = container.with_background(theme.background());
|
||||
}
|
||||
|
||||
container.finish()
|
||||
}
|
||||
@@ -90,6 +90,7 @@ use super::editor::{
|
||||
scroll::{ScrollPosition, ScrollTrigger},
|
||||
view::{CodeEditorEvent, CodeEditorView},
|
||||
};
|
||||
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
|
||||
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
|
||||
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
|
||||
use super::language_server_extension::ProcessedDiagnostic;
|
||||
@@ -101,11 +102,18 @@ type SaveCallback =
|
||||
Box<dyn FnOnce(SaveOutcome, &mut ViewContext<LocalCodeEditorView>) + Send + Sync + 'static>;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"cmdorctrl-l",
|
||||
LocalCodeEditorAction::InsertSelectedTextToInput,
|
||||
id!("LocalCodeEditorView") & !id!("IMEOpen"),
|
||||
)]);
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"cmdorctrl-l",
|
||||
LocalCodeEditorAction::InsertSelectedTextToInput,
|
||||
id!("LocalCodeEditorView") & !id!("IMEOpen"),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"cmdorctrl-.",
|
||||
LocalCodeEditorAction::OpenCodeActions,
|
||||
id!("LocalCodeEditorView"),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
pub enum LocalCodeEditorEvent {
|
||||
@@ -198,6 +206,8 @@ pub enum LocalCodeEditorAction {
|
||||
lsp_position: lsp::types::Location,
|
||||
anchor_offset: CharOffset,
|
||||
},
|
||||
/// Open the code actions menu (Cmd+.).
|
||||
OpenCodeActions,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -308,6 +318,10 @@ pub struct LocalCodeEditorView {
|
||||
pub(super) completion_state: CompletionState,
|
||||
/// Channel for debouncing completion requests triggered by typing.
|
||||
pub(super) completion_debounce_tx: async_channel::Sender<CharOffset>,
|
||||
/// State for LSP code actions (quick fixes, refactorings).
|
||||
pub(super) code_actions_state: CodeActionsState,
|
||||
/// Channel for debouncing code actions requests on cursor/selection change.
|
||||
pub(super) code_actions_debounce_tx: async_channel::Sender<CharOffset>,
|
||||
}
|
||||
|
||||
impl LocalCodeEditorView {
|
||||
@@ -363,6 +377,7 @@ impl LocalCodeEditorView {
|
||||
ctx.emit(LocalCodeEditorEvent::DiffStatusUpdated);
|
||||
}
|
||||
CodeEditorEvent::SelectionEnd => {
|
||||
me.on_selection_changed_for_code_actions(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
CodeEditorEvent::MouseHovered {
|
||||
@@ -500,6 +515,14 @@ impl LocalCodeEditorView {
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
// Set up debounce for code actions requests (triggered on cursor movement)
|
||||
let (code_actions_debounce_tx, code_actions_debounce_rx) = async_channel::unbounded();
|
||||
ctx.spawn_stream_local(
|
||||
debounce(CODE_ACTIONS_DEBOUNCE_PERIOD, code_actions_debounce_rx),
|
||||
|me, offset, ctx| me.request_code_actions_debounced(offset, ctx),
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
let model = Self {
|
||||
editor,
|
||||
diff_type,
|
||||
@@ -524,6 +547,8 @@ impl LocalCodeEditorView {
|
||||
find_references_view: None,
|
||||
completion_state: CompletionState::default(),
|
||||
completion_debounce_tx,
|
||||
code_actions_state: CodeActionsState::default(),
|
||||
code_actions_debounce_tx,
|
||||
};
|
||||
|
||||
if let Some(display_mode) = display_mode {
|
||||
@@ -2222,6 +2247,14 @@ impl View for LocalCodeEditorView {
|
||||
stack.add_positioned_overlay_child(completion_menu, positioning);
|
||||
}
|
||||
|
||||
// Render code actions menu if open
|
||||
if let (Some(actions_menu), Some(positioning)) = (
|
||||
self.render_code_actions_menu(app),
|
||||
self.code_actions_menu_positioning(app),
|
||||
) {
|
||||
stack.add_positioned_overlay_child(actions_menu, positioning);
|
||||
}
|
||||
|
||||
// Render LSP hover tooltip if available (render last so it appears on top)
|
||||
if let (Some(hover_tooltip), Some(positioning)) = (
|
||||
self.render_hover_tooltip(app),
|
||||
@@ -2309,6 +2342,9 @@ impl TypedActionView for LocalCodeEditorView {
|
||||
// This is triggered on cmd-click when go-to-definition has no different location.
|
||||
self.fetch_find_references_and_show(lsp_position.clone(), *anchor_offset, ctx);
|
||||
}
|
||||
LocalCodeEditorAction::OpenCodeActions => {
|
||||
self.open_code_actions_menu(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::AddAssign;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod code_actions;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod completion;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
|
||||
Reference in New Issue
Block a user