Add LSP rename (Phase 3) and update AGENTS.md with future work

- F2 keybinding triggers prepareRename at cursor
- Shows inline text input pre-filled with current symbol name
- Enter confirms and applies workspace edits across all occurrences
- Escape cancels the rename
- Full flow: prepareRename → input → rename → apply edits
- AGENTS.md: document inline token/cache/cost stats feature idea
- AGENTS.md: document rename implementation for reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-05-18 16:44:30 -05:00
co-authored by Claude Opus 4.6
parent 940c3b5dff
commit 99159184cb
4 changed files with 340 additions and 1 deletions
+16 -1
View File
@@ -92,6 +92,7 @@ use super::editor::{
};
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
use super::rename::RenameState;
use super::signature_help::SignatureHelpState;
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
use super::language_server_extension::ProcessedDiagnostic;
@@ -114,6 +115,11 @@ pub fn init(app: &mut AppContext) {
LocalCodeEditorAction::OpenCodeActions,
id!("LocalCodeEditorView"),
),
FixedBinding::new(
"f2",
LocalCodeEditorAction::StartRename,
id!("LocalCodeEditorView"),
),
]);
}
@@ -209,6 +215,8 @@ pub enum LocalCodeEditorAction {
},
/// Open the code actions menu (Cmd+.).
OpenCodeActions,
/// Start LSP rename at cursor (F2).
StartRename,
}
#[derive(Default)]
@@ -325,6 +333,8 @@ pub struct LocalCodeEditorView {
pub(super) code_actions_debounce_tx: async_channel::Sender<CharOffset>,
/// State for LSP signature help (parameter hints).
pub(super) signature_help_state: SignatureHelpState,
/// State for LSP rename (F2).
pub(super) rename_state: RenameState,
}
impl LocalCodeEditorView {
@@ -555,6 +565,7 @@ impl LocalCodeEditorView {
code_actions_state: CodeActionsState::default(),
code_actions_debounce_tx,
signature_help_state: SignatureHelpState::default(),
rename_state: RenameState::default(),
};
if let Some(display_mode) = display_mode {
@@ -1981,7 +1992,8 @@ impl LocalCodeEditorView {
}
let had_sig_help = self.signature_help_state.clear();
let had_code_actions = self.code_actions_state.dismiss();
had_refs || had_hover || had_completion || had_sig_help || had_code_actions
let had_rename = self.rename_state.dismiss();
had_refs || had_hover || had_completion || had_sig_help || had_code_actions || had_rename
}
/// Perform goto definition at the cursor position and navigate directly.
@@ -2361,6 +2373,9 @@ impl TypedActionView for LocalCodeEditorView {
LocalCodeEditorAction::OpenCodeActions => {
self.open_code_actions_menu(ctx);
}
LocalCodeEditorAction::StartRename => {
self.start_rename(ctx);
}
}
}
}
+2
View File
@@ -13,6 +13,8 @@ pub mod completion;
#[cfg(not(target_family = "wasm"))]
pub mod find_references_view;
#[cfg(not(target_family = "wasm"))]
pub mod rename;
#[cfg(not(target_family = "wasm"))]
pub mod signature_help;
#[cfg(not(target_family = "wasm"))]
pub mod language_server_extension;
+276
View File
@@ -0,0 +1,276 @@
use futures::stream::AbortHandle;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{SingletonEntity, ViewContext, ViewHandle};
use lsp::types::Location;
use string_offset::CharOffset;
use vec1::Vec1;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
use super::local_code_editor::LocalCodeEditorView;
pub(super) enum RenameState {
Idle,
Preparing { abort_handle: AbortHandle },
InputActive {
editor: ViewHandle<EditorView>,
anchor_offset: CharOffset,
},
Applying { abort_handle: AbortHandle },
}
impl Default for RenameState {
fn default() -> Self {
Self::Idle
}
}
impl RenameState {
pub fn dismiss(&mut self) -> bool {
match self {
Self::Idle => false,
Self::Preparing { abort_handle } | Self::Applying { abort_handle } => {
abort_handle.abort();
*self = Self::Idle;
true
}
Self::InputActive { .. } => {
*self = Self::Idle;
true
}
}
}
}
impl LocalCodeEditorView {
pub(super) fn is_rename_enabled() -> bool {
FeatureFlag::LspRename.is_enabled()
}
pub(super) fn start_rename(&mut self, ctx: &mut ViewContext<Self>) {
if !Self::is_rename_enabled() {
return;
}
let Some(file_path) = self.file_path() else {
return;
};
let Some(lsp_server) = &self.lsp_server else {
return;
};
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
let lsp_position = self
.editor()
.as_ref(ctx)
.offset_to_lsp_position(cursor_offset, ctx);
let future = match lsp_server
.as_ref(ctx)
.prepare_rename(file_path.to_path_buf(), lsp_position)
{
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.prepare_rename: {e}");
return;
}
};
self.rename_state.dismiss();
let abort_handle = ctx
.spawn(future, move |me, result, ctx| {
me.handle_prepare_rename_response(result, cursor_offset, ctx);
})
.abort_handle();
self.rename_state = RenameState::Preparing { abort_handle };
}
fn handle_prepare_rename_response(
&mut self,
result: anyhow::Result<Option<lsp::PrepareRenameResult>>,
cursor_offset: CharOffset,
ctx: &mut ViewContext<Self>,
) {
let prepare_result = match result {
Ok(Some(r)) => r,
_ => {
log::info!("Symbol at cursor cannot be renamed");
self.rename_state = RenameState::Idle;
return;
}
};
let editor_ref = self.editor().as_ref(ctx);
let range_start = editor_ref.lsp_location_to_offset(&prepare_result.range.start, ctx);
let range_end = editor_ref.lsp_location_to_offset(&prepare_result.range.end, ctx);
let current_name = editor_ref
.buffer_text_in_range(range_start..range_end, ctx)
.unwrap_or_default();
let placeholder = prepare_result.placeholder.unwrap_or(current_name);
let rename_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(None, appearance),
select_all_on_focus: true,
..Default::default()
},
ctx,
);
editor.set_buffer_text(&placeholder, ctx);
editor
});
ctx.subscribe_to_view(&rename_editor, |me, _, event, ctx| {
me.handle_rename_editor_event(event, ctx);
});
ctx.focus(&rename_editor);
self.rename_state = RenameState::InputActive {
editor: rename_editor,
anchor_offset: cursor_offset,
};
ctx.notify();
}
fn handle_rename_editor_event(
&mut self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EditorEvent::Enter => {
let new_name = match &self.rename_state {
RenameState::InputActive { editor, .. } => {
editor.as_ref(ctx).buffer_text(ctx)
}
_ => return,
};
self.confirm_rename(new_name, ctx);
}
EditorEvent::Escape => {
self.rename_state = RenameState::Idle;
ctx.focus(self.editor());
ctx.notify();
}
_ => {}
}
}
fn confirm_rename(&mut self, new_name: String, ctx: &mut ViewContext<Self>) {
if new_name.is_empty() {
self.rename_state = RenameState::Idle;
ctx.focus(self.editor());
ctx.notify();
return;
}
let anchor_offset = match &self.rename_state {
RenameState::InputActive { anchor_offset, .. } => *anchor_offset,
_ => return,
};
let Some(file_path) = self.file_path() else {
return;
};
let Some(lsp_server) = &self.lsp_server else {
return;
};
let lsp_position = self
.editor()
.as_ref(ctx)
.offset_to_lsp_position(anchor_offset, ctx);
let future = match lsp_server
.as_ref(ctx)
.rename(file_path.to_path_buf(), lsp_position, new_name)
{
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.rename: {e}");
self.rename_state = RenameState::Idle;
ctx.focus(self.editor());
ctx.notify();
return;
}
};
let abort_handle = ctx
.spawn(future, move |me, result, ctx| {
me.handle_rename_response(result, ctx);
})
.abort_handle();
self.rename_state = RenameState::Applying { abort_handle };
}
fn handle_rename_response(
&mut self,
result: anyhow::Result<Option<lsp::RenameResult>>,
ctx: &mut ViewContext<Self>,
) {
self.rename_state = RenameState::Idle;
ctx.focus(self.editor());
let rename_result = match result {
Ok(Some(r)) if !r.edits.is_empty() => r,
Ok(_) => {
log::info!("Rename returned no edits");
ctx.notify();
return;
}
Err(e) => {
log::warn!("Rename failed: {e}");
ctx.notify();
return;
}
};
let mut edits_for_current_file: Vec<(String, std::ops::Range<CharOffset>)> = Vec::new();
for file_edit in &rename_result.edits {
for text_edit in &file_edit.edits {
let start = self.editor().as_ref(ctx).lsp_location_to_offset(
&Location {
line: text_edit.range.start.line,
column: text_edit.range.start.column,
},
ctx,
);
let end = self.editor().as_ref(ctx).lsp_location_to_offset(
&Location {
line: text_edit.range.end.line,
column: text_edit.range.end.column,
},
ctx,
);
edits_for_current_file.push((text_edit.text.clone(), start..end));
}
}
if edits_for_current_file.is_empty() {
ctx.notify();
return;
}
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);
});
}
ctx.notify();
}
}