diff --git a/Cargo.lock b/Cargo.lock index e6ee9d5403e4..de829a783111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -118,12 +127,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" +[[package]] +name = "bytesize" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38fcc2979eff34a4b84e1cf9a1e3da42a7d44b3b690a40cdcb23e3d556cfb2e5" + [[package]] name = "cassowary" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.79" @@ -186,6 +210,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compact_str" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5138945395949e7dfba09646dc9e766b548ff48e23deb5246890e6b64ae9e1b9" +dependencies = [ + "castaway", + "itoa", + "ryu", +] + [[package]] name = "content_inspector" version = "0.2.4" @@ -280,6 +315,19 @@ dependencies = [ "syn 1.0.104", ] +[[package]] +name = "dashmap" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" +dependencies = [ + "cfg-if", + "hashbrown 0.12.3", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dirs" version = "4.0.0" @@ -504,6 +552,508 @@ dependencies = [ "wasi", ] +[[package]] +name = "git-actor" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962399e67a7aad16be57967806405ca9e84221eccbbc1379411b869ca70b8a61" +dependencies = [ + "bstr", + "btoi", + "git-date", + "itoa", + "nom", + "quick-error", +] + +[[package]] +name = "git-attributes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d10e74ac301dbeef90061c7b43d108e040786570ad58d3b52fb04a2eed5a9a" +dependencies = [ + "bstr", + "compact_str", + "git-features", + "git-glob", + "git-path", + "git-quote", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "git-bitmap" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6065972bca2f27a2bf25dbbf81835e3077f1cfb7116392c48190bb54476124b1" +dependencies = [ + "quick-error", +] + +[[package]] +name = "git-chunk" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45742ef08ab6ce1155d878c058affafc133a7a87a09e1c329bf441e555108419" +dependencies = [ + "thiserror", +] + +[[package]] +name = "git-command" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5291c7d94b7ec2865f6eabf789c170402bc1e488421e90c9e6d7e73a91a273" +dependencies = [ + "bstr", +] + +[[package]] +name = "git-config" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cc0517781f9f573c4dc26feb3ae0cdc28ae7160a81ef104590943984f6a8e" +dependencies = [ + "bstr", + "git-config-value", + "git-features", + "git-glob", + "git-path", + "git-ref", + "git-sec", + "memchr", + "nom", + "once_cell", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "git-config-value" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddda666018cac6e20b5a74e9bb371060f5595083a3302f6fa2153e28b33e1455" +dependencies = [ + "bitflags 1.3.2", + "bstr", + "git-path", + "libc", + "thiserror", +] + +[[package]] +name = "git-credentials" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca4dc00e69972eba9bb7323bf79501347219328f3d863bbc7d36035008c49c4" +dependencies = [ + "bstr", + "git-command", + "git-config-value", + "git-path", + "git-prompt", + "git-sec", + "git-url", + "thiserror", +] + +[[package]] +name = "git-date" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af620495c87416854d47817c93af0fc97971a0580aa5c8bc688ca90e663eaef" +dependencies = [ + "bstr", + "itoa", + "thiserror", + "time", +] + +[[package]] +name = "git-diff" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0efd2c1b66f683a8a59d4612dd990637529055660c2999b47c7543d4c1a1b" +dependencies = [ + "git-hash", + "git-object", + "imara-diff", + "thiserror", +] + +[[package]] +name = "git-discover" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2738a9941f1411cff31e6ea4399a6c7304cc3ea34fb8c1c6f1aef1f667d46cc" +dependencies = [ + "bstr", + "git-hash", + "git-path", + "git-ref", + "git-sec", + "thiserror", +] + +[[package]] +name = "git-features" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64be6a1e602760c2c83aac3d05553c90805748bba9cb0f0e944d66b0f85cea0d" +dependencies = [ + "crc32fast", + "flate2", + "git-hash", + "libc", + "once_cell", + "prodash", + "quick-error", + "sha1_smol", + "walkdir", +] + +[[package]] +name = "git-glob" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3018b16df92d0ae605bab756b096a828a6f4f76c71bae789eea76652d5a42cec" +dependencies = [ + "bitflags 1.3.2", + "bstr", +] + +[[package]] +name = "git-hash" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4af641a41fdb4b1d5c2be9783cd8ffcd4e22a6ad41581c1f0dc3e882000585" +dependencies = [ + "hex", + "thiserror", +] + +[[package]] +name = "git-hashtable" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b312098de215ab298d2f198ae376c63a3ebaa0ca86a84794d5c7dd69fbfeff14" +dependencies = [ + "git-hash", + "hashbrown 0.13.2", +] + +[[package]] +name = "git-index" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49462dc6957cdf75c6b3e680929ed9b08c85320d09a998e5628b45dc3f2cf495" +dependencies = [ + "atoi", + "bitflags 1.3.2", + "bstr", + "filetime", + "git-bitmap", + "git-features", + "git-hash", + "git-lock", + "git-object", + "git-traverse", + "itoa", + "memmap2", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-lock" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc6191258a396557ff9bc3349aedc32e5c42e91cac0898bd63674a775a10276" +dependencies = [ + "fastrand", + "git-tempfile", + "quick-error", +] + +[[package]] +name = "git-mailmap" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81194c04162bcb54d592c78a229cf6b917935e99d77a5e305ffcc03699a619b3" +dependencies = [ + "bstr", + "git-actor", + "quick-error", +] + +[[package]] +name = "git-object" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba51b9c619d9bcf49cff288bdee2f9f43ac59c56cb03fbeecfb89f5a14ab1a7d" +dependencies = [ + "bstr", + "btoi", + "git-actor", + "git-features", + "git-hash", + "git-validate", + "hex", + "itoa", + "nom", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-odb" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db04fc5f58a0255af86347e2fc9a239668774a6ee8426d885d4c531ec8a92fce" +dependencies = [ + "arc-swap", + "git-features", + "git-hash", + "git-object", + "git-pack", + "git-path", + "git-quote", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "git-pack" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75e30de8be2920d0bac069cb937fbfd37848c8b125e157d0a0b5363fc8585fc" +dependencies = [ + "bytesize", + "clru", + "dashmap", + "git-chunk", + "git-diff", + "git-features", + "git-hash", + "git-hashtable", + "git-object", + "git-path", + "git-tempfile", + "git-traverse", + "memmap2", + "parking_lot", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43803d7636b05fa395e019c696f67cd39915029f6505a7e710bc372b21dc397c" +dependencies = [ + "bstr", + "thiserror", +] + +[[package]] +name = "git-prompt" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086bf15aeb3d06e1559fb723b2bd41f572b79c3714a7eefc207287b2af369fe1" +dependencies = [ + "git-command", + "git-config-value", + "nix", + "parking_lot", + "thiserror", +] + +[[package]] +name = "git-quote" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a83125617ff30ddc1d28169f60b39e5953a77a5df6fb23383c4248e54d501fc7" +dependencies = [ + "bstr", + "btoi", + "quick-error", +] + +[[package]] +name = "git-ref" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2c29bab109acaf626d49a54f1f85ab7f0911268fbf62c2b39680ef4ef19069" +dependencies = [ + "git-actor", + "git-features", + "git-hash", + "git-lock", + "git-object", + "git-path", + "git-tempfile", + "git-validate", + "memmap2", + "nom", + "thiserror", +] + +[[package]] +name = "git-refspec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e985199f3ada4a0fdbd3e8cfd23db3508daeb54756c3507566b97ad392789d" +dependencies = [ + "bstr", + "git-hash", + "git-revision", + "git-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-repository" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993277960cb7e2d3991a11c1ec6951c1d142de052c26a18d2db64304e52d3741" +dependencies = [ + "git-actor", + "git-attributes", + "git-config", + "git-credentials", + "git-date", + "git-diff", + "git-discover", + "git-features", + "git-glob", + "git-hash", + "git-hashtable", + "git-index", + "git-lock", + "git-mailmap", + "git-object", + "git-odb", + "git-pack", + "git-path", + "git-prompt", + "git-ref", + "git-refspec", + "git-revision", + "git-sec", + "git-tempfile", + "git-traverse", + "git-url", + "git-validate", + "git-worktree", + "log", + "once_cell", + "prodash", + "signal-hook", + "smallvec", + "thiserror", + "unicode-normalization", +] + +[[package]] +name = "git-revision" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76143773a770f231615768c1cafbda6ab5ee9c05196c534a3ded14821340cf08" +dependencies = [ + "bstr", + "git-date", + "git-hash", + "git-hashtable", + "git-object", + "thiserror", +] + +[[package]] +name = "git-sec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc383cff3720ce73bd00e7d1d5076f37d5ed662fffcb9e333d9d4f87d1c8bfae" +dependencies = [ + "bitflags 1.3.2", + "dirs", + "git-path", + "libc", + "windows", +] + +[[package]] +name = "git-tempfile" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48a308d2630132831d4ecb7e46558f56ff64a501b00a0cde158ab46080e929f7" +dependencies = [ + "dashmap", + "libc", + "once_cell", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "git-traverse" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf75ba4601da381a707e2d3980e13ba8714a0d6403a9cc28d45d774461f3fede" +dependencies = [ + "git-hash", + "git-hashtable", + "git-object", + "thiserror", +] + +[[package]] +name = "git-url" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d329ad9772efd73b54830c18c67e861b32a95f4ab5478e39803cbddfe5565f" +dependencies = [ + "bstr", + "git-features", + "git-path", + "home", + "thiserror", + "url", +] + +[[package]] +name = "git-validate" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390195f4569880a6b1c6be5172a37c32fe8b9d6f039c45e8dd84ecf4148e3f8b" +dependencies = [ + "bstr", + "thiserror", +] + +[[package]] +name = "git-worktree" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92459b2194cd0c6982c267bdb06ac9f6872dd58adcfc98bb5488ee0eae458bd" +dependencies = [ + "bstr", + "git-attributes", + "git-features", + "git-glob", + "git-hash", + "git-index", + "git-object", + "git-path", + "io-close", + "thiserror", +] + [[package]] name = "gix" version = "0.41.0" @@ -1079,6 +1629,7 @@ name = "helix-core" version = "0.6.0" dependencies = [ "ahash 0.8.3", + "anyhow", "arc-swap", "bitflags 2.0.2", "chrono", @@ -1095,9 +1646,11 @@ dependencies = [ "ropey", "serde", "serde_json", + "sha1_smol", "slotmap", "smallvec", "smartstring", + "tempfile", "textwrap", "toml", "tree-sitter", @@ -1223,6 +1776,7 @@ version = "0.6.0" dependencies = [ "anyhow", "arc-swap", + "git-repository", "gix", "helix-core", "imara-diff", @@ -1253,9 +1807,12 @@ dependencies = [ "log", "once_cell", "parking_lot", + "quickcheck", + "rand", "serde", "serde_json", "slotmap", + "tempfile", "tokio", "tokio-stream", "toml", @@ -1287,6 +1844,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "human_format" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86cce260d758a9aa3d7c4b99d55c815a540f8a37514ba6046ab6be402a157cb0" + [[package]] name = "iana-time-zone" version = "0.1.53" @@ -1626,6 +2189,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro2" version = "1.0.52" @@ -1640,6 +2209,11 @@ name = "prodash" version = "23.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d73c6b64cb5b99eb63ca97d378685712617ec0172ff5c04cd47a489d3e2c51f8" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] [[package]] name = "pulldown-cmark" @@ -1652,6 +2226,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quickcheck" version = "1.0.3" @@ -1676,6 +2256,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -1755,6 +2347,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + [[package]] name = "ryu" version = "1.0.11" diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 9dfef9ae20ec..926d5c5468ee 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -12,11 +12,12 @@ include = ["src/**/*", "README.md"] [features] unicode-lines = ["ropey/unicode_lines"] -integration = [] +integration = ["helix-loader/integration"] [dependencies] helix-loader = { version = "0.6", path = "../helix-loader" } +anyhow = "1.0" ropey = { version = "1.6.0", default-features = false, features = ["simd"] } smallvec = "1.10" smartstring = "1.0.1" @@ -33,6 +34,8 @@ bitflags = "2.0" ahash = "0.8.3" hashbrown = { version = "0.13.2", features = ["raw"] } +sha1_smol = "1.0" + log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -50,3 +53,4 @@ textwrap = "0.16.0" [dev-dependencies] quickcheck = { version = "1", default-features = false } indoc = "2.0.1" +tempfile = "3.3.0" diff --git a/helix-core/src/history.rs b/helix-core/src/history.rs index 1aac38d934c7..a1d51ec086c3 100644 --- a/helix-core/src/history.rs +++ b/helix-core/src/history.rs @@ -1,7 +1,11 @@ +use crate::parse::*; use crate::{Assoc, ChangeSet, Range, Rope, Selection, Transaction}; use once_cell::sync::Lazy; use regex::Regex; +use std::io::{Read, Seek, SeekFrom, Write}; use std::num::NonZeroUsize; +use std::path::Path; +use std::sync::Arc; use std::time::{Duration, Instant}; #[derive(Debug, Clone)] @@ -47,7 +51,7 @@ pub struct State { /// delete, we also store an inversion of the transaction. /// /// Using time to navigate the history: -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct History { revisions: Vec, current: usize, @@ -58,13 +62,22 @@ pub struct History { struct Revision { parent: usize, last_child: Option, - transaction: Transaction, + transaction: Arc, // We need an inversion for undos because delete transactions don't store // the deleted text. - inversion: Transaction, + inversion: Arc, timestamp: Instant, } +impl PartialEq for Revision { + fn eq(&self, other: &Self) -> bool { + self.parent == other.parent + && self.last_child == other.last_child + && self.transaction == other.transaction + && self.inversion == other.inversion + } +} + impl Default for History { fn default() -> Self { // Add a dummy root revision with empty transaction @@ -72,8 +85,8 @@ impl Default for History { revisions: vec![Revision { parent: 0, last_child: None, - transaction: Transaction::from(ChangeSet::new(&Rope::new())), - inversion: Transaction::from(ChangeSet::new(&Rope::new())), + transaction: Arc::new(Transaction::from(ChangeSet::new(&Rope::new()))), + inversion: Arc::new(Transaction::from(ChangeSet::new(&Rope::new()))), timestamp: Instant::now(), }], current: 0, @@ -81,6 +94,196 @@ impl Default for History { } } +impl Revision { + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + // `timestamp` is ignored since `Instant`s can't be serialized. + write_usize(writer, self.parent)?; + self.transaction.serialize(writer)?; + self.inversion.serialize(writer)?; + + Ok(()) + } + + fn deserialize(reader: &mut R, timestamp: Instant) -> std::io::Result { + let parent = read_usize(reader)?; + let transaction = Arc::new(Transaction::deserialize(reader)?); + let inversion = Arc::new(Transaction::deserialize(reader)?); + Ok(Revision { + parent, + last_child: None, + transaction, + inversion, + timestamp, + }) + } +} + +const HEADER_TAG: &str = "Helix Undofile 1\n"; + +fn get_hash(reader: &mut R) -> std::io::Result<[u8; 20]> { + const BUF_SIZE: usize = 8192; + + let mut buf = [0u8; BUF_SIZE]; + let mut hash = sha1_smol::Sha1::new(); + loop { + let total_read = reader.read(&mut buf)?; + if total_read == 0 { + break; + } + + hash.update(&buf[0..total_read]); + } + Ok(hash.digest().bytes()) +} + +#[derive(Debug)] +pub enum StateError { + Outdated, + InvalidHeader, + InvalidOffset, + InvalidData(String), +} + +impl std::fmt::Display for StateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Outdated => f.write_str("Outdated file"), + Self::InvalidHeader => f.write_str("Invalid undofile header"), + Self::InvalidOffset => f.write_str("Invalid merge offset"), + Self::InvalidData(msg) => f.write_str(msg), + } + } +} + +impl std::error::Error for StateError {} + +impl History { + pub fn serialize( + &self, + writer: &mut W, + path: &Path, + revision: usize, + last_saved_revision: usize, + ) -> std::io::Result<()> { + // Header + let mtime = std::fs::metadata(path)? + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + write_string(writer, HEADER_TAG)?; + write_usize(writer, self.current)?; + write_usize(writer, revision)?; + write_u64(writer, mtime)?; + writer.write_all(&get_hash(&mut std::fs::File::open(path)?)?)?; + + // Append new revisions to the end of the file. + write_usize(writer, self.revisions.len())?; + writer.seek(SeekFrom::End(0))?; + for rev in &self.revisions[last_saved_revision..] { + rev.serialize(writer)?; + } + Ok(()) + } + + /// Returns the deserialized [`History`] and the last_saved_revision. + pub fn deserialize(reader: &mut R, path: &Path) -> anyhow::Result<(usize, Self)> { + let (current, last_saved_revision) = Self::read_header(reader, path)?; + + // Since `timestamp` can't be serialized, a new timestamp is created. + let timestamp = Instant::now(); + + // Read the revisions and construct the tree. + let len = read_usize(reader)?; + let mut revisions: Vec = Vec::with_capacity(len); + for _ in 0..len { + let res = Revision::deserialize(reader, timestamp)?; + let len = revisions.len(); + match revisions.get_mut(res.parent) { + Some(r) => r.last_child = NonZeroUsize::new(len), + None if len != 0 => { + anyhow::bail!(StateError::InvalidData(format!( + "non-contiguous history: {} >= {}", + res.parent, len + ))); + } + None => {} + } + revisions.push(res); + } + + let history = History { current, revisions }; + Ok((last_saved_revision, history)) + } + + /// If two histories originate from: `A -> B (B is head)` but have deviated since then such that + /// the first history is: `A -> B -> C -> D (D is head)` and the second one is: + /// `A -> B -> E -> F (F is head)`. + /// Then they are merged into + /// ```md + /// A -> B -> C -> D + /// \ + /// E -> F + /// ``` + /// and retain their revision heads. + pub fn merge(&mut self, mut other: History, offset: usize) -> anyhow::Result<()> { + if !self + .revisions + .iter() + .zip(other.revisions.iter()) + .take(offset) + .all(|(a, b)| { + a.parent == b.parent && a.transaction == b.transaction && a.inversion == b.inversion + }) + { + anyhow::bail!(StateError::InvalidOffset); + } + + let revisions = self.revisions.split_off(offset); + let len = other.revisions.len(); + other.revisions.reserve_exact(revisions.len()); + + for r in revisions { + // parent is 0-indexed, while offset is +1. + let parent = if r.parent < offset { + r.parent + } else { + len + (r.parent - offset) + }; + debug_assert!(parent < other.revisions.len()); + + other.revisions.get_mut(parent).unwrap().last_child = + NonZeroUsize::new(other.revisions.len()); + other.revisions.push(r); + } + self.revisions = other.revisions; + Ok(()) + } + + pub fn read_header(reader: &mut R, path: &Path) -> anyhow::Result<(usize, usize)> { + let header = read_string(reader)?; + if HEADER_TAG != header { + Err(anyhow::anyhow!(StateError::InvalidHeader)) + } else { + let current = read_usize(reader)?; + let last_saved_revision = read_usize(reader)?; + let mtime = read_u64(reader)?; + let last_mtime = std::fs::metadata(path)? + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hash = [0u8; 20]; + reader.read_exact(&mut hash)?; + + if mtime != last_mtime && hash != get_hash(&mut std::fs::File::open(path)?)? { + anyhow::bail!(StateError::Outdated); + } + Ok((current, last_saved_revision)) + } + } +} + impl History { pub fn commit_revision(&mut self, transaction: &Transaction, original: &State) { self.commit_revision_at_timestamp(transaction, original, Instant::now()); @@ -92,17 +295,19 @@ impl History { original: &State, timestamp: Instant, ) { - let inversion = transaction - .invert(&original.doc) - // Store the current cursor position - .with_selection(original.selection.clone()); + let inversion = Arc::new( + transaction + .invert(&original.doc) + // Store the current cursor position + .with_selection(original.selection.clone()), + ); let new_current = self.revisions.len(); self.revisions[self.current].last_child = NonZeroUsize::new(new_current); self.revisions.push(Revision { parent: self.current, last_child: None, - transaction: transaction.clone(), + transaction: Arc::new(transaction.clone()), inversion, timestamp, }); @@ -119,6 +324,11 @@ impl History { self.current == 0 } + #[inline] + pub fn is_empty(&self) -> bool { + self.revisions.len() <= 1 + } + /// Returns the changes since the given revision composed into a transaction. /// Returns None if there are no changes between the current and given revisions. pub fn changes_since(&self, revision: usize) -> Option { @@ -128,8 +338,10 @@ impl History { let up_txns = up .iter() .rev() - .map(|&n| self.revisions[n].inversion.clone()); - let down_txns = down.iter().map(|&n| self.revisions[n].transaction.clone()); + .map(|&n| self.revisions[n].inversion.as_ref().clone()); + let down_txns = down + .iter() + .map(|&n| self.revisions[n].transaction.as_ref().clone()); down_txns.chain(up_txns).reduce(|acc, tx| tx.compose(acc)) } @@ -215,11 +427,13 @@ impl History { let up = self.path_up(self.current, lca); let down = self.path_up(to, lca); self.current = to; - let up_txns = up.iter().map(|&n| self.revisions[n].inversion.clone()); + let up_txns = up + .iter() + .map(|&n| self.revisions[n].inversion.as_ref().clone()); let down_txns = down .iter() .rev() - .map(|&n| self.revisions[n].transaction.clone()); + .map(|&n| self.revisions[n].transaction.as_ref().clone()); up_txns.chain(down_txns).collect() } @@ -386,6 +600,10 @@ impl std::str::FromStr for UndoKind { #[cfg(test)] mod test { + use std::io::Cursor; + + use quickcheck::quickcheck; + use super::*; use crate::Selection; @@ -630,4 +848,76 @@ mod test { Err("duration too large".to_string()) ); } + + #[test] + fn merge_history() { + let file = tempfile::NamedTempFile::new().unwrap(); + let mut undo = Cursor::new(Vec::new()); + let mut history_1 = History::default(); + let mut history_2 = History::default(); + + let state = State { + doc: Rope::new(), + selection: Selection::point(0), + }; + let tx = Transaction::change( + &Rope::new(), + [(0, 0, Some("Hello, world!".into()))].into_iter(), + ); + history_1.commit_revision(&tx, &state); + history_1.serialize(&mut undo, file.path(), 0, 0).unwrap(); + undo.seek(SeekFrom::Start(0)).unwrap(); + + let saved_history = History::deserialize(&mut undo, file.path()).unwrap().1; + let err = format!( + "{:#?} vs. {:#?}", + history_2.revisions, saved_history.revisions + ); + history_2.merge(saved_history, 1).expect(&err); + + assert_eq!(history_1.revisions, history_2.revisions); + } + + quickcheck!( + fn serde_history(original: String, changes_a: Vec, changes_b: Vec) -> bool { + // Constructs a set of transactions and applies them to the history. + fn create_changes(history: &mut History, doc: &mut Rope, changes: Vec) { + for c in changes.into_iter().map(Rope::from) { + let transaction = crate::diff::compare_ropes(doc, &c); + let state = State { + doc: doc.clone(), + selection: Selection::point(0), + }; + history.commit_revision(&transaction, &state); + *doc = c; + } + } + + let mut history = History::default(); + let mut original = Rope::from(original); + + create_changes(&mut history, &mut original, changes_a); + let mut cursor = Cursor::new(Vec::new()); + let file = tempfile::NamedTempFile::new().unwrap(); + history.serialize(&mut cursor, file.path(), 0, 0).unwrap(); + cursor.set_position(0); + + // Check if the original and deserialized history match. + let (_, res) = History::deserialize(&mut cursor, file.path()).unwrap(); + assert_eq!(history, res); + + let last_saved_revision = history.revisions.len(); + + cursor.set_position(0); + create_changes(&mut history, &mut original, changes_b); + history + .serialize(&mut cursor, file.path(), 0, last_saved_revision) + .unwrap(); + cursor.set_position(0); + + // Check if they are the same after appending new changes. + let (_, res) = History::deserialize(&mut cursor, file.path()).unwrap(); + history == res + } + ); } diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index b67e2c8a38e2..98af4a055227 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod macros; pub mod match_brackets; pub mod movement; pub mod object; +pub mod parse; pub mod path; mod position; pub mod register; diff --git a/helix-core/src/parse.rs b/helix-core/src/parse.rs new file mode 100644 index 000000000000..42cf266d2190 --- /dev/null +++ b/helix-core/src/parse.rs @@ -0,0 +1,128 @@ +use std::io::Error; +use std::io::ErrorKind; +use std::io::Read; +use std::io::Result; +use std::io::Write; + +pub fn write_byte(writer: &mut W, byte: u8) -> Result<()> { + writer.write_all(&[byte])?; + Ok(()) +} + +pub fn write_bool(writer: &mut W, state: bool) -> Result<()> { + write_byte(writer, state as u8) +} + +pub fn write_u32(writer: &mut W, n: u32) -> Result<()> { + writer.write_all(&n.to_ne_bytes())?; + Ok(()) +} + +pub fn write_u64(writer: &mut W, n: u64) -> Result<()> { + writer.write_all(&n.to_ne_bytes())?; + Ok(()) +} + +pub fn write_usize(writer: &mut W, n: usize) -> Result<()> { + writer.write_all(&n.to_ne_bytes())?; + Ok(()) +} + +pub fn write_string(writer: &mut W, s: &str) -> Result<()> { + write_usize(writer, s.len())?; + writer.write_all(s.as_bytes())?; + Ok(()) +} + +pub fn write_vec( + writer: &mut W, + slice: &[T], + f: impl Fn(&mut W, &T) -> Result<()>, +) -> Result<()> { + write_usize(writer, slice.len())?; + for element in slice { + f(writer, element)?; + } + Ok(()) +} + +pub fn write_option( + writer: &mut W, + value: Option, + f: impl Fn(&mut W, T) -> Result<()>, +) -> Result<()> { + write_bool(writer, value.is_some())?; + if let Some(value) = value { + f(writer, value)?; + } + Ok(()) +} + +pub fn read_byte(reader: &mut R) -> Result { + match reader.bytes().next() { + Some(s) => s, + None => Err(Error::from(ErrorKind::UnexpectedEof)), + } +} + +pub fn read_bool(reader: &mut R) -> Result { + let res = match read_byte(reader)? { + 0 => false, + 1 => true, + _ => { + return Err(Error::new( + ErrorKind::Other, + "invalid byte to bool conversion", + )) + } + }; + Ok(res) +} + +pub fn read_u32(reader: &mut R) -> Result { + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + Ok(u32::from_ne_bytes(buf)) +} + +pub fn read_u64(reader: &mut R) -> Result { + let mut buf = [0u8; 8]; + reader.read_exact(&mut buf)?; + Ok(u64::from_ne_bytes(buf)) +} + +pub fn read_usize(reader: &mut R) -> Result { + let mut buf = [0u8; 8]; + reader.read_exact(&mut buf)?; + Ok(usize::from_ne_bytes(buf)) +} + +pub fn read_string(reader: &mut R) -> Result { + let len = read_usize(reader)?; + let mut buf = vec![0; len]; + reader.read_exact(&mut buf)?; + + let res = String::from_utf8(buf).map_err(|e| Error::new(ErrorKind::InvalidData, e))?; + Ok(res) +} + +pub fn read_vec(reader: &mut R, f: impl Fn(&mut R) -> Result) -> Result> { + let len = read_usize(reader)?; + let mut res = Vec::with_capacity(len); + for _ in 0..len { + res.push(f(reader)?); + } + Ok(res) +} + +pub fn read_option( + reader: &mut R, + f: impl Fn(&mut R) -> Result, +) -> Result> { + let res = if read_bool(reader)? { + Some(f(reader)?) + } else { + None + }; + Ok(res) +} diff --git a/helix-core/src/path.rs b/helix-core/src/path.rs index d59a6baad604..a21cc69335b3 100644 --- a/helix-core/src/path.rs +++ b/helix-core/src/path.rs @@ -1,5 +1,8 @@ use etcetera::home_dir; -use std::path::{Component, Path, PathBuf}; +use std::{ + path::{Component, Path, PathBuf}, + str::Utf8Error, +}; /// Replaces users home directory from `path` with tilde `~` if the directory /// is available, otherwise returns the path unchanged. @@ -141,3 +144,42 @@ pub fn get_truncated_path>(path: P) -> PathBuf { ret.push(file); ret } + +pub fn os_str_as_bytes>(path: P) -> Vec { + let path = path.as_ref(); + + #[cfg(windows)] + return path.to_str().unwrap().into(); + + #[cfg(unix)] + return std::os::unix::ffi::OsStrExt::as_bytes(path).to_vec(); +} + +pub fn path_from_bytes(slice: &[u8]) -> Result { + #[cfg(windows)] + return Ok(PathBuf::from(std::str::from_utf8(slice)?)); + + #[cfg(unix)] + return Ok(PathBuf::from( + ::from_bytes(slice), + )); +} + +pub fn is_sep_byte(b: u8) -> bool { + if cfg!(windows) { + b == b'/' || b == b'\\' + } else { + b == b'/' + } +} + +pub fn escape_path(path: &Path) -> PathBuf { + let s = path.as_os_str().to_os_string(); + let mut bytes = os_str_as_bytes(&s); + for b in bytes.iter_mut() { + if is_sep_byte(*b) { + *b = b'%'; + } + } + path_from_bytes(&bytes).unwrap() +} diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 8e93c633e4ad..811dc5eb9b30 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -389,8 +389,8 @@ impl From<(usize, usize)> for Range { /// invariant: A selection can never be empty (always contains at least primary range). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Selection { - ranges: SmallVec<[Range; 1]>, - primary_index: usize, + pub(crate) ranges: SmallVec<[Range; 1]>, + pub(crate) primary_index: usize, } #[allow(clippy::len_without_is_empty)] // a Selection is never empty diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index d8e581aae12f..149f7bc77509 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -1,8 +1,11 @@ use smallvec::SmallVec; +use crate::parse::*; use crate::{Range, Rope, Selection, Tendril}; -use std::borrow::Cow; - +use std::{ + borrow::Cow, + io::{Read, Write}, +}; /// (from, to, replacement) pub type Change = (usize, usize, Option); @@ -590,6 +593,95 @@ impl Transaction { pub fn changes_iter(&self) -> ChangeIterator { self.changes.changes_iter() } + + pub fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + write_option(writer, self.selection.as_ref(), |writer, selection| { + write_usize(writer, selection.primary_index)?; + write_vec(writer, selection.ranges(), |writer, range| { + write_usize(writer, range.anchor)?; + write_usize(writer, range.head)?; + write_option(writer, range.old_visual_position.as_ref(), |writer, pos| { + write_u32(writer, pos.0)?; + write_u32(writer, pos.1)?; + Ok(()) + })?; + Ok(()) + })?; + + Ok(()) + })?; + + write_usize(writer, self.changes.len)?; + write_usize(writer, self.changes.len_after)?; + write_vec(writer, self.changes.changes(), |writer, operation| { + let variant = match operation { + Operation::Retain(_) => 0, + Operation::Delete(_) => 1, + Operation::Insert(_) => 2, + }; + write_byte(writer, variant)?; + match operation { + Operation::Retain(n) | Operation::Delete(n) => { + write_usize(writer, *n)?; + } + + Operation::Insert(tendril) => { + write_string(writer, tendril.as_str())?; + } + } + + Ok(()) + })?; + + Ok(()) + } + + pub fn deserialize(reader: &mut R) -> std::io::Result { + let selection = read_option(reader, |reader| { + let primary_index = read_usize(reader)?; + let ranges = read_vec(reader, |reader| { + let anchor = read_usize(reader)?; + let head = read_usize(reader)?; + let old_visual_position = read_option(reader, |reader| { + let res = (read_u32(reader)?, read_u32(reader)?); + Ok(res) + })?; + Ok(Range { + anchor, + head, + old_visual_position, + }) + })?; + Ok(Selection { + ranges: ranges.into(), + primary_index, + }) + })?; + + let len = read_usize(reader)?; + let len_after = read_usize(reader)?; + let changes = read_vec(reader, |reader| { + let res = match read_byte(reader)? { + 0 => Operation::Retain(read_usize(reader)?), + 1 => Operation::Delete(read_usize(reader)?), + 2 => Operation::Insert(read_string(reader)?.into()), + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "invalid variant", + )) + } + }; + Ok(res) + })?; + let changes = ChangeSet { + changes, + len, + len_after, + }; + + Ok(Transaction { changes, selection }) + } } impl From for Transaction { diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index 9225ad1a2235..36f1532d20d4 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -13,6 +13,9 @@ homepage = "https://helix-editor.com" name = "hx-loader" path = "src/main.rs" +[features] +integration = [] + [dependencies] anyhow = "1" serde = { version = "1.0", features = ["derive"] } diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 6c7169758df0..f060d2c4c9d3 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -114,9 +114,13 @@ pub fn config_dir() -> PathBuf { } pub fn cache_dir() -> PathBuf { - // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); - let mut path = strategy.cache_dir(); + let mut path = if cfg!(feature = "integration") || cfg!(test) { + std::env::temp_dir() + } else { + // TODO: allow env var override + let strategy = choose_base_strategy().expect("Unable to find the config directory!"); + strategy.cache_dir() + }; path.push("helix"); path } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 4d903eec4cda..6da54fa55e00 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -360,6 +360,21 @@ impl Application { // the Application can apply it. ConfigEvent::Update(editor_config) => { let mut app_config = (*self.config.load().clone()).clone(); + if !self.config.load().editor.persistent_undo && editor_config.persistent_undo { + for doc in self.editor.documents_mut() { + // HAXX: Do this so all revisions in this doc are treated as new. + let lsr = doc.get_last_saved_revision(); + doc.set_last_saved_revision(0); + if let Err(e) = doc.load_history() { + doc.set_last_saved_revision(lsr); + log::error!( + "failed to reload history for {}: {e}", + doc.path().unwrap().to_string_lossy() + ); + return; + } + } + } app_config.editor = *editor_config; self.config.store(Arc::new(app_config)); } @@ -414,6 +429,19 @@ impl Application { let mut refresh_config = || -> Result<(), Error> { let default_config = Config::load_default() .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; + + // Merge histories of existing docs if persistent undo was enabled. + if !self.config.load().editor.persistent_undo && default_config.editor.persistent_undo { + for doc in self.editor.documents_mut() { + // HAXX: Do this so all revisions in this doc are treated as new. + let lsr = doc.get_last_saved_revision(); + doc.set_last_saved_revision(0); + if let Err(e) = doc.load_history() { + doc.set_last_saved_revision(lsr); + return Err(e); + } + } + } self.refresh_language_config()?; self.refresh_theme(&default_config)?; // Store new config @@ -500,7 +528,7 @@ impl Application { } } - pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) { + pub async fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) { let doc_save_event = match doc_save_event { Ok(event) => event, Err(err) => { @@ -560,6 +588,9 @@ impl Application { lines, bytes )); + if doc_save_event.serialize_error { + self.editor.set_error("failed to serialize history"); + } } #[inline(always)] @@ -568,7 +599,7 @@ impl Application { match event { EditorEvent::DocumentSaved(event) => { - self.handle_document_write(event); + self.handle_document_write(event).await; self.render().await; } EditorEvent::ConfigEvent(event) => { diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index ca55151add5f..cd94d743a535 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1220,7 +1220,8 @@ fn reload( doc.reload(view, &cx.editor.diff_providers, redraw_handle) .map(|_| { view.ensure_cursor_in_view(doc, scrolloff); - }) + })?; + Ok(()) } fn reload_all( diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index 342a849be349..eac787f00334 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -382,6 +382,32 @@ async fn test_character_info() -> anyhow::Result<()> { false, ) .await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_persistent_undo() -> anyhow::Result<()> { + let file = tempfile::NamedTempFile::new()?; + let mut config = Config::default(); + config.editor.persistent_undo = true; + let mut app = helpers::AppBuilder::new() + .with_config(config) + .with_file(file.path(), None) + .build()?; + + // TODO: Test if the history file is valid. + test_key_sequence( + &mut app, + Some(&format!( + "ihello:w:bc!:o {}", + file.path().to_string_lossy() + )), + Some(&|app| { + assert!(!app.editor.is_err()); + }), + false, + ) + .await?; Ok(()) } diff --git a/helix-vcs/Cargo.toml b/helix-vcs/Cargo.toml index b32c028bdb70..d8824090a491 100644 --- a/helix-vcs/Cargo.toml +++ b/helix-vcs/Cargo.toml @@ -15,6 +15,7 @@ helix-core = { version = "0.6", path = "../helix-core" } tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] } parking_lot = "0.12" +git-repository = { version = "0.32", default-features = false, optional = true } arc-swap = { version = "1.6.0" } gix = { version = "0.41.0", default-features = false , optional = true } diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 4f7b08edd191..128b850f9481 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -53,4 +53,7 @@ clipboard-win = { version = "4.5", features = ["std"] } libc = "0.2" [dev-dependencies] +quickcheck = { version = "1", default-features = false } +tempfile = "3" +rand = "0.8" helix-tui = { path = "../helix-tui" } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index eca6002653f5..670eb3bb14e1 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -23,6 +23,7 @@ use std::rc::Rc; use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::SystemTime; +use tokio::fs::OpenOptions; use helix_core::{ encoding, @@ -103,6 +104,7 @@ pub struct DocumentSavedEvent { pub doc_id: DocumentId, pub path: PathBuf, pub text: Rope, + pub serialize_error: bool, } pub type DocumentSavedEventResult = Result; @@ -687,7 +689,14 @@ impl Document { let encoding = self.encoding; let last_saved_time = self.last_saved_time; - + let history = self + .config + .load() + .persistent_undo + .then(|| self.history.get_mut().clone()) + .filter(|history| !history.is_empty()); + let undofile_path = self.undo_file(Some(&path))?.unwrap(); + let last_saved_revision = self.get_last_saved_revision(); // We encode the file according to the `Document`'s encoding. let future = async move { use tokio::{fs, fs::File}; @@ -716,11 +725,55 @@ impl Document { let mut file = File::create(&path).await?; to_writer(&mut file, encoding, &text).await?; + let mut serialize_error = false; + if let Some(history) = history { + let res = { + let path = path.clone(); + let mut undofile = OpenOptions::new() + .write(true) + .read(true) + .create(true) + .open(&undofile_path) + .await? + .into_std() + .await; + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + // Truncate the file if it's not a valid undofile. + let offset = if History::deserialize( + &mut std::fs::File::open(&undofile_path)?, + &path, + ) + .is_ok() + { + log::info!("Overwriting undofile for {}", path.to_string_lossy()); + undofile.set_len(0)?; + 0 + } else { + last_saved_revision + }; + history.serialize(&mut undofile, &path, current_rev, offset)?; + Ok(()) + }) + .await + .map_err(|e| anyhow!(e)) + .and_then(std::convert::identity) + }; + + if let Err(e) = res { + log::error!( + "Failed to serialize history for {}: {e}", + path.to_string_lossy() + ); + serialize_error = true; + } + }; + let event = DocumentSavedEvent { revision: current_rev, doc_id, path, text: text.clone(), + serialize_error, }; if let Some(language_server) = language_server { @@ -782,14 +835,17 @@ impl Document { let mut file = std::fs::File::open(&path)?; let (rope, ..) = from_reader(&mut file, Some(encoding))?; - // Calculate the difference between the buffer and source text, and apply it. - // This is not considered a modification of the contents of the file regardless - // of the encoding. - let transaction = helix_core::diff::compare_ropes(self.text(), &rope); - self.apply(&transaction, view.id); - self.append_changes_to_history(view); - self.reset_modified(); - + let e = self.load_history().map_err(|e| { + log::error!("{}", e); + // Calculate the difference between the buffer and source text, and apply it. + // This is not considered a modification of the contents of the file regardless + // of the encoding. + let transaction = helix_core::diff::compare_ropes(self.text(), &rope); + self.apply(&transaction, view.id); + self.append_changes_to_history(view); + self.reset_modified(); + e + }); self.last_saved_time = SystemTime::now(); self.detect_indent_and_line_ending(); @@ -801,6 +857,45 @@ impl Document { self.version_control_head = provider_registry.get_current_head_name(&path); + e + } + + pub fn undo_file(&self, path: Option<&PathBuf>) -> anyhow::Result> { + let undo_dir = helix_loader::cache_dir().join("undo"); + std::fs::create_dir_all(&undo_dir)?; + let res = self.path().or(path).map(|path| { + let escaped_path = helix_core::path::escape_path(path); + undo_dir.join(escaped_path) + }); + Ok(res) + } + + pub fn load_history(&mut self) -> anyhow::Result<()> { + if !self.config.load().persistent_undo { + return Ok(()); + } + + if let Some(mut undo_file) = self + .undo_file(None)? + .and_then(|path| std::fs::File::open(path).ok()) + { + if undo_file.metadata()?.len() != 0 { + let (last_saved_revision, history) = helix_core::history::History::deserialize( + &mut undo_file, + self.path().unwrap(), + )?; + + if self.history.get_mut().is_empty() + || self.get_current_revision() == last_saved_revision + { + self.history.set(history); + } else { + let offset = self.get_last_saved_revision() + 1; + self.history.get_mut().merge(history, offset)?; + } + self.set_last_saved_revision(last_saved_revision); + } + } Ok(()) } @@ -1208,7 +1303,7 @@ impl Document { } /// Get the document's latest saved revision. - pub fn get_last_saved_revision(&mut self) -> usize { + pub fn get_last_saved_revision(&self) -> usize { self.last_saved_revision } @@ -1533,9 +1628,8 @@ impl Display for FormatterError { #[cfg(test)] mod test { - use arc_swap::ArcSwap; - use super::*; + use arc_swap::ArcSwap; #[test] fn changeset_to_changes_ignore_line_endings() { @@ -1703,6 +1797,131 @@ mod test { ); } + #[tokio::test(flavor = "multi_thread")] + async fn reload_history() { + let test_fn: fn(Vec) -> bool = |changes| -> bool { + // Divide the vec into 3 sets of changes. + let len = changes.len() / 3; + let mut original = Rope::new(); + let mut iter = changes.into_iter(); + + let changes_a: Vec<_> = iter + .by_ref() + .take(len) + .map(|c| { + let c = Rope::from(c); + let transaction = helix_core::diff::compare_ropes(&original, &c); + original = c; + transaction + }) + .collect(); + let mut original_concurrent = original.clone(); + + let changes_b: Vec<_> = iter + .by_ref() + .take(len) + .map(|c| { + let c = Rope::from(c); + let transaction = helix_core::diff::compare_ropes(&original, &c); + original = c; + transaction + }) + .collect(); + let changes_c: Vec<_> = iter + .take(len) + .map(|c| { + let c = Rope::from(c); + let transaction = helix_core::diff::compare_ropes(&original_concurrent, &c); + original_concurrent = c; + transaction + }) + .collect(); + + let file = tempfile::NamedTempFile::new().unwrap(); + let config = Config { + persistent_undo: true, + ..Default::default() + }; + + let view_id = ViewId::default(); + let config = Arc::new(ArcSwap::new(Arc::new(config))); + let mut doc_1 = Document::open(file.path(), None, None, config.clone()).unwrap(); + doc_1.ensure_view_init(view_id); + + // Make changes & save document A + for c in changes_a { + doc_1.apply(&c, view_id); + } + helix_lsp::block_on(doc_1.save::(None, true).unwrap()).unwrap(); + + let mut doc_2 = Document::open(file.path(), None, None, config.clone()).unwrap(); + let mut doc_3 = Document::open(file.path(), None, None, config.clone()).unwrap(); + doc_2.ensure_view_init(view_id); + doc_3.ensure_view_init(view_id); + + // Make changes in A and B at the same time. + for c in changes_b { + doc_1.apply(&c, view_id); + } + for c in changes_c { + doc_2.apply(&c, view_id); + } + helix_lsp::block_on(doc_2.save::(None, true).unwrap()).unwrap(); + + doc_1.load_history().unwrap(); + doc_3.load_history().unwrap(); + + // doc_3 had no diverging edits, so they should be the same. + assert_eq!(doc_2.history.get_mut(), doc_3.history.get_mut()); + + helix_lsp::block_on(doc_1.save::(None, true).unwrap()).unwrap(); + doc_2.load_history().unwrap(); + doc_3.load_history().unwrap(); + + let _ = Document::open(file.path(), None, None, config).unwrap(); + doc_1.history.get_mut() == doc_2.history.get_mut() + && doc_1.history.get_mut() == doc_3.history.get_mut() + }; + let handles: Vec<_> = (0..100) + .map(|_| { + tokio::task::spawn_blocking(move || { + quickcheck::QuickCheck::new() + .max_tests(1) + .quickcheck(test_fn); + }) + }) + .collect(); + futures_util::future::try_join_all(handles).await.unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn save_history() { + let file = tempfile::NamedTempFile::new().unwrap(); + let config = Config { + persistent_undo: true, + ..Default::default() + }; + + let view_id = ViewId::default(); + let config = Arc::new(ArcSwap::new(Arc::new(config))); + let mut doc = Document::open(file.path(), None, None, config.clone()).unwrap(); + + let tx = Transaction::change(&Rope::new(), [(0, 0, None)].into_iter()); + doc.apply(&tx, view_id); + doc.save::(None, false).unwrap().await.unwrap(); + + // Wipe undo file + tokio::fs::File::create(doc.undo_file(None).unwrap().unwrap()) + .await + .unwrap(); + + // Write it again. + doc.save::(None, false).unwrap().await.unwrap(); + + // Will load history. + Document::open(file.path(), None, None, config.clone()).unwrap(); + } + macro_rules! decode { ($name:ident, $label:expr, $label_override:expr) => { #[test] diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 727e1261d54c..a5b9d8d7ff66 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -284,6 +284,8 @@ pub struct Config { pub soft_wrap: SoftWrap, /// Workspace specific lsp ceiling dirs pub workspace_lsp_roots: Vec, + + pub persistent_undo: bool, } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -750,6 +752,7 @@ impl Default for Config { text_width: 80, completion_replace: false, workspace_lsp_roots: Vec::new(), + persistent_undo: false, } } } @@ -1313,6 +1316,9 @@ impl Editor { Some(self.syn_loader.clone()), self.config.clone(), )?; + if let Err(e) = doc.load_history() { + self.set_error(Cow::Owned(format!("failed to load history from disk: {e}"))); + } if let Some(diff_base) = self.diff_providers.get_diff_base(&path) { doc.set_diff_base(diff_base, self.redraw_handle.clone());