From e02d7683c6c570e04679a68fd5ace11698815542 Mon Sep 17 00:00:00 2001 From: Sam Vente Date: Thu, 12 Jun 2025 21:35:39 +0200 Subject: [PATCH] feat: implement bookmarks --- Cargo.lock | 1 + book/src/generated/typable-cmd.md | 2 + helix-core/src/selection.rs | 79 +++++++++++++- helix-term/Cargo.toml | 30 +++++- helix-term/src/commands.rs | 7 ++ helix-term/src/commands/typed.rs | 171 ++++++++++++++++++++++++++++++ helix-term/src/keymap/default.rs | 1 + helix-term/tests/test/movement.rs | 37 +++++++ helix-view/src/lib.rs | 17 ++- 9 files changed, 338 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3694c0605..57485769a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1552,6 +1552,7 @@ dependencies = [ "helix-event", "helix-loader", "helix-lsp", + "helix-parsec", "helix-stdx", "helix-tui", "helix-vcs", diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 219f6b95f..acd6e7efb 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -16,6 +16,8 @@ | `:write-buffer-close`, `:wbc` | Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt) | | `:write-buffer-close!`, `:wbc!` | Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt) | | `:new`, `:n` | Create a new scratch buffer. | +| `:goto-mark` | Go to the selection saved in a register. Register can be provided as argument or selected register else ^ will be used | +| `:register-mark` | Save current selection into a register. Register can be provided as argument or selected register else ^ will be used | | `:format`, `:fmt` | Format the file using an external formatter or language server. | | `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.) | | `:line-ending` | Set the document's default line ending. Options: crlf, lf. | diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 5bde08e31..33b9b2bee 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -12,10 +12,11 @@ use crate::{ tree_sitter::Node, Assoc, ChangeSet, RopeSlice, }; +use helix_parsec::{seq, take_until, Parser}; use helix_stdx::range::is_subset; use helix_stdx::rope::{self, RopeSliceExt}; use smallvec::{smallvec, SmallVec}; -use std::{borrow::Cow, iter, slice}; +use std::{borrow::Cow, fmt::Display, iter, slice}; /// A single selection range. /// @@ -392,6 +393,34 @@ impl Range { } } +impl Display for Range { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({},{})", self.anchor, self.head) + } +} + +impl TryFrom<&str> for Range { + type Error = String; + + fn try_from(value: &str) -> Result { + let parser = seq!( + "(", + take_until(|c| c == ','), + ",", + take_until(|c| c == ')'), + ")" + ); + match parser.parse(value) { + Ok((_tail, (_, anchor, _, head, _))) => Ok(Self { + anchor: anchor.parse::().map_err(|e| e.to_string())?, + head: head.parse::().map_err(|e| e.to_string())?, + old_visual_position: None, + }), + Err(e) => Err(e.to_string()), + } + } +} + impl From<(usize, usize)> for Range { fn from((anchor, head): (usize, usize)) -> Self { Self { @@ -884,6 +913,54 @@ mod test { use super::*; use crate::Rope; + #[test] + fn parse_range() -> Result<(), String> { + // sometimes we want Ok, sometimes we want Err, but we never want a panic + assert_eq!( + Range::try_from("(0,28)"), + Ok(Range { + anchor: 0, + head: 28, + old_visual_position: None + }) + ); + assert_eq!( + Range::try_from("(3456789,123456789)"), + Ok(Range { + anchor: 3456789, + head: 123456789, + old_visual_position: None + }) + ); + assert_eq!(Range::try_from("(,)"), Err("(,)".to_string())); + assert_eq!( + Range::try_from("(asdf,asdf)"), + Err("invalid digit found in string".to_string()) + ); + assert_eq!(Range::try_from("()"), Err("()".to_string())); + assert_eq!( + Range::try_from("(-4,ALSK)"), + Err("invalid digit found in string".to_string()) + ); + assert_eq!( + Range::try_from("(⦡⓼␀⍆ⴉ├⺶⍄⾨,⦡⓼␀⍆ⴉ├⺶⍄⾨)"), + Err("invalid digit found in string".to_string()) + ); + Ok(()) + } + + #[test] + fn display_range() { + assert_eq!( + Range { + anchor: 72, + head: 28, + old_visual_position: None, + } + .to_string(), + "(72,28)".to_string(), + ); + } #[test] #[should_panic] fn test_new_empty() { diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index fca91e9d6..699385933 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -49,16 +49,32 @@ helix-lsp = { path = "../helix-lsp" } helix-dap = { path = "../helix-dap" } helix-vcs = { path = "../helix-vcs" } helix-loader = { path = "../helix-loader" } +helix-parsec = { path = "../helix-parsec" } anyhow = "1" once_cell = "1.21" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } -tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] } +tokio = { version = "1", features = [ + "rt", + "rt-multi-thread", + "io-util", + "io-std", + "time", + "process", + "macros", + "fs", + "parking_lot", +] } +tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = [ + "crossterm", +] } crossterm = { version = "0.28", features = ["event-stream"] } signal-hook = "0.3" tokio-stream = "0.1" -futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } +futures-util = { version = "0.3", features = [ + "std", + "async-await", +], default-features = false } arc-swap = { version = "1.7.1" } termini = "1" indexmap = "2.9" @@ -91,12 +107,16 @@ serde = { version = "1.0", features = ["derive"] } grep-regex = "0.1.13" grep-searcher = "0.1.14" -[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 +[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } libc = "0.2.172" [target.'cfg(target_os = "macos")'.dependencies] -crossterm = { version = "0.28", features = ["event-stream", "use-dev-tty", "libc"] } +crossterm = { version = "0.28", features = [ + "event-stream", + "use-dev-tty", + "libc", +] } [build-dependencies] helix-loader = { path = "../helix-loader" } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2cbdeb451..5b2c30ba5 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -5,6 +5,7 @@ pub(crate) mod typed; pub use dap::*; use futures_util::FutureExt; use helix_event::status; +use helix_parsec::{seq, take_until, Parser}; use helix_stdx::{ path::{self, find_paths}, rope::{self, RopeSliceExt}, @@ -47,6 +48,7 @@ use helix_view::{ info::Info, input::KeyEvent, keyboard::KeyCode, + register::RegisterValues, theme::Style, tree, view::View, @@ -74,6 +76,7 @@ use std::{ future::Future, io::Read, num::NonZeroUsize, + str::FromStr, }; use std::{ @@ -6635,6 +6638,10 @@ fn extend_to_word(cx: &mut Context) { jump_to_word(cx, Movement::Extend) } +fn read_from_register(editor: &mut Editor, reg: char) -> Option { + editor.registers.read(reg, &*editor) +} + fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { let doc = doc!(cx.editor); let alphabet = &cx.editor.config().jump_label_alphabet; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 2013a9d81..3ea705565 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -479,6 +479,155 @@ fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> an Ok(()) } +fn register_mark( + cx: &mut compositor::Context, + args: Args, + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + }; + let register_name: char = args + .first() + .map_or_else(|| cx.editor.selected_register, |s| s.chars().next()) + .unwrap_or('^'); + + let (view, doc) = current!(cx.editor); + + let ranges_str = doc + .selection(view.id) + .ranges() + .iter() + .map(|r| r.to_string()) + .collect::>(); + + // we have to take because of cell + let history = doc.history.take(); + let current_history_point = history.current_revision(); + doc.history.replace(history); + + // doc_id so we know which doc to switch to + // current_history_point so we can apply changes + // to our selection when we restore it. + // the rest of the elements are just the stringified ranges + let mut register_val = vec![ + format!("{}", doc.id()), + format!("{}", current_history_point), + ]; + register_val.extend(ranges_str); + + cx.editor.registers.write(register_name, register_val)?; + + cx.editor + .set_status(format!("Saved selection bookmark to [{}]", register_name)); + Ok(()) +} + +fn parse_mark_register_contents( + registers_vals: Option, +) -> anyhow::Result<(DocumentId, usize, Selection)> { + match registers_vals { + Some(rv) => { + let mut rv_iter = rv.into_iter(); + + let Some(doc_id) = rv_iter + .next() + .map(|c| c.into_owned()) + .and_then(|s| s.try_into().ok()) + else { + return Err(anyhow!("Register did not contain valid document id")); + }; + let Some(history_rev) = rv_iter + .next() + .map(|c| c.into_owned()) + .and_then(|s| s.parse().ok()) + else { + return Err(anyhow!("Register did not contain valid revision number")); + }; + + let Ok(ranges) = rv_iter + .map(|tup| { + let s = tup.into_owned(); + let range_parser = seq!( + "(", + take_until(|c| c == ','), + ",", + take_until(|c| c == ')'), + ")" + ); + let Ok((_tail, (_lparen, anchor_str, _comma, head_str, _rparen))) = + range_parser.parse(&s) + else { + return Err(format!("Could not parse range from string: {}", s)); + }; + + let Ok(anchor) = ::from_str(anchor_str) else { + return Err(format!("Could not parse range from string: {}", s)); + }; + let Ok(head) = ::from_str(head_str) else { + return Err(format!("Could not parse range from string: {}", s)); + }; + + Ok(Range { + anchor, + head, + old_visual_position: None, + }) + }) + // reverse the iterators so the first range will end up as the primary when we push them + .rev() + .collect::, String>>() + else { + return Err(anyhow!("Some ranges in the register failed to parse!")); + }; + + let mut ranges_iter = ranges.into_iter(); + + let last_range = ranges_iter.next().unwrap(); // safe since there is always at least one range + let mut selection = Selection::from(last_range); + for r in ranges_iter { + selection = selection.push(r); + } + + Ok((doc_id, history_rev, selection)) + } + None => Err(anyhow!("Register was empty")), + } +} + +fn goto_mark(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + }; + let register_name: char = args + .first() + .map_or_else(|| cx.editor.selected_register, |s| s.chars().next()) + .unwrap_or('^'); + + let scrolloff = cx.editor.config().scrolloff; + // use some helper functions to avoid making the borrow checker angry + let registers_vals = read_from_register(cx.editor, register_name); + let (doc_id, history_rev, mut selection) = parse_mark_register_contents(registers_vals)?; + + cx.editor.switch(doc_id, Action::Replace); + + let (view, doc) = current!(cx.editor); + let history = doc.history.take(); + let revisions_to_apply = history.changes_since(history_rev); + doc.history.replace(history); + + selection = match revisions_to_apply { + Some(t) => selection.map(t.changes()), + None => selection, + }; + + doc.set_selection(view.id, selection); + + view.ensure_cursor_in_view(doc, scrolloff); + + Ok(()) +} + fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); @@ -2758,6 +2907,28 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ ..Signature::DEFAULT }, }, + TypableCommand { + name: "goto-mark", + aliases: &[], + doc: "Go to the selection saved in a register. Register can be provided as argument or selected register else ^ will be used", + fun: goto_mark, + completer: CommandCompleter::positional(&[completers::register]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::DEFAULT + }, + }, + TypableCommand { + name: "register-mark", + aliases: &[], + doc: "Save current selection into a register. Register can be provided as argument or selected register else ^ will be used", + fun: register_mark, + completer: CommandCompleter::positional(&[completers::register]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::DEFAULT + }, + }, TypableCommand { name: "format", aliases: &["fmt"], diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 82baf336f..cb993d2df 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -336,6 +336,7 @@ pub fn default() -> HashMap { "C-a" => increment, "C-x" => decrement, + }); let mut select = normal.clone(); select.merge_nodes(keymap!({ "Select mode" diff --git a/helix-term/tests/test/movement.rs b/helix-term/tests/test/movement.rs index 062d37967..a5fd858df 100644 --- a/helix-term/tests/test/movement.rs +++ b/helix-term/tests/test/movement.rs @@ -65,6 +65,43 @@ async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn bookmark() -> anyhow::Result<()> { + // add a mark and then immediately paste it out + test(( + indoc! {"\ + #[|Lorem]# + ipsum + #(|Lorem)# + ipsum + #(|Lorem)# + ipsum + #(|Lorem)# + ipsum + #(|Lorem)# + ipsum" + }, + // make a mark, make changes to the doc, colapse selection by going to end of doc + // then resore mark and see the selection is still good + ":register-mark1casdfge:goto-mark1", + indoc! {"\ + #[|asdf]# + ipsum + #(|asdf)# + ipsum + #(|asdf)# + ipsum + #(|asdf)# + ipsum + #(|asdf)# + ipsum" + }, + )) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn surround_by_character() -> anyhow::Result<()> { // Only pairs matching the passed character count diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index e30a23381..888196ec7 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -19,7 +19,7 @@ pub mod theme; pub mod tree; pub mod view; -use std::num::NonZeroUsize; +use std::num::{NonZeroUsize, ParseIntError}; // uses NonZeroUsize so Option use a byte rather than two #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] @@ -32,6 +32,21 @@ impl Default for DocumentId { } } +impl TryFrom<&str> for DocumentId { + type Error = ParseIntError; + + fn try_from(value: &str) -> Result { + Ok(Self(value.parse::()?)) + } +} + +impl TryFrom for DocumentId { + type Error = ParseIntError; + + fn try_from(value: String) -> Result { + Ok(Self(value.parse::()?)) + } +} impl std::fmt::Display for DocumentId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!("{}", self.0))