From e868678139bc8f4f5b14236c3c3c3f6aa4aab47b Mon Sep 17 00:00:00 2001
From: Yomain <40139584+yo-main@users.noreply.github.com>
Date: Wed, 8 Nov 2023 19:38:17 +0100
Subject: [PATCH] Add command to move files with LSP support (#8584)

* Added rename command

* Added an error if the new path already exists

* Fixed wrong command name being used

* fixed clippy suggestions

* removed didRenameFiles call, fixed early return due to path Err

* added ':rnm' alias to ':rename'

* code cleanup

* formatting

* removed debug line

* cargo fmt

* Improved new buffer error message

Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>

* Removed unnecessary path normalizing

Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>

* Update helix-term/src/commands/typed.rs

Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>

* Update helix-term/src/commands/typed.rs

Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>

* Update helix-term/src/commands/typed.rs

Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>

* Update helix-term/src/commands/typed.rs

Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>

* feat: change `rename` command to `move`

* feat: add multi lsp support when moving files

* feat: allow lsp calls with a custom timeout

* feat: sending lsp file_changed event once file has moved

---------

Co-authored-by: ontley <theontley@gmail.com>
Co-authored-by: ontley <67148677+ontley@users.noreply.github.com>
Co-authored-by: Pascal Kuthe <pascal.kuthe@semimod.de>
---
 book/src/generated/typable-cmd.md |  1 +
 helix-lsp/src/client.rs           | 76 +++++++++++++++++++++++++++-
 helix-term/src/commands/typed.rs  | 84 ++++++++++++++++++++++++++++++-
 3 files changed, 159 insertions(+), 2 deletions(-)

diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md
index 4b737893..6280d3c7 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 341702c3..e6e1f8a0 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<Output = Result<Value>>
+    where
+        R::Params: serde::Serialize,
+    {
+        self.call_with_timeout::<R>(params, self.req_timeout)
+    }
+
+    fn call_with_timeout<R: lsp::request::Request>(
+        &self,
+        params: R::Params,
+        timeout_secs: u64,
+    ) -> impl Future<Output = Result<Value>>
     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<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
+        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::request::WillRenameFiles>(
+            lsp::RenameFilesParams { files },
+            5,
+        );
+
+        Some(async move {
+            let json = request.await?;
+            let response: Option<lsp::WorkspaceEdit> = 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<impl Future<Output = std::result::Result<(), Error>>> {
+        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::notification::DidRenameFiles>(lsp::RenameFilesParams { files }))
+    }
+
     // -------------------------------------------------------------------------------------------
     // Text document
     // -------------------------------------------------------------------------------------------
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index e7343308..4148257f 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<str>],
+    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<helix_lsp::lsp::WorkspaceEdit>,
+        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<HashMap<&'static str, &'static TypableCommand>> =