From 835107ab3c2f2e1ee52a5b4a18938aa2b0624678 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Nov 2025 20:40:45 +0100 Subject: [PATCH] Go to next/prev file in same dir as current file `[n` and `]n` implement the unimpaired mappings: > [f Go to the file preceding the current one alphabetically in the current file's directory. > ]f Go to the file succeeding the current one alphabetically in the current file's directory. that are already taken. --- book/src/generated/static-cmd.md | 2 + book/src/generated/typable-cmd.md | 2 + book/src/keymap.md | 54 +++++++------- helix-term/src/commands.rs | 62 ++++++++++++++-- helix-term/src/commands/typed.rs | 40 ++++++++++ helix-term/src/keymap/default.rs | 2 + helix-term/tests/test/commands.rs | 117 ++++++++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 34 deletions(-) diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 1b347290b9f1..8b8a4f2e4c35 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -161,6 +161,8 @@ | `extend_to_column` | Extend to column | select: `` g\| `` | | `goto_next_buffer` | Goto next buffer | normal: `` gn ``, select: `` gn `` | | `goto_previous_buffer` | Goto previous buffer | normal: `` gp ``, select: `` gp `` | +| `goto_next_file` | Go to the next file alphabetically in the current file's directory | normal: `` ]n ``, select: `` ]n `` | +| `goto_prev_file` | Go to the previous file alphabetically in the current file's directory | normal: `` [n ``, select: `` [n `` | | `goto_line_end_newline` | Goto newline at line end | insert: `` `` | | `goto_first_nonwhitespace` | Goto first non-blank in line | normal: `` gs ``, select: `` gs `` | | `trim_selections` | Trim whitespace from selections | normal: `` _ ``, select: `` _ `` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index f7e05cff3fbf..c52887042efb 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -13,6 +13,8 @@ | `:buffer-close-all!`, `:bca!`, `:bcloseall!` | Force close all buffers ignoring unsaved changes without quitting. | | `:buffer-next`, `:bn`, `:bnext` | Goto next buffer. | | `:buffer-previous`, `:bp`, `:bprev` | Goto previous buffer. | +| `:file-next`, `:fn` | Goto next file in the current file's directory. | +| `:file-previous`, `:fp` | Goto previous file in the current file's directory. | | `:write`, `:w` | Write changes to disk. Accepts an optional path (:write some/path.txt) | | `:write!`, `:w!` | Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write! some/path.txt) | | `:write-buffer-close`, `:wbc` | Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt) | diff --git a/book/src/keymap.md b/book/src/keymap.md index 95662f71bac4..cbb7d088079e 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -352,32 +352,34 @@ Displays the signature of the selected completion item. Remapping currently not These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). -| Key | Description | Command | -| ----- | ----------- | ------- | -| `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` | -| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` | -| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | -| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` | -| `]f` | Go to next function (**TS**) | `goto_next_function` | -| `[f` | Go to previous function (**TS**) | `goto_prev_function` | -| `]t` | Go to next type definition (**TS**) | `goto_next_class` | -| `[t` | Go to previous type definition (**TS**) | `goto_prev_class` | -| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` | -| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | -| `]c` | Go to next comment (**TS**) | `goto_next_comment` | -| `[c` | Go to previous comment (**TS**) | `goto_prev_comment` | -| `]T` | Go to next test (**TS**) | `goto_next_test` | -| `[T` | Go to previous test (**TS**) | `goto_prev_test` | -| `]p` | Go to next paragraph | `goto_next_paragraph` | -| `[p` | Go to previous paragraph | `goto_prev_paragraph` | -| `]g` | Go to next change | `goto_next_change` | -| `[g` | Go to previous change | `goto_prev_change` | -| `]G` | Go to last change | `goto_last_change` | -| `[G` | Go to first change | `goto_first_change` | -| `[x` | Go to next (X)HTML element | `goto_next_xml_element` | -| `]x` | Go to previous (X)HTML element | `goto_prev_xml_element` | -| `]Space` | Add newline below | `add_newline_below` | -| `[Space` | Add newline above | `add_newline_above` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` | +| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` | +| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | +| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` | +| `]f` | Go to next function (**TS**) | `goto_next_function` | +| `[f` | Go to previous function (**TS**) | `goto_prev_function` | +| `]t` | Go to next type definition (**TS**) | `goto_next_class` | +| `[t` | Go to previous type definition (**TS**) | `goto_prev_class` | +| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` | +| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | +| `]c` | Go to next comment (**TS**) | `goto_next_comment` | +| `[c` | Go to previous comment (**TS**) | `goto_prev_comment` | +| `]T` | Go to next test (**TS**) | `goto_next_test` | +| `[T` | Go to previous test (**TS**) | `goto_prev_test` | +| `]p` | Go to next paragraph | `goto_next_paragraph` | +| `[p` | Go to previous paragraph | `goto_prev_paragraph` | +| `]g` | Go to next change | `goto_next_change` | +| `[g` | Go to previous change | `goto_prev_change` | +| `]G` | Go to last change | `goto_last_change` | +| `[G` | Go to first change | `goto_first_change` | +| `[x` | Go to next (X)HTML element | `goto_next_xml_element` | +| `]x` | Go to previous (X)HTML element | `goto_prev_xml_element` | +| `]Space` | Add newline below | `add_newline_below` | +| `[Space` | Add newline above | `add_newline_above` | +| `[n` | Go to the next file alphabetically in the current file's directory | `goto_next_file` | +| `]n` | Go to the previous file alphabetically in the current file's directory | `goto_prev_file` | ## Insert mode diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 4c12b0239854..c49cb5610c46 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -464,6 +464,8 @@ impl MappableCommand { extend_to_column, "Extend to column", goto_next_buffer, "Goto next buffer", goto_previous_buffer, "Goto previous buffer", + goto_next_file, "Go to the next file alphabetically in the current file's directory", + goto_prev_file, "Go to the previous file alphabetically in the current file's directory", goto_line_end_newline, "Goto newline at line end", goto_first_nonwhitespace, "Goto first non-blank in line", trim_selections, "Trim whitespace from selections", @@ -3070,14 +3072,10 @@ fn file_picker(cx: &mut Context) { } fn file_picker_in_current_buffer_directory(cx: &mut Context) { - let doc_dir = doc!(cx.editor) - .path() - .and_then(|path| path.parent().map(|path| path.to_path_buf())); - - let path = match doc_dir { - Some(path) => path, - None => { - cx.editor.set_error("current buffer has no path or parent"); + let path = match parent_dir(cx.editor) { + Ok(value) => value, + Err(msg) => { + cx.editor.set_error(msg.to_string()); return; } }; @@ -6955,3 +6953,51 @@ fn lsp_or_syntax_workspace_symbol_picker(cx: &mut Context) { syntax_workspace_symbol_picker(cx); } } + +fn goto_next_file(cx: &mut Context) { + if let Err(err) = goto_next_file_impl(cx.editor, Direction::Forward, cx.count()) { + cx.editor.set_error(err.to_string()); + } +} + +fn goto_prev_file(cx: &mut Context) { + if let Err(err) = goto_next_file_impl(cx.editor, Direction::Backward, cx.count()) { + cx.editor.set_error(err.to_string()); + } +} + +fn goto_next_file_impl( + editor: &mut Editor, + direction: Direction, + count: usize, +) -> anyhow::Result<()> { + let path = parent_dir(editor)?; + let files: Vec<_> = WalkBuilder::new(path) + .sort_by_file_name(|name1, name2| name1.cmp(name2)) + .max_depth(Some(1)) + .build() + .flatten() + .map(|e| e.into_path()) + .filter(|p| p.is_file()) + .collect(); + if let Some(path) = doc!(editor).path() { + let file = match direction { + Direction::Forward => files.iter().skip_while(|f| *f != path).nth(count), + Direction::Backward => files.iter().rev().skip_while(|f| *f != path).nth(count), + }; + + let file = file.ok_or_else(|| anyhow!("No more files"))?; + if let Err(e) = editor.open(file, Action::Replace) { + anyhow::bail!(format!("Open file failed: {:?}", e)); + } + } + Ok(()) +} + +/// Get the parent dir if it exists. +fn parent_dir(editor: &mut Editor) -> anyhow::Result { + doc!(editor) + .path() + .and_then(|path| path.parent().map(|path| path.to_path_buf())) + .ok_or_else(|| anyhow!("current buffer has no path or parent")) +} diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b928dd4f28d0..c7508c203e28 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -481,6 +481,24 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { } } +fn file_next(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + goto_next_file_impl(cx.editor, Direction::Forward, 1) +} + +fn file_previous( + cx: &mut compositor::Context, + _args: Args, + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + goto_next_file_impl(cx.editor, Direction::Backward, 1) +} + #[derive(Debug, Clone, Copy)] pub struct WriteOptions { pub force: bool, @@ -2903,6 +2921,28 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ ..Signature::DEFAULT }, }, + TypableCommand { + name: "file-next", + aliases: &["fn"], + doc: "Goto next file in the current file's directory.", + fun: file_next, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::DEFAULT + }, + }, + TypableCommand { + name: "file-previous", + aliases: &["fp"], + doc: "Goto previous file in the current file's directory.", + fun: file_previous, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::DEFAULT + }, + }, TypableCommand { name: "write", aliases: &["w"], diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 5bbbd3f40429..1660bef3e044 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -114,6 +114,7 @@ pub fn default() -> HashMap { "g" => goto_prev_change, "G" => goto_first_change, "f" => goto_prev_function, + "n" => goto_prev_file, "t" => goto_prev_class, "a" => goto_prev_parameter, "c" => goto_prev_comment, @@ -129,6 +130,7 @@ pub fn default() -> HashMap { "g" => goto_next_change, "G" => goto_last_change, "f" => goto_next_function, + "n" => goto_next_file, "t" => goto_next_class, "a" => goto_next_parameter, "c" => goto_next_comment, diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index 90ff4cf0cdc4..2580f28b41ad 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -1,6 +1,9 @@ use helix_term::application::Application; +use helix_view::editor::Severity; use super::*; +use std::fs::File; +use std::ops::Deref; mod insert; mod movement; @@ -844,3 +847,117 @@ async fn global_search_with_multibyte_chars() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_goto_next_file() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + + let a = dir.path().join("a"); + let b = dir.path().join("b"); + let c = dir.path().join("c"); + let d = dir.path().join("d").join("ignored"); + + std::fs::create_dir(d.parent().unwrap())?; + + File::create(&a)?; + File::create(&b)?; + File::create(&c)?; + File::create(&d)?; + + test_key_sequence( + &mut helpers::AppBuilder::new().build()?, + Some("]n"), + Some(&|app| { + let (msg, severity) = app.editor.get_status().unwrap(); + assert_eq!("current buffer has no path or parent", msg); + assert_eq!(&Severity::Error, severity); + }), + false, + ) + .await?; + + test_key_sequence( + &mut helpers::AppBuilder::new().build()?, + Some("[n"), + Some(&|app| { + let (msg, severity) = app.editor.get_status().unwrap(); + assert_eq!("current buffer has no path or parent", msg); + assert_eq!(&Severity::Error, severity); + }), + false, + ) + .await?; + + fn open_buffers(app: &Application) -> Vec { + app.editor + .documents() + .filter_map(|d| d.path()?.file_name()) + .map(|n| n.to_string_lossy().deref().to_owned()) + .collect() + } + + test_key_sequence( + &mut AppBuilder::new().with_file(&b, None).build()?, + Some("[n"), + Some(&|app| { + assert_eq!(vec!["b", "a"], open_buffers(app)); + assert_status_not_error(&app.editor); + }), + false, + ) + .await?; + + test_key_sequence( + &mut AppBuilder::new().with_file(&c, None).build()?, + Some("[n[n[n[n[n[n[n[n[n"), + Some(&|app| { + assert_eq!(vec!["c", "b", "a"], open_buffers(app)); + + let (msg, severity) = app.editor.get_status().unwrap(); + assert_eq!("No more files", msg); + assert_eq!(&Severity::Error, severity); + }), + false, + ) + .await?; + + test_key_sequence( + &mut AppBuilder::new().with_file(&b, None).build()?, + Some("]n"), + Some(&|app| { + assert_eq!(vec!["b", "c"], open_buffers(app)); + assert_status_not_error(&app.editor); + }), + false, + ) + .await?; + + test_key_sequence( + &mut AppBuilder::new().with_file(&a, None).build()?, + Some("]n]n]n]n]n]n]n]n"), + Some(&|app| { + assert_eq!(vec!["a", "b", "c"], open_buffers(app)); + + let (msg, severity) = app.editor.get_status().unwrap(); + assert_eq!("No more files", msg); + assert_eq!(&Severity::Error, severity); + }), + false, + ) + .await?; + + test_key_sequence( + &mut AppBuilder::new() + .with_file(dir.path().join("not").join("there"), None) + .build()?, + Some("[n"), + Some(&|app| { + let (msg, severity) = app.editor.get_status().unwrap(); + assert_eq!("No more files", msg); + assert_eq!(&Severity::Error, severity); + }), + false, + ) + .await?; + Ok(()) +}