diff --git a/Cargo.lock b/Cargo.lock index 6adc5e48ed8d..a91c06766b74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,15 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1675,6 +1684,7 @@ name = "helix-loader" version = "25.7.1" dependencies = [ "anyhow", + "bincode", "cc", "etcetera", "globset", @@ -1880,9 +1890,11 @@ dependencies = [ "once_cell", "parking_lot", "quickcheck", + "regex", "rustix 1.1.4", "serde", "serde_json", + "serde_regex", "slotmap", "tempfile", "termina", @@ -2700,6 +2712,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2832,6 +2854,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smartstring" diff --git a/Cargo.toml b/Cargo.toml index f9a4444c856d..e9047f4d02f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ globset = "0.4" etcetera = "0.11" arc-swap = "1.9" arrayvec = "0.7" +regex = "1" # dev dependencies criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } diff --git a/book/src/editor.md b/book/src/editor.md index 4e0b838f9110..405bad3c170d 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -610,3 +610,20 @@ level = "servers" # under a matching path. `~` and environment variables are expanded. trusted = ["~/src/github.com/me/*"] ``` + +### `[editor.persistence]` Section + +Options for persisting editor state between sessions. + +The state is formatted with bincode, and stored in files in the state directory (`~/.local/state/helix` on Unix, `~\Local Settings\Application Data\helix\state` on Windows). You can reset your persisted state (and recover from any corruption) by deleting these files. + +| Key | Description | Default | +| --- | ----------- | ------- | +| `old-files` | whether to persist file locations between sessions ( when you reopen the a file, it will open at the place you last closed it) | `false` | +| `commands` | whether to persist command history between sessions | `false` | +| `search` | whether to persist search history between sessions | `false` | +| `clipboard` | whether to persist helix's internal clipboard between sessions | `false` | +| `old-files-exclusions` | a list of regexes defining file paths to exclude from persistence | `[".*/\.git/.*", ".*/COMMIT_EDITMSG"]` | +| `old-files-trim` | number of old-files entries to keep when helix trims the state files at startup | `100` | +| `commands-trim` | number of command history entries to keep when helix trims the state files at startup | `100` | +| `search-trim` | number of search history entries to keep when helix trims the state files at startup | `100` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 1a3c5937733c..52cdc81780e5 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -105,3 +105,4 @@ | `:workspace-trust` | Allow language servers and local config for the current workspace. | | `:workspace-untrust` | Revoke the current workspace's trust grant or exclusion. | | `:workspace-exclude` | Mark the current workspace as never-prompt. Never prompts for trust again. | +| `:reload-history` | Reload history files for persistent state | diff --git a/docs/persistence-merge-notes.md b/docs/persistence-merge-notes.md new file mode 100644 index 000000000000..0e8e63c044d3 --- /dev/null +++ b/docs/persistence-merge-notes.md @@ -0,0 +1,290 @@ +# Persistence merge notes + +How cross-session history was ported from upstream PR +[helix-editor/helix#9143](https://github.com/helix-editor/helix/pull/9143) +(`intarga/helix` branch `persistent_state`) onto this gj1118-based tree. + +Published branch on this fork: **`persistent_state`** +([bellhyve/helix](https://github.com/bellhyve/helix/tree/persistent_state)). +Frozen upstream tip: `vendor/intarga-persistent_state` (`ea5d2df1`). + +**Purpose of this doc:** simplify rebase after a gj1118/`master` bump. + +**First land commit:** `0f409a7a` (merge of `intarga/persistent_state` + conflict +resolution + one behavioral fix). + +--- + +## What this feature is + +Opt-in persistence under `[editor.persistence]`: + +| Key | Effect | Default | +|-----|--------|---------| +| `commands` | `:` history survives quit | `false` | +| `search` | `/` history survives quit | `false` | +| `old-files` | reopen file at last cursor/view | `false` | +| `clipboard` | Helix internal `"` register | `false` | +| `*-trim` | max entries kept on startup trim | `100` | +| `old-files-exclusions` | regex paths skipped for old-files | git + `COMMIT_EDITMSG` | + +On-disk format: **bincode**, append/rewrite under XDG state: + +- Unix: `~/.local/state/helix/` +- files: `command_history`, `search_history`, `file_history`, `clipboard` + +Command: `:reload-history` — re-read histfiles into the live session (and +re-queue trim jobs). + +**Not** session restore of splits/buffers. **Not** a plugin — it touches +registers, `Editor::open`/`close`/`close_document`, startup, and config. + +Upstream status as of 2026-08: PR closed unmerged (author fatigue / no +maintainer review). Branch still existed at `intarga/helix:persistent_state` +(`ea5d2df1`). + +--- + +## Source map from #9143 + +New files from #9143 (probably keep whole): + +- `helix-loader/src/persistence.rs` — bincode read/write/trim +- `helix-view/src/persistence.rs` — register + file-history helpers +- `helix-view/src/regex.rs` — `EqRegex` wrapper for config exclusions +- `helix-term/tests/test/persistence.rs` — multi-session integration test +- book section: `book/src/editor.md` → `[editor.persistence]` + +Conflicts: + +| Area | Why it conflicts | +|------|------------------| +| `helix-view/src/editor.rs` | Config struct, `Editor::new` signature, `open`/`close`/`close_document` | +| `helix-term/src/application.rs` | startup load of hist + trim jobs; file-open CLI positions | +| `helix-term/src/commands/typed.rs` | `:reload-history`; command table grows often in gj1118 | +| `helix-term/src/ui/prompt.rs` | push `:`/`/` lines on validate | +| `helix-term/src/commands.rs` | yank → optional clipboard file write | +| `helix-term/src/main.rs` | `initialize_*_histfile` / clipboard path | +| `helix-loader/src/lib.rs` | `state_dir`, histfile OnceCells + getters | +| Cargo.toml(s) | `bincode`, `serde_regex`, `regex` workspace, `smallvec` serde feature | +| `helix-core/src/selection.rs` | `Serialize`/`Deserialize` on `Range`/`Selection` | +| `helix-view/src/view.rs` | same on `ViewPosition` | + +--- + +## Merge strategy that worked + +1. **Remote:** `git remote add intarga https://github.com/intarga/helix.git` (if missing). +2. **Fetch:** `git fetch intarga persistent_state`. +3. **Merge** (prefer over raw patch — 3-way helps): + ```bash + git merge intarga/persistent_state --no-ff + ``` +4. **Do not** blindly take “theirs” on big gj1118 files (`editor.rs`, + `application.rs`, `typed.rs`). Those carry noice cmdline, plugins, + workspace-trust, notifications, local_search, etc. +5. **Do** take new persistence modules and loader histfile helpers intact. +6. **Cargo.lock:** restore ours if mangled, then let `cargo check -p helix-term` + add only `bincode` / `serde_regex`. Avoid `cargo generate-lockfile`. + +If `intarga/persistent_state` disappears later, recover from this branch’s +merge commit `0f409a7a`, from `vendor/intarga-persistent_state`, or from the +upstream PR patch on #9143. + +--- + +## Conflict playbook (by file) + +### Always keep both modules (loader) + +```rust +pub mod workspace_trust; +pub mod persistence; +``` + +Keep **both** `data_dir()` (gj1118/upstream HEAD) and `state_dir()` (persistence). +Histfiles live under `state_dir()`, not cache. + +### Cargo / deps + +Workspace `Cargo.toml`: keep current workspace deps; add: + +```toml +regex = "1" +``` + +`helix-core`: keep workspace `ropey` / `bitflags` / `foldhash`; use + +```toml +smallvec = { version = "1.15", features = ["serde"] } # version: match tree +regex.workspace = true +``` + +`helix-view`: keep workspace `toml` / `parking_lot`; add `serde_regex`, +`regex.workspace = true`. + +`helix-loader`: add `bincode = "1.3.3"` (version flexible if API stable). + +### `Editor::new` signature + +Persistence adds a final argument: + +```rust +old_file_locs: HashMap, +``` + +**Keep** gj1118’s `workspace_trust: WorkspaceTrust` argument. Order used on +first land: + +```text +area, theme_loader, syn_loader, config, handlers, workspace_trust, old_file_locs +``` + +Every `Editor::new(` call site must pass both (today: `application.rs` only). + +### `application.rs` startup sequence + +After `ArcSwap` config exists: + +1. `persistence_config = config.load().editor.persistence.clone()` +2. If `old_files`: build `old_file_locs` from `persistence::read_file_history()` +3. `Editor::new(..., workspace_trust, old_file_locs)` +4. If enabled: `registers.write(':', command_history)`, same for `/` and `"` +5. Queue trim jobs with `Job::new(...).wait_before_exiting()` (Jobs::add is + `&self` — `mut jobs` not required) + +Imports needed: `persistence`, `Job`, `HashMap`. + +### Opinionated fix (not in original PR) — CLI position clobber + +**Problem:** After `editor.open()`, gj1118/HEAD always applied CLI file +positions. `parse_file` yields `Position::default()` (0,0) when the user did +not pass `:line`. That **wipes** `old-files` restore from inside `open()`. + +**Fix** (keep this on every rebase): + +```rust +let apply_cli_pos = match pos.as_slice() { + [] => false, + [p] if *p == helix_core::Position::default() => false, + _ => true, +}; +if apply_cli_pos { + // existing set_selection from CLI coords +} +``` + +**Tradeoff:** `hx file:1:1` is also (0,0) after saturating_sub and will not +force-apply. Do **not** drop this fix or the integration test fails on +session-2 insert order. + +Original PR used `Vec<(PathBuf, Option)>` so “no line” was `None`. +This tree uses `IndexMap>` — keep that API; only skip +implicit default. + +### `typed.rs` + +- Add `reload_history` with current signature: + `fn(..., args: Args, event: PromptEvent)` (not old `&[Cow]`). +- Register at end of `TYPABLE_COMMAND_LIST` with + `CommandCompleter::none()` + `Signature { positionals: (0, Some(0)), .. }`. +- gj1118 often appends commands (notifications, workspace-trust, plugins). + Re-apply the table entry after those blocks; don’t delete theirs. + +### `close` / `close_document` + +When saving `FileHistoryEntry`, `doc.path()` is `&Path` — use +`path.to_path_buf()`, not `path.clone()` (type error). + +`:wq` → `write_*` then `quit` → `editor.close(view_id)`. File locs must be +recorded in **`close`**, not only `close_document`. + +### Tests + +- `helix-term/tests/integration.rs`: `mod persistence;` +- `helpers.rs` `with_file`: prefer empty position list when `pos` is `None`: + ```rust + self.args.files.insert(path.into(), pos.map(|p| vec![p]).unwrap_or_default()); + ``` + so tests don’t inject a fake (0,0). +- Histfiles use `OnceCell` — `initialize_*_histfile(Some(temp))` only works + once per process. The persistence test sets temps at start; fine for that + test binary run. + +### Args / parse_file + +If a merge tries to change `files` to `Vec<(PathBuf, Option)>`, +**reject** and keep IndexMap + multi-position behavior unless you’re ready to +update every call site (application open loop, helpers, `:open`). + +--- + +## Problems hit on first land + +1. **Huge conflict hunks in `editor.rs` / `typed.rs` / `application.rs`** + Auto-merge interleaved multi-hundred-line HEAD-only UI with small PR + inserts. Resolution: `git checkout --ours` those three, then **surgically** + re-apply persistence (Config field, PersistenceConfig type, open/close + hooks, startup load, reload command). Faster than hand-merging noice UI. + +2. **`cargo generate-lockfile`** + Unlocked the world; pulled `kstring` needing rustc 1.96 while Homebrew was + 1.94. Fix: restore `Cargo.lock` from HEAD, `cargo check` to add only new + crates. + +3. **Integration test failed first run (`\nb\na\n` vs `\na\nb\n`)** + Root cause: CLI default position clobber (see above). Not a broken histfile. + +4. **`cp target/release/hx ~/.cargo/bin/hx` → exit 137** + Prefer `cargo install --path helix-term --locked --force`. After install, + `hx -V` should print. + +5. **Upstream PR is not “almost merged”** + Closed by author after ping spam; no maintainer sign-off. Treat as + vendored feature on this fork, not something that will land upstream soon. + +--- + +## Config snippet + +```toml +[editor.persistence] +commands = true +search = true +old-files = true +# clipboard = true +# commands-trim = 100 +# search-trim = 100 +# old-files-trim = 100 +``` + +Nuke corrupted state: `rm -rf ~/.local/state/helix` + +Verify after a rebase: + +```bash +cargo test -p helix-term --features integration test_persistence +``` + +--- + +## Rebase checklist + +- [ ] `git merge master` (or `upstream/master`) into `persistent_state` +- [ ] `helix-loader`: both `workspace_trust` + `persistence`; both `data_dir` + `state_dir` +- [ ] `Editor::new` still has `workspace_trust` **and** `old_file_locs` +- [ ] `application.rs`: load hist before new; register writes; trim jobs; **CLI pos skip** +- [ ] `prompt.rs`: push_reg_history on `:` / `/` +- [ ] `typed.rs`: `:reload-history` still in table +- [ ] `close` / `close_document`: `to_path_buf()` on paths +- [ ] `cargo check -p helix-term` +- [ ] `cargo test -p helix-term --features integration test_persistence` +- [ ] Manual: quit/reopen, history recall in `:` and `/` + +--- + +## Credits + +- Original implementation: **intarga** — [helix-editor/helix#9143](https://github.com/helix-editor/helix/pull/9143) +- Base editor fork: **gj1118/helix** +- Port + CLI position fix + these notes: **bellhyve/helix**, 2026-08 diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index e0bdf67a1859..f5c6c65f1b3d 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -21,7 +21,7 @@ helix-loader = { path = "../helix-loader" } helix-parsec = { path = "../helix-parsec" } ropey.workspace = true -smallvec = "1.15" +smallvec = { version = "1.15", features = ["serde"] } smartstring = "1.0.1" unicode-segmentation.workspace = true # unicode-width is changing width definitions @@ -35,7 +35,7 @@ slotmap.workspace = true tree-house.workspace = true once_cell = "1.21" arc-swap = "1" -regex = "1" +regex.workspace = true bitflags.workspace = true foldhash.workspace = true diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 6292e90e6175..067c0f2fa5c4 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -14,6 +14,7 @@ use crate::{ }; use helix_stdx::range::is_subset; use helix_stdx::rope::{self, RopeSliceExt}; +use serde::{Deserialize, Serialize}; use smallvec::{smallvec, SmallVec}; use std::{borrow::Cow, iter, slice}; @@ -51,7 +52,7 @@ use std::{borrow::Cow, iter, slice}; /// single grapheme inward from the range's edge. There are a /// variety of helper methods on `Range` for working in terms of /// that block cursor, all of which have `cursor` in their name. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Range { /// The anchor of the range: the side that doesn't move when extending. pub anchor: usize, @@ -413,7 +414,7 @@ impl From for helix_stdx::Range { /// A selection consists of one or more selection ranges. /// invariant: A selection can never be empty (always contains at least primary range). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Selection { ranges: SmallVec<[Range; 1]>, primary_index: usize, diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index 7a16de4d52d4..158499c5290b 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -26,6 +26,7 @@ parking_lot.workspace = true sha2 = "0.11" globset.workspace = true log = "0.4" +bincode = "1.3.3" # TODO: these two should be on !wasm32 only diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 47699196e26a..0587a1dce7e2 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod grammar; +pub mod persistence; pub mod workspace_trust; use helix_stdx::{env::current_working_dir, path}; @@ -16,6 +17,14 @@ static CONFIG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCe static LOG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); +static COMMAND_HISTFILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + +static SEARCH_HISTFILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + +static FILE_HISTFILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + +static CLIPBOARD_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + pub fn initialize_config_file(specified_file: Option) { let config_file = specified_file.unwrap_or_else(default_config_file); ensure_parent_dir(&config_file); @@ -28,6 +37,30 @@ pub fn initialize_log_file(specified_file: Option) { LOG_FILE.set(log_file).ok(); } +pub fn initialize_command_histfile(specified_file: Option) { + let command_histfile = specified_file.unwrap_or_else(default_command_histfile); + ensure_parent_dir(&command_histfile); + COMMAND_HISTFILE.set(command_histfile).ok(); +} + +pub fn initialize_search_histfile(specified_file: Option) { + let search_histfile = specified_file.unwrap_or_else(default_search_histfile); + ensure_parent_dir(&search_histfile); + SEARCH_HISTFILE.set(search_histfile).ok(); +} + +pub fn initialize_file_histfile(specified_file: Option) { + let file_histfile = specified_file.unwrap_or_else(default_file_histfile); + ensure_parent_dir(&file_histfile); + FILE_HISTFILE.set(file_histfile).ok(); +} + +pub fn initialize_clipboard_file(specified_file: Option) { + let clipboard_file = specified_file.unwrap_or_else(default_clipboard_file); + ensure_parent_dir(&clipboard_file); + CLIPBOARD_FILE.set(clipboard_file).ok(); +} + /// A list of runtime directories from highest to lowest priority /// /// The priority is: @@ -140,6 +173,22 @@ pub fn data_dir() -> PathBuf { path } +pub fn state_dir() -> PathBuf { + // TODO: allow env var override + let strategy = choose_base_strategy().expect("Unable to find the state directory!"); + match strategy.state_dir() { + Some(mut path) => { + path.push("helix"); + path + } + None => { + let mut path = strategy.cache_dir(); + path.push("helix/state"); + path + } + } +} + pub fn config_file() -> PathBuf { CONFIG_FILE.get().map(|path| path.to_path_buf()).unwrap() } @@ -148,6 +197,28 @@ pub fn log_file() -> PathBuf { LOG_FILE.get().map(|path| path.to_path_buf()).unwrap() } +pub fn command_histfile() -> PathBuf { + COMMAND_HISTFILE + .get() + .map(|path| path.to_path_buf()) + .unwrap() +} + +pub fn search_histfile() -> PathBuf { + SEARCH_HISTFILE + .get() + .map(|path| path.to_path_buf()) + .unwrap() +} + +pub fn file_histfile() -> PathBuf { + FILE_HISTFILE.get().map(|path| path.to_path_buf()).unwrap() +} + +pub fn clipboard_file() -> PathBuf { + CLIPBOARD_FILE.get().map(|path| path.to_path_buf()).unwrap() +} + pub fn workspace_config_file() -> PathBuf { find_workspace().0.join(".helix").join("config.toml") } @@ -164,6 +235,22 @@ pub fn default_log_file() -> PathBuf { cache_dir().join("helix.log") } +pub fn default_command_histfile() -> PathBuf { + state_dir().join("command_history") +} + +pub fn default_search_histfile() -> PathBuf { + state_dir().join("search_history") +} + +pub fn default_file_histfile() -> PathBuf { + state_dir().join("file_history") +} + +pub fn default_clipboard_file() -> PathBuf { + state_dir().join("clipboard") +} + /// Merge two TOML documents, merging values from `right` onto `left` /// /// `merge_depth` sets the nesting depth up to which values are merged instead diff --git a/helix-loader/src/persistence.rs b/helix-loader/src/persistence.rs new file mode 100644 index 000000000000..01997779424a --- /dev/null +++ b/helix-loader/src/persistence.rs @@ -0,0 +1,72 @@ +use bincode::{deserialize_from, serialize_into}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{File, OpenOptions}, + io::{self, BufReader}, + path::PathBuf, +}; + +pub fn write_history(filepath: PathBuf, entries: &Vec) { + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(filepath) + .unwrap(); + + for entry in entries { + serialize_into(&file, &entry).unwrap(); + } +} + +pub fn push_history(filepath: PathBuf, entry: &T) { + let file = OpenOptions::new() + .append(true) + .create(true) + .open(filepath) + .unwrap(); + + serialize_into(file, entry).unwrap(); +} + +pub fn read_history Deserialize<'a>>(filepath: &PathBuf) -> Vec { + match File::open(filepath) { + Ok(file) => { + let mut read = BufReader::new(file); + let mut entries = Vec::new(); + // FIXME: Can we do better error handling here? It's unfortunate that bincode doesn't + // distinguish an empty reader from an actual error. + // + // Perhaps we could use the underlying bufreader to check for emptiness in the while + // condition, then we could know any errors from bincode should be surfaced or logged. + // BufRead has a method `has_data_left` that would work for this, but at the time of + // writing it is nightly-only and experimental :( + while let Ok(entry) = deserialize_from(&mut read) { + entries.push(entry); + } + entries + } + Err(e) => match e.kind() { + io::ErrorKind::NotFound => Vec::new(), + // Going through the potential errors listed from the docs: + // - `InvalidInput` can't happen since we aren't setting options + // - `AlreadyExists` can't happen since we aren't setting `create_new` + // - `PermissionDenied` could happen if someone really borked their file permissions + // in `~/.local`, but helix already panics in that case, and I think a panic is + // acceptable. + _ => unreachable!(), + }, + } +} + +pub fn trim_history Deserialize<'a>>( + filepath: PathBuf, + limit: usize, +) { + let history: Vec = read_history(&filepath); + if history.len() > limit { + let trim_start = history.len() - limit; + let trimmed_history = history[trim_start..].to_vec(); + write_history(filepath, &trimmed_history); + } +} diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8e273ca6d032..9de223bab478 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -13,7 +13,7 @@ use helix_view::{ editor::{ConfigEvent, EditorEvent}, events::EditorConfigDidChange, graphics::Rect, - theme, + persistence, theme, tree::Layout, Align, Editor, }; @@ -26,13 +26,14 @@ use crate::{ config::Config, events::OnModeSwitch, handlers, - job::Jobs, + job::{Job, Jobs}, keymap::Keymaps, ui::{self, overlay::overlaid}, }; use log::{debug, error, info, warn}; use std::{ + collections::HashMap, io::{stdin, IsTerminal}, path::Path, sync::Arc, @@ -134,6 +135,16 @@ impl Application { let mut compositor = Compositor::new(area); let config = Arc::new(ArcSwap::from_pointee(config)); let handlers = handlers::setup(config.clone()); + let persistence_config = config.load().editor.persistence.clone(); + let old_file_locs = if persistence_config.old_files { + HashMap::from_iter( + persistence::read_file_history() + .into_iter() + .map(|entry| (entry.path.clone(), (entry.view_position, entry.selection))), + ) + } else { + HashMap::new() + }; let mut editor = Editor::new( area, Arc::new(theme_loader), @@ -143,7 +154,29 @@ impl Application { })), handlers, workspace_trust, + old_file_locs, ); + + // Load cross-session history into registers when enabled. + if persistence_config.commands { + editor + .registers + .write(':', persistence::read_command_history()) + .unwrap(); + } + if persistence_config.search { + editor + .registers + .write('/', persistence::read_search_history()) + .unwrap(); + } + if persistence_config.clipboard { + editor + .registers + .write('"', persistence::read_clipboard_file()) + .unwrap(); + } + Self::load_configured_theme(&mut editor, &config.load(), &mut terminal, theme_mode); let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { @@ -153,6 +186,36 @@ impl Application { compositor.push(editor_view); let jobs = Jobs::new(); + if persistence_config.old_files { + let file_trim = persistence_config.old_files_trim; + jobs.add( + Job::new(async move { + persistence::trim_file_history(file_trim); + Ok(()) + }) + .wait_before_exiting(), + ); + } + if persistence_config.commands { + let commands_trim = persistence_config.commands_trim; + jobs.add( + Job::new(async move { + persistence::trim_command_history(commands_trim); + Ok(()) + }) + .wait_before_exiting(), + ); + } + if persistence_config.search { + let search_trim = persistence_config.search_trim; + jobs.add( + Job::new(async move { + persistence::trim_search_history(search_trim); + Ok(()) + }) + .wait_before_exiting(), + ); + } if args.load_tutor { let path = helix_loader::runtime_file(Path::new("tutor")); @@ -214,15 +277,27 @@ impl Application { // NOTE: this isn't necessarily true anymore. If // `--vsplit` or `--hsplit` are used, the file which is // opened last is focused on. - let view_id = editor.tree.focus; - let doc = doc_mut!(editor, &doc_id); - let selection = pos - .into_iter() - .map(|coords| { - Range::point(pos_at_coords(doc.text().slice(..), coords, true)) - }) - .collect(); - doc.set_selection(view_id, selection); + // + // Only apply CLI positions when explicitly provided. + // `parse_file` yields Position::default() (0,0) when no + // `:line` is given; applying that would clobber + // [editor.persistence] old-files restore from open(). + let apply_cli_pos = match pos.as_slice() { + [] => false, + [p] if *p == helix_core::Position::default() => false, + _ => true, + }; + if apply_cli_pos { + let view_id = editor.tree.focus; + let doc = doc_mut!(editor, &doc_id); + let selection = pos + .into_iter() + .map(|coords| { + Range::point(pos_at_coords(doc.text().slice(..), coords, true)) + }) + .collect(); + doc.set_selection(view_id, selection); + } } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 9829f867d05c..eb0afee1ff18 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -60,6 +60,7 @@ use helix_view::{ info::Info, input::KeyEvent, keyboard::KeyCode, + persistence, theme::Style, tree::{self, Dimension, Resize}, view::View, @@ -79,12 +80,7 @@ use crate::{ use crate::job::{self, Jobs}; use std::{ - cmp::Ordering, - collections::{HashMap, HashSet}, - error::Error, - fmt, - future::Future, - io::Read, + cmp::Ordering, collections::HashSet, error::Error, fmt, future::Future, io::Read, num::NonZeroUsize, }; @@ -5633,6 +5629,10 @@ fn yank_impl(editor: &mut Editor, register: char) { .collect(); let selections = values.len(); + if editor.config().persistence.clipboard { + persistence::write_clipboard_file(&values); + } + match editor.registers.write(register, values) { Ok(_) => editor.set_status(format!( "yanked {selections} selection{} to register {register}", diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 9c5b13d1489d..db109dbb42e9 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -18,7 +18,9 @@ use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; use helix_view::expansion; use helix_view::handlers::BlameEvent; +use helix_view::persistence; use serde_json::Value; +use std::collections::HashMap; use std::sync::Arc; use ui::completers::{self, Completer}; @@ -4849,7 +4851,18 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: exclude_workspace, completer: CommandCompleter::none(), signature: Signature { positionals: (0, None), ..Signature::DEFAULT }, - } + }, + TypableCommand { + name: "reload-history", + aliases: &[], + doc: "Reload history files for persistent state", + fun: reload_history, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::DEFAULT + }, + }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> = @@ -5415,3 +5428,63 @@ fn exclude_workspace( cx.editor.config_events.0.send(ConfigEvent::Refresh)?; Ok(()) } + +fn reload_history( + cx: &mut compositor::Context, + _args: Args, + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + if cx.editor.config().persistence.old_files { + cx.editor.old_file_locs = HashMap::from_iter( + persistence::read_file_history() + .into_iter() + .map(|entry| (entry.path.clone(), (entry.view_position, entry.selection))), + ); + let file_trim = cx.editor.config().persistence.old_files_trim; + cx.jobs.add( + Job::new(async move { + persistence::trim_file_history(file_trim); + Ok(()) + }) + .wait_before_exiting(), + ); + } + if cx.editor.config().persistence.commands { + cx.editor + .registers + .write(':', persistence::read_command_history())?; + let commands_trim = cx.editor.config().persistence.commands_trim; + cx.jobs.add( + Job::new(async move { + persistence::trim_command_history(commands_trim); + Ok(()) + }) + .wait_before_exiting(), + ); + } + if cx.editor.config().persistence.search { + cx.editor + .registers + .write('/', persistence::read_search_history())?; + let search_trim = cx.editor.config().persistence.search_trim; + cx.jobs.add( + Job::new(async move { + persistence::trim_search_history(search_trim); + Ok(()) + }) + .wait_before_exiting(), + ); + } + if cx.editor.config().persistence.clipboard { + cx.editor + .registers + .write('"', persistence::read_clipboard_file())?; + } + + cx.editor.set_status("History reloaded"); + Ok(()) +} diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index bd3782ca77b6..7008b027b5e1 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -28,6 +28,10 @@ async fn main_impl() -> Result { helix_loader::initialize_config_file(args.config_file.clone()); helix_loader::initialize_log_file(args.log_file.clone()); + helix_loader::initialize_command_histfile(None); + helix_loader::initialize_search_histfile(None); + helix_loader::initialize_file_histfile(None); + helix_loader::initialize_clipboard_file(None); // Help has a higher priority and should be handled separately. if args.display_help { diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 1fce3b7c2a51..9ee474686cba 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -19,7 +19,7 @@ use helix_core::{ use helix_view::{ editor::CmdlineStyle, graphics::{CursorKind, Margin, Rect}, - Editor, + persistence, Editor, }; type PromptCharHandler = Box; @@ -799,6 +799,11 @@ impl Component for Prompt { { cx.editor.set_error(err.to_string()); } + if (cx.editor.config().persistence.commands && register == ':') + || (cx.editor.config().persistence.search && register == '/') + { + persistence::push_reg_history(register, &self.line); + } }; } diff --git a/helix-term/tests/integration.rs b/helix-term/tests/integration.rs index 59240a58da56..9df96505756f 100644 --- a/helix-term/tests/integration.rs +++ b/helix-term/tests/integration.rs @@ -19,5 +19,6 @@ mod test { mod command_line; mod commands; mod movement; + mod persistence; mod splits; } diff --git a/helix-term/tests/test/helpers.rs b/helix-term/tests/test/helpers.rs index ebe4181a57f4..6bc7acfd78b5 100644 --- a/helix-term/tests/test/helpers.rs +++ b/helix-term/tests/test/helpers.rs @@ -376,8 +376,7 @@ impl AppBuilder { ) -> Self { self.args .files - .insert(path.into(), vec![pos.unwrap_or_default()]); - + .insert(path.into(), pos.map(|p| vec![p]).unwrap_or_default()); self } diff --git a/helix-term/tests/test/persistence.rs b/helix-term/tests/test/persistence.rs new file mode 100644 index 000000000000..94cdad3cdfd6 --- /dev/null +++ b/helix-term/tests/test/persistence.rs @@ -0,0 +1,138 @@ +use super::*; +use helix_term::{config::Config, keymap}; +use helix_view::editor; +use std::{fs::File, io::Read}; +use tempfile::{NamedTempFile, TempPath}; + +fn init_persistence_files() -> anyhow::Result<(TempPath, TempPath, TempPath, TempPath)> { + let command_file = NamedTempFile::new()?; + let command_path = command_file.into_temp_path(); + helix_loader::initialize_command_histfile(Some(command_path.to_path_buf())); + + let search_file = NamedTempFile::new()?; + let search_path = search_file.into_temp_path(); + helix_loader::initialize_search_histfile(Some(search_path.to_path_buf())); + + let file_file = NamedTempFile::new()?; + let file_path = file_file.into_temp_path(); + helix_loader::initialize_file_histfile(Some(file_path.to_path_buf())); + + let clipboard_file = NamedTempFile::new()?; + let clipboard_path = clipboard_file.into_temp_path(); + helix_loader::initialize_clipboard_file(Some(clipboard_path.to_path_buf())); + + Ok((command_path, search_path, file_path, clipboard_path)) +} + +fn config_with_persistence() -> Config { + let mut editor_config = editor::Config::default(); + editor_config.persistence.old_files = true; + editor_config.persistence.commands = true; + editor_config.persistence.search = true; + editor_config.persistence.clipboard = true; + editor_config.persistence.search_trim = 3; + + Config { + theme: None, + keys: keymap::default(), + editor: editor_config, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_persistence() -> anyhow::Result<()> { + let (_, search_histfile_path, _, _) = init_persistence_files()?; + let mut file = tempfile::NamedTempFile::new()?; + + // Session 1: + // open a new file, + // add a newline, then a, + // write-quit + test_key_sequence( + &mut helpers::AppBuilder::new() + .with_config(config_with_persistence()) + .with_file(file.path(), None) + .build()?, + Some("oa:wq"), + None, + true, + ) + .await?; + + // Sanity check contents of file after first session + helpers::assert_file_has_content(&mut file, &LineFeedHandling::Native.apply("\na\n"))?; + + // Session 2: + // open same file, + // add newline, then b, + // copy the line ("b\n") + // search for "a" + // go back down to b + // use last command (write-quit) + test_key_sequence( + &mut helpers::AppBuilder::new() + .with_config(config_with_persistence()) + .with_file(file.path(), None) + .build()?, + Some("obxy/aj:"), + None, + true, + ) + .await?; + + // This verifies both that the file position was persisted (since the b is inserted after the + // a), and the command history (":" resolves to the ":wq" from session 1) + helpers::assert_file_has_content(&mut file, &LineFeedHandling::Native.apply("\na\nb\n"))?; + + // Session 3: + // open same file, + // paste + // use last search ("/a") + // append a + // search for "1", "2", and "3" in sequence. + // use last command (write-quit) + test_key_sequence( + &mut helpers::AppBuilder::new() + .with_config(config_with_persistence()) + .with_file(file.path(), None) + .build()?, + Some("p/aa/1/2/3:"), + None, + true, + ) + .await?; + + // This verifies search history was persisted ("/" resolves to "/a" from session 2), and + // the clipboard was persisted (paste pastes the "b\n" copied in session 2) + helpers::assert_file_has_content(&mut file, &LineFeedHandling::Native.apply("\naa\nb\nb\n"))?; + + // Session 4: + // open same file + // use last command (write-quit) + test_key_sequence( + &mut helpers::AppBuilder::new() + .with_config(config_with_persistence()) + .with_file(file.path(), None) + .build()?, + Some(":"), + None, + true, + ) + .await?; + + // NOTE: This time we check the search history file, instead of the edited file + let mut search_histfile = File::open(search_histfile_path)?; + let mut search_histfile_contents = String::new(); + search_histfile.read_to_string(&mut search_histfile_contents)?; + // This verifies that trimming the persistent state files is working correctly, because + // session 3 sent more searches (4: "/a", "/1", "/2", "/3") than the trim limit (3), so when + // session 4 starts, it should perform a trim, removing the oldest entry ("/a") while leaving + // the other 3 intact. + // The weird looking format of the string is because persistence data is encoded using bincode. + assert_eq!( + search_histfile_contents, + "\u{1}\0\0\0\0\0\0\01\u{1}\0\0\0\0\0\0\02\u{1}\0\0\0\0\0\0\03" + ); + + Ok(()) +} diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index f4b189b41986..f9ab0af7563a 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -55,6 +55,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml.workspace = true log = "~0.4" +serde_regex = "1.1.0" +regex.workspace = true parking_lot.workspace = true thiserror.workspace = true diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index a721c3a6aeeb..6c50c89391f4 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -9,14 +9,18 @@ use crate::{ handlers::Handlers, info::Info, input::KeyEvent, + persistence::{self, FileHistoryEntry}, + regex::EqRegex, register::Registers, theme::{self, Theme}, tree::{self, Dimension, Resize, Tree}, + view::ViewPosition, Document, DocumentId, View, ViewId, }; use helix_event::dispatch; use helix_loader::workspace_trust::{ImplicitTrustLevel, TrustQuery, WorkspaceTrust}; use helix_vcs::DiffProviderRegistry; +use regex::Regex; use futures_util::stream::select_all::SelectAll; use futures_util::StreamExt; @@ -555,6 +559,8 @@ pub struct Config { pub insecure: bool, /// Workspace-trust configuration. pub workspace_trust: WorkspaceTrustConfig, + /// Cross-session persistence of command/search history, clipboard, and file positions. + pub persistence: PersistenceConfig, } /// User-facing configuration for `[editor.workspace-trust]`. @@ -1706,6 +1712,38 @@ impl Default for CompletionHighlight { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct PersistenceConfig { + pub old_files: bool, + pub commands: bool, + pub search: bool, + pub clipboard: bool, + pub old_files_exclusions: Vec, + pub old_files_trim: usize, + pub commands_trim: usize, + pub search_trim: usize, +} + +impl Default for PersistenceConfig { + fn default() -> Self { + Self { + old_files: false, + commands: false, + search: false, + clipboard: false, + // TODO: any more defaults we should add here? + old_files_exclusions: [r".*/\.git/.*", r".*/COMMIT_EDITMSG"] + .iter() + .map(|s| Regex::new(s).unwrap().into()) + .collect(), + old_files_trim: 100, + commands_trim: 100, + search_trim: 100, + } + } +} + impl Default for Config { fn default() -> Self { Self { @@ -1791,6 +1829,7 @@ impl Default for Config { fold_textobjects: Vec::new(), insecure: false, workspace_trust: WorkspaceTrustConfig::default(), + persistence: PersistenceConfig::default(), } } } @@ -2025,6 +2064,8 @@ pub struct Editor { pub mouse_down_range: Option, pub cursor_cache: CursorCache, pub workspace_trust: WorkspaceTrust, + /// File positions restored from / written to persistent state. + pub old_file_locs: HashMap, } pub type Motion = Box; @@ -2099,6 +2140,7 @@ impl Editor { config: Arc>, handlers: Handlers, workspace_trust: WorkspaceTrust, + old_file_locs: HashMap, ) -> Self { let language_servers = helix_lsp::Registry::new(syn_loader.clone()); let conf = config.load(); @@ -2151,6 +2193,7 @@ impl Editor { cursor_cache: CursorCache::default(), dir_stack: VecDeque::with_capacity(DIR_STACK_CAP), workspace_trust, + old_file_locs, } } @@ -2957,9 +3000,11 @@ impl Editor { let path = helix_stdx::path::canonicalize(path); let id = self.document_id_by_path(&path); + let mut id_was_new = false; let id = if let Some(id) = id { id } else { + id_was_new = true; let mut doc = Document::open( &path, None, @@ -2996,16 +3041,72 @@ impl Editor { id }; + let new_doc = id_was_new; self.switch(id, action); + // Restore file position from persistent history when enabled. + // This needs to happen after switch, since switch messes with view offsets. + if new_doc + && self.config().persistence.old_files + && !self + .config() + .persistence + .old_files_exclusions + .iter() + .any(|r| r.is_match(&path.to_string_lossy())) + { + if let Some((view_position, selection)) = + self.old_file_locs.get(&path).map(|x| x.to_owned()) + { + let (view, doc) = current!(self); + + let doc_len = doc.text().len_chars(); + // Don't restore if selection goes beyond the file's end + if !selection.ranges().iter().any(|range| range.to() > doc_len) { + doc.set_view_offset(view.id, view_position); + doc.set_selection(view.id, selection); + } + } + } + Ok(id) } pub fn close(&mut self, id: ViewId) { - // Remove selections for the closed view on all documents. + let mut file_locs = Vec::new(); + for doc in self.documents_mut() { + // Persist file location history for this view + if doc.selections().contains_key(&id) { + if let Some(path) = doc.path() { + file_locs.push(FileHistoryEntry::new( + path.to_path_buf(), + doc.view_offset(id), + doc.selection(id).clone(), + )); + } + } + + // Remove selections for the closed view on all documents. doc.remove_view(id); } + + if self.config().persistence.old_files { + for loc in file_locs { + if !self + .config() + .persistence + .old_files_exclusions + .iter() + .any(|r| r.is_match(&loc.path.to_string_lossy())) + { + persistence::push_file_history(&loc); + self.old_file_locs + .insert(loc.path, (loc.view_position, loc.selection)); + } + } + } + self.tree.remove(id); self._refresh(); } @@ -3027,6 +3128,8 @@ impl Editor { ReplaceDoc(ViewId, DocumentId), } + let mut file_locs = Vec::new(); + let actions: Vec = self .tree .views_mut() @@ -3034,6 +3137,14 @@ impl Editor { view.remove_document(&doc_id); if view.doc == doc_id { + if let Some(path) = doc.path() { + file_locs.push(FileHistoryEntry::new( + path.to_path_buf(), + doc.view_offset(view.id), + doc.selection(view.id).clone(), + )); + } + // something was previously open in the view, switch to previous doc if let Some(prev_doc) = view.docs_access_history.pop() { Some(Action::ReplaceDoc(view.id, prev_doc)) @@ -3047,6 +3158,22 @@ impl Editor { }) .collect(); + if self.config().persistence.old_files { + for loc in file_locs { + if !self + .config() + .persistence + .old_files_exclusions + .iter() + .any(|r| r.is_match(&loc.path.to_string_lossy())) + { + persistence::push_file_history(&loc); + self.old_file_locs + .insert(loc.path, (loc.view_position, loc.selection)); + } + } + } + for action in actions { match action { Action::Close(view_id) => { diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index a238a5fed32d..1314613e5a2d 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -15,6 +15,8 @@ pub mod icons; pub mod info; pub mod input; pub mod keyboard; +pub mod persistence; +pub mod regex; pub mod register; pub mod theme; pub mod tree; diff --git a/helix-view/src/persistence.rs b/helix-view/src/persistence.rs new file mode 100644 index 000000000000..754b08aa4b8a --- /dev/null +++ b/helix-view/src/persistence.rs @@ -0,0 +1,81 @@ +use helix_core::Selection; +use helix_loader::{ + clipboard_file, command_histfile, file_histfile, + persistence::{push_history, read_history, trim_history, write_history}, + search_histfile, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::view::ViewPosition; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FileHistoryEntry { + pub path: PathBuf, + pub view_position: ViewPosition, + pub selection: Selection, +} + +impl FileHistoryEntry { + pub fn new(path: PathBuf, view_position: ViewPosition, selection: Selection) -> Self { + Self { + path, + view_position, + selection, + } + } +} + +pub fn push_file_history(entry: &FileHistoryEntry) { + push_history(file_histfile(), entry) +} + +pub fn read_file_history() -> Vec { + read_history(&file_histfile()) +} + +pub fn trim_file_history(limit: usize) { + trim_history::(file_histfile(), limit) +} + +pub fn push_reg_history(register: char, line: &String) { + let filepath = match register { + ':' => command_histfile(), + '/' => search_histfile(), + _ => return, + }; + + push_history(filepath, line) +} + +fn read_reg_history(filepath: PathBuf) -> Vec { + read_history(&filepath) +} + +pub fn read_command_history() -> Vec { + let mut hist = read_reg_history(command_histfile()); + hist.reverse(); + hist +} + +pub fn trim_command_history(limit: usize) { + trim_history::(command_histfile(), limit) +} + +pub fn read_search_history() -> Vec { + let mut hist = read_reg_history(search_histfile()); + hist.reverse(); + hist +} + +pub fn trim_search_history(limit: usize) { + trim_history::(search_histfile(), limit) +} + +pub fn write_clipboard_file(values: &Vec) { + write_history(clipboard_file(), values) +} + +pub fn read_clipboard_file() -> Vec { + read_history(&clipboard_file()) +} diff --git a/helix-view/src/regex.rs b/helix-view/src/regex.rs new file mode 100644 index 000000000000..8a491a3d8965 --- /dev/null +++ b/helix-view/src/regex.rs @@ -0,0 +1,37 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{ + cmp::{Eq, PartialEq}, + ops::Deref, +}; + +/// Wrapper type for regex::Regex that only exists so we can implement Eq on it, as that's needed +/// to put it in editor::Config +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EqRegex { + #[serde(with = "serde_regex")] + inner: Regex, +} + +impl From for EqRegex { + fn from(value: Regex) -> Self { + EqRegex { inner: value } + } +} + +impl Deref for EqRegex { + type Target = Regex; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl PartialEq for EqRegex { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Eq for EqRegex {} diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index 63bb6f43dee3..f8cdfdfb4b75 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -17,6 +17,7 @@ use helix_core::{ Transaction, VisualOffsetError::{PosAfterMaxRow, PosBeforeAnchorRow}, }; +use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, VecDeque}, @@ -136,7 +137,7 @@ impl JumpList { } } -#[derive(Clone, Debug, PartialEq, Eq, Copy, Default)] +#[derive(Clone, Debug, PartialEq, Eq, Copy, Default, Serialize, Deserialize)] pub struct ViewPosition { pub anchor: usize, pub horizontal_offset: usize,