diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 4b737893d465..6280d3c72fde 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -85,3 +85,4 @@ | `:reset-diff-change`, `:diffget`, `:diffg` | Reset the diff change at the cursor position. | | `:clear-register` | Clear given register. If no argument is provided, clear all registers. | | `:redraw` | Clear and re-render the whole UI | +| `:move` | Move the current buffer and its corresponding file to a different path | diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 341702c374ce..e6e1f8a03301 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -401,12 +401,22 @@ impl Client { &self, params: R::Params, ) -> impl Future> + where + R::Params: serde::Serialize, + { + self.call_with_timeout::(params, self.req_timeout) + } + + fn call_with_timeout( + &self, + params: R::Params, + timeout_secs: u64, + ) -> impl Future> where R::Params: serde::Serialize, { let server_tx = self.server_tx.clone(); let id = self.next_request_id(); - let timeout_secs = self.req_timeout; async move { use std::time::Duration; @@ -548,6 +558,11 @@ impl Client { dynamic_registration: Some(true), relative_pattern_support: Some(false), }), + file_operations: Some(lsp::WorkspaceFileOperationsClientCapabilities { + will_rename: Some(true), + did_rename: Some(true), + ..Default::default() + }), ..Default::default() }), text_document: Some(lsp::TextDocumentClientCapabilities { @@ -700,6 +715,65 @@ impl Client { }) } + pub fn prepare_file_rename( + &self, + old_uri: &lsp::Url, + new_uri: &lsp::Url, + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support willRename feature + match &capabilities.workspace { + Some(workspace) => match &workspace.file_operations { + Some(op) => { + op.will_rename.as_ref()?; + } + _ => return None, + }, + _ => return None, + } + + let files = vec![lsp::FileRename { + old_uri: old_uri.to_string(), + new_uri: new_uri.to_string(), + }]; + let request = self.call_with_timeout::( + lsp::RenameFilesParams { files }, + 5, + ); + + Some(async move { + let json = request.await?; + let response: Option = serde_json::from_value(json)?; + Ok(response.unwrap_or_default()) + }) + } + + pub fn did_file_rename( + &self, + old_uri: &lsp::Url, + new_uri: &lsp::Url, + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support DidRename feature + match &capabilities.workspace { + Some(workspace) => match &workspace.file_operations { + Some(op) => { + op.did_rename.as_ref()?; + } + _ => return None, + }, + _ => return None, + } + + let files = vec![lsp::FileRename { + old_uri: old_uri.to_string(), + new_uri: new_uri.to_string(), + }]; + Some(self.notify::(lsp::RenameFilesParams { files })) + } + // ------------------------------------------------------------------------------------------- // Text document // ------------------------------------------------------------------------------------------- diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index abe6dd97ec89..2c82e4dd750c 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -6,7 +6,8 @@ use crate::job::Job; use super::*; use helix_core::fuzzy::fuzzy_match; -use helix_core::{encoding, line_ending, shellwords::Shellwords}; +use helix_core::{encoding, line_ending, path::get_canonicalized_path, shellwords::Shellwords}; +use helix_lsp::{OffsetEncoding, Url}; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; use serde_json::Value; @@ -2408,6 +2409,80 @@ fn redraw( Ok(()) } +fn move_buffer( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + ensure!(args.len() == 1, format!(":move takes one argument")); + let doc = doc!(cx.editor); + + let new_path = get_canonicalized_path(&PathBuf::from(args.first().unwrap().to_string())); + let old_path = doc + .path() + .ok_or_else(|| anyhow!("Scratch buffer cannot be moved. Use :write instead"))? + .clone(); + let old_path_as_url = doc.url().unwrap(); + let new_path_as_url = Url::from_file_path(&new_path).unwrap(); + + let edits: Vec<( + helix_lsp::Result, + OffsetEncoding, + String, + )> = doc + .language_servers() + .map(|lsp| { + ( + lsp.prepare_file_rename(&old_path_as_url, &new_path_as_url), + lsp.offset_encoding(), + lsp.name().to_owned(), + ) + }) + .filter(|(f, _, _)| f.is_some()) + .map(|(f, encoding, name)| (helix_lsp::block_on(f.unwrap()), encoding, name)) + .collect(); + + for (lsp_reply, encoding, name) in edits { + match lsp_reply { + Ok(edit) => { + if let Err(e) = apply_workspace_edit(cx.editor, encoding, &edit) { + log::error!( + ":move command failed to apply edits from lsp {}: {:?}", + name, + e + ); + }; + } + Err(e) => { + log::error!("LSP {} failed to treat willRename request: {:?}", name, e); + } + }; + } + + let doc = doc_mut!(cx.editor); + + doc.set_path(Some(new_path.as_path())); + if let Err(e) = std::fs::rename(&old_path, &new_path) { + doc.set_path(Some(old_path.as_path())); + bail!("Could not move file: {}", e); + }; + + doc.language_servers().for_each(|lsp| { + lsp.did_file_rename(&old_path_as_url, &new_path_as_url); + }); + + cx.editor + .language_servers + .file_event_handler + .file_changed(new_path); + + Ok(()) +} + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -3008,6 +3083,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: redraw, signature: CommandSignature::none(), }, + TypableCommand { + name: "move", + aliases: &[], + doc: "Move the current buffer and its corresponding file to a different path", + fun: move_buffer, + signature: CommandSignature::positional(&[completers::filename]), + }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> =