cross-tab sync

This commit is contained in:
vcoppe committed 2026-10-10 11:07:07 +02:00
1 parent 77929cfa04
commit a2b4d89150
9 files changed
+537 -71

No files matched your search

+21 -13
View File
@@ -129,32 +129,40 @@ impl Engine {
}
}
/// Puts files in an engine that has none, typically the ones read from the storage. They are
/// the state to start from: they cannot be undone. The files are reported as added, and the
/// order, the selection and the clipboard as changed.
/// Replaces what the engine holds by the files read from the storage: at the start, or when
/// another writer (a tab) committed since, which makes what was held here out of date. What
/// was here is gone, edits that were not saved included, and the files are the state to start
/// from: they cannot be undone, and nothing from before can be.
///
/// The selection keeps what still exists, and so does the clipboard. The files that differ
/// from the ones that were here are reported as added, removed or modified (all the files
/// that were here, as they are not the same objects), and the order as changed if it is.
pub fn restore(&mut self, restored: Restored) -> Outcome {
let selection_before = self.selection.clone();
let order_before = self.order.0.clone();
let before = self.stack.current().cloned();
let files: StackEntry = restored
.files
.into_iter()
.map(|file| (file.id, file))
.collect();
let diff = Stack::diff_between(before.as_ref(), Some(&files));
let mut diff =
(!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty()))
.then_some(diff);
self.categories = restored.categories;
self.order = FileOrder(restored.order);
self.selection = Selection::Empty;
self.clipboard = None;
let mut diff = Some(Diff {
added: files.keys().copied().collect(),
..Default::default()
});
self.stack = Stack::restored(files);
self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0);
let outcome = Outcome {
changed: true,
changed: diff.is_some()
|| self.order.0 != order_before
|| self.selection != selection_before,
diff,
order_changed: true,
selection_changed: true,
clipboard_changed: true,
order_changed: self.order.0 != order_before,
selection_changed: self.selection != selection_before,
clipboard_changed: false,
error: None,
};
#[cfg(test)]
+10 -4
View File
@@ -120,8 +120,14 @@ impl Stack {
if prev == cur {
return None;
}
let prev = prev.map_or_default(|i| self.get(i));
let cur = cur.map_or_default(|i| self.get(i));
Some(Self::diff_between(
prev.map_or_default(|i| self.get(i)),
cur.map_or_default(|i| self.get(i)),
))
}
/// Which files differ between two states.
pub fn diff_between(prev: Option<&StackEntry>, cur: Option<&StackEntry>) -> Diff {
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
@@ -132,11 +138,11 @@ impl Stack {
modified.push(*id);
}
}
Some(Diff {
Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
})
}
}
}
+65 -1
View File
@@ -1,7 +1,8 @@
use std::collections::HashSet;
use std::rc::Rc;
use crate::{
ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId,
ClipboardIds, Delete, DeleteAll, File, Load, Metadata, New, NewTrack, Paste, Style, TrackId,
TrackSegmentId,
};
@@ -1308,3 +1309,66 @@ fn test_execute_returns_what_the_action_did() {
assert!(!outcome.changed);
assert!(matches!(outcome.error, Some(CommandError::InvalidData(_))));
}
// What the engine does when it is overwritten with what is stored
fn restored_from(engine: &Engine, keep: &[FileId], extra: Vec<Rc<File>>) -> crate::Restored {
let mut files: Vec<Rc<File>> = keep
.iter()
.map(|id| engine.snapshot().files[id].clone())
.collect();
files.extend(extra);
crate::Restored {
order: files.iter().map(|file| file.id).collect(),
files,
categories: engine.categories().clone(),
unreadable: 0,
read_only: false,
}
}
#[test]
fn test_overwriting_reports_what_was_added_removed_and_modified() {
let mut engine = Engine::default();
new(&mut engine, "kept");
new(&mut engine, "modified");
new(&mut engine, "removed");
let [kept, modified, removed] = engine.order()[..] else {
panic!()
};
let mut changed = (*engine.snapshot().files[&modified]).clone();
changed.info.name = "other name".into();
let added = File::default();
let added_id = added.id;
let restored = restored_from(&engine, &[kept], vec![Rc::new(changed), Rc::new(added)]);
let outcome = engine.restore(restored);
assert!(outcome.changed);
let diff = outcome.diff.unwrap();
assert_eq!(diff.added, vec![added_id]);
assert_eq!(diff.removed, vec![removed]);
assert_eq!(diff.modified, vec![modified]);
assert!(outcome.order_changed);
assert_eq!(engine.order(), &[kept, modified, added_id]);
}
#[test]
fn test_overwriting_with_the_same_files_changes_nothing() {
let mut engine = Engine::default();
new(&mut engine, "a");
let ids = engine.order().to_vec();
let outcome = engine.restore(restored_from(&engine, &ids, vec![]));
assert!(!outcome.changed);
assert!(outcome.diff.is_none());
}
#[test]
fn test_overwriting_forgets_the_history() {
let mut engine = Engine::default();
new(&mut engine, "a");
new(&mut engine, "b");
assert!(engine.can_undo());
let ids = engine.order().to_vec();
engine.restore(restored_from(&engine, &ids, vec![]));
assert!(!engine.can_undo());
}
+64 -3
View File
@@ -1,7 +1,9 @@
use std::cell::RefCell;
use std::collections::HashMap;
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
use crate::{
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
};
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
/// and what the tests use.
@@ -12,6 +14,7 @@ pub struct MemoryStorage {
#[derive(Debug, Default)]
struct State {
head: Revision,
files: HashMap<FileId, Vec<u8>>,
chunks: HashMap<ChunkKey, Vec<u8>>,
order: Option<Vec<u8>>,
@@ -46,11 +49,19 @@ impl Storage for MemoryStorage {
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
order: state.order.clone(),
head: state.head,
})
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
async fn head(&self) -> Result<Revision, StorageError> {
Ok(self.state.borrow().head)
}
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
let mut state = self.state.borrow_mut();
if state.head != batch.expect_head {
return Err(CommitError::Stale { head: state.head });
}
state.files.extend(batch.put_files.iter().cloned());
for id in &batch.delete_files {
state.files.remove(id);
@@ -62,7 +73,8 @@ impl Storage for MemoryStorage {
if let Some(order) = &batch.order {
state.order = Some(order.clone());
}
Ok(())
state.head = state.head.next();
Ok(state.head)
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
@@ -135,6 +147,7 @@ mod tests {
(ChunkKey::waypoints(w), vec![4]),
],
order: Some(vec![5]),
expect_head: Revision(0),
..Default::default()
}))
.unwrap();
@@ -158,6 +171,7 @@ mod tests {
put_files: vec![(a, vec![10])],
delete_files: vec![b],
delete_chunks: vec![ChunkKey::trackpoints(t)],
expect_head: Revision(1),
..Default::default()
}))
.unwrap();
@@ -169,6 +183,7 @@ mod tests {
block_on(storage.commit(&Batch {
delete_chunks: vec![ChunkKey::waypoints(w)],
order: Some(vec![]),
expect_head: Revision(2),
..Default::default()
}))
.unwrap();
@@ -204,4 +219,50 @@ mod tests {
block_on(storage.put_setting("a", "1")).unwrap();
assert_eq!(block_on(storage.load()).unwrap(), StoredData::default());
}
fn batch(expect_head: u64, entry: u8) -> Batch {
Batch {
put_files: vec![(FileId::default(), vec![entry])],
expect_head: Revision(expect_head),
..Default::default()
}
}
#[test]
fn test_every_commit_advances_the_head() {
let storage = MemoryStorage::default();
assert_eq!(block_on(storage.head()).unwrap(), Revision(0));
assert_eq!(block_on(storage.load()).unwrap().head, Revision(0));
assert_eq!(block_on(storage.commit(&batch(0, 1))), Ok(Revision(1)));
assert_eq!(block_on(storage.commit(&batch(1, 2))), Ok(Revision(2)));
assert_eq!(block_on(storage.head()).unwrap(), Revision(2));
assert_eq!(block_on(storage.load()).unwrap().head, Revision(2));
}
#[test]
fn test_a_commit_based_on_another_head_changes_nothing() {
let storage = MemoryStorage::default();
block_on(storage.commit(&batch(0, 1))).unwrap();
block_on(storage.commit(&batch(1, 2))).unwrap();
let before = block_on(storage.load()).unwrap();
// behind, or ahead: the head is not the one it is based on
for expect in [0, 1, 3] {
let stale = Batch {
delete_files: vec![before.files[0].0],
order: Some(vec![9]),
..batch(expect, 3)
};
assert_eq!(
block_on(storage.commit(&stale)),
Err(CommitError::Stale { head: Revision(2) })
);
}
let after = block_on(storage.load()).unwrap();
assert_eq!(after.head, Revision(2));
assert_eq!(after.files.len(), before.files.len());
assert_eq!(after.order, before.order);
// and it can be done again from the right head
assert_eq!(block_on(storage.commit(&batch(2, 3))), Ok(Revision(3)));
}
}
+54 -2
View File
@@ -8,6 +8,12 @@
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
//!
//! Several engines can write to a storage (tabs, a server). It has a head, a revision that each
//! commit advances, and a commit only applies if it is based on the current head: an engine that
//! is behind is refused, and has to read the storage again, which replaces what it holds, before
//! it writes. So what an engine knows of the storage is exact whenever it writes, which is what
//! lets it write only what changed and delete what it does not use anymore.
//!
//! The surface, highway and scales of the trackpoints are stored as names, in the chunk that has
//! them: the codes that the engine gives them (see [`crate::TrackpointCategories`]) depend on the
//! order in which it learned the names, so they mean nothing outside of it. A storage that two
@@ -35,6 +41,44 @@ impl std::fmt::Display for StorageError {
impl std::error::Error for StorageError {}
/// A state of the storage: the head is the revision of the last commit, 0 for a storage that was
/// never written to.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Revision(pub u64);
impl Revision {
pub fn next(self) -> Self {
Self(self.0 + 1)
}
}
/// Why a commit did not happen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitError {
/// The head is not the one the commit is based on: another engine wrote since.
Stale {
head: Revision,
},
Failed(StorageError),
}
impl std::fmt::Display for CommitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stale { head } => write!(f, "the storage changed (now at revision {})", head.0),
Self::Failed(error) => error.fmt(f),
}
}
}
impl std::error::Error for CommitError {}
impl From<StorageError> for CommitError {
fn from(error: StorageError) -> Self {
Self::Failed(error)
}
}
/// What a chunk holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChunkKind {
@@ -73,6 +117,8 @@ pub struct StoredData {
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
/// The order of the files.
pub order: Option<Vec<u8>>,
/// The revision of the storage that this is the state of.
pub head: Revision,
}
/// A change of the stored data, to be applied all at once: a stored state is always one that the
@@ -84,6 +130,8 @@ pub struct Batch {
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
pub delete_chunks: Vec<ChunkKey>,
pub order: Option<Vec<u8>>,
/// The head that the batch is based on: it is only applied if the head still is that one.
pub expect_head: Revision,
}
impl Batch {
@@ -118,8 +166,12 @@ pub trait Storage {
/// Reads the files.
async fn load(&self) -> Result<StoredData, StorageError>;
/// Applies a batch: either all of it is stored, or none.
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
/// The head of the storage.
async fn head(&self) -> Result<Revision, StorageError>;
/// Applies a batch if the head is the one it is based on (`Stale` otherwise): either all of it
/// is stored, or none. The head advances. Gives the new head.
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError>;
/// Reads the settings, as `(key, value)`. The engine does not look into them.
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
+152 -28
View File
@@ -9,8 +9,8 @@ use super::codec::{
encode_order, encode_trackpoints, encode_waypoints,
};
use crate::{
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
TrackpointChunk, WaypointChunk,
Batch, ChunkKey, CommitError, File, FileId, Revision, StackEntry, Storage, StorageError,
TrackpointCategories, TrackpointChunk, WaypointChunk,
};
/// What there is to keep of the engine: the files of the current state, their order, and the
@@ -50,6 +50,8 @@ pub struct Persister {
stored_files: HashSet<FileId>,
chunks: HashSet<ChunkKey>,
order: Option<Vec<FileId>>,
/// The head of the storage that all of this is the state of.
head: Revision,
/// Set by `restore`, see [`Restored::read_only`].
read_only: bool,
}
@@ -156,26 +158,37 @@ impl Persister {
self.chunks = chunk_bytes.into_keys().collect();
self.order = stored_order;
restored.categories = categories.into_inner();
self.head = data.head;
self.read_only = newer.0.get();
restored.read_only = self.read_only;
Ok(restored)
}
/// The revision of the storage that the persister knows.
pub fn head(&self) -> Revision {
self.head
}
/// Writes what changed since the last save. Nothing is written if nothing changed.
///
/// The write only happens if nothing else was committed since the persister read or wrote
/// last: otherwise it is refused (`Stale`), nothing is written, and the persister has to read
/// the storage again ([`Persister::restore`]) before it can write.
pub async fn save<S: Storage>(
&mut self,
storage: &S,
snapshot: Snapshot,
) -> Result<(), StorageError> {
) -> Result<(), CommitError> {
if self.read_only {
return Ok(());
}
let plan = self.plan(snapshot);
let mut plan = self.plan(snapshot);
if plan.batch.is_empty() {
self.files = plan.snapshot.files;
return Ok(());
}
storage.commit(&plan.batch).await?;
plan.batch.expect_head = self.head;
self.head = storage.commit(&plan.batch).await?;
self.stored_files = plan.snapshot.files.keys().copied().collect();
self.files = plan.snapshot.files;
@@ -184,6 +197,13 @@ impl Persister {
Ok(())
}
/// Whether something else was committed to the storage since the persister read or wrote it
/// last: what it knows of the storage is out of date, and it cannot write until it reads
/// again ([`Persister::restore`]).
pub async fn is_behind<S: Storage>(&self, storage: &S) -> Result<bool, StorageError> {
Ok(storage.head().await? != self.head)
}
fn plan(&self, snapshot: Snapshot) -> Plan {
let mut batch = Batch::default();
// the chunks that the files use, which the storage has afterwards
@@ -268,9 +288,14 @@ mod tests {
self.inner.load().await
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
async fn head(&self) -> Result<Revision, StorageError> {
self.inner.head().await
}
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
let revision = self.inner.commit(batch).await?;
self.batches.borrow_mut().push(batch.clone());
self.inner.commit(batch).await
Ok(revision)
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
@@ -441,6 +466,7 @@ mod tests {
let broken = snapshot.order[0];
block_on(storage.inner.commit(&Batch {
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
expect_head: persister.head(),
..Default::default()
}))
.unwrap();
@@ -467,6 +493,7 @@ mod tests {
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
let mut batch = Batch::default();
damage(&snapshot, &mut batch);
batch.expect_head = block_on(storage.inner.head()).unwrap();
block_on(storage.inner.commit(&batch)).unwrap();
storage.batches.borrow_mut().clear();
(storage, snapshot)
@@ -527,33 +554,130 @@ mod tests {
assert_eq!(clean.inner.counts().files, FILES.len());
}
/// Opens a storage as a tab does: the persister, and the state of the engine it gives.
fn open(storage: &Recording) -> (Persister, Snapshot) {
let mut persister = Persister::default();
let restored = block_on(persister.restore(storage)).unwrap();
let snapshot = Snapshot {
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
order: restored.order,
categories: restored.categories,
};
(persister, snapshot)
}
/// Adds a file that has just been read to the state of an engine.
fn add_file(snapshot: &mut Snapshot, path: &str) -> FileId {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut snapshot.categories).unwrap();
let id = file.id;
snapshot.order.push(id);
snapshot.files.insert(id, Rc::new(file));
id
}
#[test]
fn test_files_saved_by_engines_with_other_categories_read_the_same() {
// two engines that share a storage (two tabs) learn the names in their own order
// a second engine opens the storage, so its categories come from what it read, in the
// order it read them; it adds a file that has names of its own
let storage = Recording::default();
let mut first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]);
let mut second = snapshot_of(&["data/with_highway.gpx", "data/with_surface.gpx"]);
assert_ne!(first.categories, second.categories);
for snapshot in [&first, &second] {
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
}
let first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]);
block_on(Persister::default().save(&storage, first.clone())).unwrap();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
assert_eq!(restored.unreadable, 0);
assert_eq!(restored.files.len(), 4);
// every file means what it did for the engine that saved it
first.files.extend(second.files.drain());
for file in &restored.files {
let original = &first.files[&file.id];
let in_first = first.order.contains(&file.id);
let categories = if in_first {
&first.categories
} else {
&second.categories
let (mut persister, mut second) = open(&storage);
let added = add_file(&mut second, "data/with_surface.gpx");
block_on(persister.save(&storage, second.clone())).unwrap();
// the first engine reads it all again, and every file means what it did for its engine
let (_, reader) = open(&storage);
assert_eq!(reader.files.len(), 3);
for (id, file) in &reader.files {
let (original, categories) = match first.files.get(id) {
Some(original) => (original, &first.categories),
None => (&second.files[id], &second.categories),
};
assert_eq!(gpx(file, &restored.categories), gpx(original, categories));
assert_eq!(gpx(file, &reader.categories), gpx(original, categories));
}
assert!(reader.files.contains_key(&added));
}
#[test]
fn test_the_head_follows_the_commits_of_the_persister() {
let storage = Recording::default();
let mut persister = Persister::default();
assert_eq!(persister.head(), Revision(0));
let mut snapshot = snapshot_of(&FILES);
block_on(persister.save(&storage, snapshot.clone())).unwrap();
assert_eq!(persister.head(), Revision(1));
assert_eq!(block_on(storage.head()).unwrap(), Revision(1));
// nothing to write is no commit
block_on(persister.save(&storage, snapshot.clone())).unwrap();
assert_eq!(persister.head(), Revision(1));
assert_eq!(storage.batches.borrow().len(), 1);
let id = snapshot.order[0];
let mut file = (*snapshot.files[&id]).clone();
file.info.name = "renamed".into();
snapshot.files.insert(id, Rc::new(file));
block_on(persister.save(&storage, snapshot)).unwrap();
assert_eq!(persister.head(), Revision(2));
// a persister that opens the storage starts from its head
assert_eq!(open(&storage).0.head(), Revision(2));
assert_eq!(storage.batches.borrow()[1].expect_head, Revision(1));
}
#[test]
fn test_a_tab_that_is_behind_cannot_write() {
// two tabs open the same file; one edits a point, the other renames the file
let storage = Recording::default();
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
let ((mut a, mut snap_a), (mut b, mut snap_b)) = (open(&storage), open(&storage));
let id = snap_a.order[0];
let mut file = (*snap_a.files[&id]).clone();
file.trk[0].trkseg[0].update(0, |p| p.ele += 100.0);
snap_a.files.insert(id, Rc::new(file));
block_on(a.save(&storage, snap_a.clone())).unwrap();
let stored = block_on(storage.load()).unwrap();
let mut file = (*snap_b.files[&id]).clone();
file.info.name = "renamed by B".into();
snap_b.files.insert(id, Rc::new(file));
assert_eq!(
block_on(b.save(&storage, snap_b)),
Err(CommitError::Stale { head: Revision(2) })
);
// nothing was written, the file is still readable, and the persister did not move
assert_eq!(block_on(storage.load()).unwrap(), stored);
assert_eq!(b.head(), Revision(1));
let (_, reader) = open(&storage);
assert_eq!(reader.files.len(), 1);
assert_eq!(reader.files[&id].info.name, snap_a.files[&id].info.name);
}
#[test]
fn test_a_tab_that_is_behind_reads_the_storage_again_and_writes_again() {
let storage = Recording::default();
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
let ((mut a, mut snap_a), (mut b, snap_b)) = (open(&storage), open(&storage));
let id = snap_a.order[0];
assert!(!block_on(b.is_behind(&storage)).unwrap());
let mut file = (*snap_a.files[&id]).clone();
file.info.name = "renamed by A".into();
snap_a.files.insert(id, Rc::new(file));
block_on(a.save(&storage, snap_a)).unwrap();
assert!(block_on(b.is_behind(&storage)).unwrap());
assert!(!block_on(a.is_behind(&storage)).unwrap());
let restored = block_on(b.restore(&storage)).unwrap();
assert_eq!(restored.files[0].info.name, "renamed by A");
assert!(!block_on(b.is_behind(&storage)).unwrap());
let mut snapshot = snap_b;
snapshot.files.insert(id, restored.files[0].clone());
assert!(block_on(b.save(&storage, snapshot)).is_ok());
}
#[test]