From 5e5766a1236bf165abe6c3791b57d642c34627ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20K=C3=B6hnen?= Date: Thu, 20 Feb 2025 02:32:35 +0100 Subject: [PATCH 1/2] feat: moving multiple files/directories --- book/src/generated/typable-cmd.md | 2 +- helix-term/src/commands/typed.rs | 58 ++++++++++++++++++++++++------- helix-view/src/editor.rs | 35 ++++++++++++++----- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index dc5a6d08a..3874c0589 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -84,7 +84,7 @@ | `: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`, `:mv` | Move the current buffer and its corresponding file to a different path | +| `:move`, `:mv` | Move files, updating any affected open buffers. A single argument moves the current buffer's file, multiple arguments move multiple source files to a target (same as the Unix `mv` command) | | `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default | | `:read`, `:r` | Load a file into buffer | | `:echo` | Prints the given arguments to the statusline. | diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 1d57930cc..a016675e5 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -10,7 +10,7 @@ use helix_core::command_line::{Args, Flag, Signature, Token, TokenKind}; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::line_ending; -use helix_stdx::path::home_dir; +use helix_stdx::path::{canonicalize, home_dir}; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; use helix_view::expansion; @@ -2395,15 +2395,49 @@ fn move_buffer(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> return Ok(()); } - let doc = doc!(cx.editor); - let old_path = doc - .path() - .context("Scratch buffer cannot be moved. Use :write instead")? - .clone(); - let new_path = args.first().unwrap().to_string(); - if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) { - bail!("Could not move file: {err}"); + let mut new_path = canonicalize(&args[args.len() - 1]); + let is_dir = new_path.is_dir(); + + let mut do_move = |old_path: PathBuf, editor: &mut Editor| { + let file_name = old_path + .file_name() + .context("Cannot move file: source is root directory")?; + // Allow moving files into directories without repeating the file name in the new path. + if is_dir { + new_path.push(file_name); + } + if let Err(err) = editor.move_path(old_path.as_path(), new_path.as_ref()) { + bail!("Could not move file: {err}"); + } + if is_dir { + new_path.pop(); + } + Ok(()) + }; + + // Move the current buffer. + if args.len() == 1 { + let doc = doc!(cx.editor); + let old_path = doc + .path() + .context("Scratch buffer cannot be moved. Use :write instead")? + .clone(); + return do_move(old_path, cx.editor); } + + // Move multiple files to one destination, like `mv foo bar baz` where "baz" is a directory. + // If we have multiple source files, the destination must be a directory. + if !is_dir && args.len() > 2 { + bail!("Cannot move files: not a directory"); + } + for old_path in args + .iter() + .take(args.len() - 1) + .map(|arg| canonicalize(arg.to_string())) + { + do_move(old_path, cx.editor)?; + } + Ok(()) } @@ -3441,11 +3475,11 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "move", aliases: &["mv"], - doc: "Move the current buffer and its corresponding file to a different path", + doc: "Move files, updating any affected open buffers. A single argument moves the current buffer's file, multiple arguments move multiple source files to a target (same as the Unix `mv` command)", fun: move_buffer, - completer: CommandCompleter::positional(&[completers::filename]), + completer: CommandCompleter::all(completers::filename), signature: Signature { - positionals: (1, Some(1)), + positionals: (1, None), ..Signature::DEFAULT }, }, diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 739dcfb49..f0605bc5e 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1376,7 +1376,7 @@ impl Editor { } /// moves/renames a path, invoking any event handlers (currently only lsp) - /// and calling `set_doc_path` if the file is open in the editor + /// and calling `set_doc_path` for all affected files open in the editor pub fn move_path(&mut self, old_path: &Path, new_path: &Path) -> io::Result<()> { let new_path = canonicalize(new_path); // sanity check @@ -1406,8 +1406,31 @@ impl Editor { } } fs::rename(old_path, &new_path)?; - if let Some(doc) = self.document_by_path(old_path) { - self.set_doc_path(doc.id(), &new_path); + let mut to_update: Vec<_> = self + .documents + .iter() + .filter_map(|(id, doc)| { + let old_doc_path = doc.path()?; + let relative = old_doc_path.strip_prefix(old_path).ok()?; + let new_doc_path = new_path.join(relative); + Some((old_doc_path.to_owned(), new_doc_path, Some(*id))) + }) + .collect(); + // If old_path doesn't have an attached document, we haven't included it above. + // We can still tell the language servers that it changed. + if self.document_by_path(old_path).is_none() { + to_update.push((old_path.to_owned(), new_path.clone(), None)); + } + for (old_doc_path, new_doc_path, id) in to_update { + if let Some(id) = id { + self.set_doc_path(id, &new_doc_path); + } + self.language_servers + .file_event_handler + .file_changed(old_doc_path); + self.language_servers + .file_event_handler + .file_changed(new_doc_path); } let is_dir = new_path.is_dir(); for ls in self.language_servers.iter_clients() { @@ -1418,12 +1441,6 @@ impl Editor { } ls.did_rename(old_path, &new_path, is_dir); } - self.language_servers - .file_event_handler - .file_changed(old_path.to_owned()); - self.language_servers - .file_event_handler - .file_changed(new_path); Ok(()) } From 8414e01906e9eac4dc53640d35ac7e4cffe22dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20K=C3=B6hnen?= Date: Fri, 28 Feb 2025 19:14:36 +0100 Subject: [PATCH 2/2] fix: improve error message when path has no file name --- helix-term/src/commands/typed.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index a016675e5..3400f08ad 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2399,9 +2399,9 @@ fn move_buffer(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> let is_dir = new_path.is_dir(); let mut do_move = |old_path: PathBuf, editor: &mut Editor| { - let file_name = old_path - .file_name() - .context("Cannot move file: source is root directory")?; + let Some(file_name) = old_path.file_name() else { + bail!("Cannot move this path: {}", old_path.to_string_lossy()); + }; // Allow moving files into directories without repeating the file name in the new path. if is_dir { new_path.push(file_name);