181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
11 KiB
Rust
294 lines
11 KiB
Rust
//! The Sync settings page and the status-bar sync pill, driven the way a user does:
|
|
//! real pointer and keyboard events through whole frames of the app over a real vault.
|
|
use egui::{Color32, Context, Event, Key, Modifiers, PointerButton, Pos2, RawInput};
|
|
use notescraft_engine::{Session, SyncPhase, SyncStatus};
|
|
use notescraft_ui_egui::app::NotesApp;
|
|
use notescraft_ui_egui::theme::{Tokens, color};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// The server the pairing form is pointed at; nothing listens there.
|
|
const DEAD_SERVER: &str = "http://127.0.0.1:9";
|
|
/// The pairing code typed in the form.
|
|
const CODE: &str = "482913";
|
|
/// The name the device gets.
|
|
const DEVICE: &str = "Test laptop";
|
|
/// The server a fake binding points at.
|
|
const FAKE_SERVER: &str = "http://127.0.0.1:9";
|
|
/// The host of that server, as the page shows it.
|
|
const FAKE_HOST: &str = "127.0.0.1:9";
|
|
|
|
struct Driver {
|
|
app: NotesApp,
|
|
ctx: Context,
|
|
time: f64,
|
|
root: std::path::PathBuf,
|
|
}
|
|
|
|
/// A painted text: what it says, where it starts and its galley.
|
|
struct Painted {
|
|
text: String,
|
|
pos: Pos2,
|
|
galley: std::sync::Arc<egui::Galley>,
|
|
}
|
|
|
|
impl Driver {
|
|
/// One frame of 1440 x 900 with `events`; the texts it painted.
|
|
fn frame(&mut self, events: Vec<Event>) -> Vec<Painted> {
|
|
self.time += 0.05;
|
|
let input = RawInput {
|
|
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
|
time: Some(self.time),
|
|
events,
|
|
..Default::default()
|
|
};
|
|
let app = &mut self.app;
|
|
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui.ctx()));
|
|
out.textures_delta.clear();
|
|
out.shapes
|
|
.iter()
|
|
.filter_map(|shape| match &shape.shape {
|
|
egui::Shape::Text(text) => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos, galley: text.galley.clone() }),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn settle(&mut self) -> Vec<Painted> {
|
|
let mut last = Vec::new();
|
|
for _ in 0..6 {
|
|
last = self.frame(Vec::new());
|
|
}
|
|
last
|
|
}
|
|
|
|
fn mouse(&mut self, at: Pos2, pressed: Option<bool>) -> Vec<Painted> {
|
|
let mut events = vec![Event::PointerMoved(at)];
|
|
if let Some(pressed) = pressed {
|
|
events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
|
}
|
|
self.frame(events)
|
|
}
|
|
|
|
fn click(&mut self, at: Pos2) {
|
|
self.mouse(at, None);
|
|
self.mouse(at, Some(true));
|
|
self.mouse(at, Some(false));
|
|
self.settle();
|
|
}
|
|
}
|
|
|
|
/// An app over a fresh vault holding `files`; `setup` runs on the vault folder first.
|
|
fn driver_with(files: &[(&str, &str)], setup: &dyn Fn(&std::path::Path)) -> Driver {
|
|
let root = std::env::temp_dir().join(format!("notescraft-sync-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir_all(&root).expect("root");
|
|
for (path, text) in files {
|
|
if let Some((folder, _)) = path.rsplit_once('/') {
|
|
std::fs::create_dir_all(root.join(folder)).expect("folder");
|
|
}
|
|
std::fs::write(root.join(path), text).expect("seed");
|
|
}
|
|
setup(&root);
|
|
let mut session = Session::open(&root).expect("session");
|
|
session.use_settings_dir(root.join("config"));
|
|
let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha").expect("theme"));
|
|
app.state.animations = false;
|
|
Driver { app, ctx: Context::default(), time: 1.0, root }
|
|
}
|
|
|
|
/// A vault that is paired with a server that is not there.
|
|
fn driver_paired(files: &[(&str, &str)]) -> Driver {
|
|
driver_with(files, &|root| {
|
|
let folder = root.join(".notescraft/sync");
|
|
std::fs::create_dir_all(&folder).expect("folder");
|
|
std::fs::write(folder.join("binding.json"), format!(r#"{{"server":"{FAKE_SERVER}","vault_id":"7f3a2b1c9d","device_id":"device-1","user_id":null}}"#))
|
|
.expect("binding");
|
|
std::fs::write(folder.join("credentials.json"), r#"{"token":"not-a-real-token"}"#).expect("credentials");
|
|
})
|
|
}
|
|
|
|
fn names(texts: &[Painted]) -> Vec<&str> {
|
|
texts.iter().map(|painted| painted.text.as_str()).collect()
|
|
}
|
|
|
|
fn find<'a>(texts: &'a [Painted], wanted: &str) -> &'a Painted {
|
|
texts.iter().find(|painted| painted.text == wanted).unwrap_or_else(|| panic!("no {wanted:?} among {:?}", names(texts)))
|
|
}
|
|
|
|
/// The middle of a painted text.
|
|
fn center(painted: &Painted) -> Pos2 {
|
|
painted.pos + egui::vec2(painted.galley.size().x / 2.0, painted.galley.size().y / 2.0)
|
|
}
|
|
|
|
/// The right end of a painted text, where a click puts the caret.
|
|
fn end_of(painted: &Painted) -> Pos2 {
|
|
painted.pos + egui::vec2(painted.galley.size().x, painted.galley.size().y / 2.0)
|
|
}
|
|
|
|
/// Opens the Settings tab and picks the Sync page.
|
|
fn open_sync(d: &mut Driver) {
|
|
d.app.open_note(notescraft_ui_egui::tabs::SETTINGS_PATH);
|
|
let texts = d.settle();
|
|
let nav = find(&texts, "Sync");
|
|
d.click(center(nav));
|
|
d.settle();
|
|
}
|
|
|
|
/// Clicks at the right end of the painted `wanted` and types `text` there.
|
|
fn type_into(d: &mut Driver, wanted: &str, text: &str) {
|
|
let texts = d.settle();
|
|
let painted = find(&texts, wanted);
|
|
d.click(end_of(painted));
|
|
clear(d);
|
|
d.frame(vec![Event::Text(text.to_owned())]);
|
|
d.settle();
|
|
}
|
|
|
|
/// Clicks the field that follows the label `label` and types `text` there.
|
|
fn type_under(d: &mut Driver, label: &str, text: &str) {
|
|
let texts = d.settle();
|
|
let painted = value_after(&texts, label);
|
|
d.click(end_of(painted));
|
|
clear(d);
|
|
d.frame(vec![Event::Text(text.to_owned())]);
|
|
d.settle();
|
|
}
|
|
|
|
/// The text painted right after `label`: the value of the field under it.
|
|
fn value_after<'a>(texts: &'a [Painted], label: &str) -> &'a Painted {
|
|
let index = texts.iter().position(|painted| painted.text == label).unwrap_or_else(|| panic!("no {label:?} among {:?}", names(texts)));
|
|
texts.get(index + 1).unwrap_or_else(|| panic!("no field under {label:?} among {:?}", names(texts)))
|
|
}
|
|
|
|
/// Backspaces over whatever the focused field holds.
|
|
fn clear(d: &mut Driver) {
|
|
let events = (0..40).map(|_| Event::Key { key: Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }).collect();
|
|
d.frame(events);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unpaired_vault_shows_the_pairing_form_and_no_status() {
|
|
let mut d = driver_with(&[], &|_| {});
|
|
open_sync(&mut d);
|
|
let texts = d.settle();
|
|
let shown = names(&texts);
|
|
assert!(shown.contains(&"Server address"), "{shown:?}");
|
|
assert!(shown.contains(&"Pairing code"), "{shown:?}");
|
|
assert!(shown.contains(&"Device name"), "{shown:?}");
|
|
assert!(shown.contains(&"Pair this computer"), "{shown:?}");
|
|
// No server card, no device list, no status line.
|
|
assert!(!shown.contains(&"Disconnect"), "{shown:?}");
|
|
assert!(!shown.iter().any(|text| text.contains("Up to date")), "{shown:?}");
|
|
assert!(!shown.contains(&"Devices"), "{shown:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn pairing_with_an_unreachable_server_shows_an_error_and_stays_unbound() {
|
|
let mut d = driver_with(&[], &|_| {});
|
|
open_sync(&mut d);
|
|
type_into(&mut d, "https://sync.example.com", DEAD_SERVER);
|
|
type_into(&mut d, "The one-time code the server shows", CODE);
|
|
type_under(&mut d, "Device name", DEVICE);
|
|
let texts = d.settle();
|
|
let shown = names(&texts);
|
|
assert!(shown.contains(&DEAD_SERVER), "{shown:?}");
|
|
assert!(shown.contains(&CODE), "{shown:?}");
|
|
assert!(shown.contains(&DEVICE), "{shown:?}");
|
|
|
|
let button = find(&texts, "Pair this computer");
|
|
d.click(center(button));
|
|
|
|
// The pairing runs off the UI thread; the page shows its answer when it comes.
|
|
let mut texts = d.settle();
|
|
for _ in 0..240 {
|
|
if names(&texts).iter().any(|text| text.contains("could not be reached")) {
|
|
break;
|
|
}
|
|
texts = d.frame(Vec::new());
|
|
}
|
|
let shown = names(&texts);
|
|
assert!(shown.iter().any(|text| text.contains("could not be reached")), "{shown:?}");
|
|
// The vault is still unpaired and the form is still there.
|
|
assert!(!d.root.join(".notescraft/sync/binding.json").exists());
|
|
assert!(shown.contains(&"Pair this computer"), "{shown:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_paired_vault_shows_the_server_host_and_the_disconnect_button() {
|
|
let mut d = driver_paired(&[]);
|
|
open_sync(&mut d);
|
|
let texts = d.settle();
|
|
let shown = names(&texts);
|
|
assert!(shown.contains(&FAKE_HOST), "{shown:?}");
|
|
assert!(shown.contains(&"Disconnect"), "{shown:?}");
|
|
assert!(shown.contains(&"This computer"), "{shown:?}");
|
|
assert!(shown.contains(&"Sync now"), "{shown:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_conflict_pair_shows_in_the_list_and_keeping_both_renames_the_copy() {
|
|
let mut d = driver_paired(&[("idea.md", "mine"), ("idea (conflict laptop 2026-10-20).md", "theirs")]);
|
|
open_sync(&mut d);
|
|
let texts = d.settle();
|
|
let shown = names(&texts);
|
|
assert!(shown.contains(&"idea.md"), "{shown:?}");
|
|
assert!(shown.contains(&"laptop · 2026-10-20"), "{shown:?}");
|
|
assert!(shown.contains(&"Keep both"), "{shown:?}");
|
|
|
|
let button = find(&texts, "Keep both");
|
|
d.click(center(button));
|
|
d.settle();
|
|
|
|
assert!(d.root.join("idea (2).md").exists(), "the copy was not renamed");
|
|
assert!(!d.root.join("idea (conflict laptop 2026-10-20).md").exists(), "the copy is still there");
|
|
assert_eq!(std::fs::read_to_string(d.root.join("idea (2).md")).unwrap_or_default(), "theirs");
|
|
}
|
|
|
|
/// The words and colours the pill shows for a status.
|
|
fn paint_pill(tokens: &Tokens, status: &SyncStatus) -> Option<(String, Color32)> {
|
|
let ctx = Context::default();
|
|
notescraft_ui_egui::fonts::install(&ctx);
|
|
let mut output = ctx.run_ui(RawInput::default(), |ctx| {
|
|
let area = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(200.0, 40.0));
|
|
let mut ui = egui::Ui::new(ctx.clone(), egui::Id::new("sync-pill"), egui::UiBuilder::new().max_rect(area));
|
|
notescraft_ui_egui::panels::sync_pill::show(&mut ui, tokens, status, 190.0, 20.0);
|
|
});
|
|
output.textures_delta.clear();
|
|
output.shapes.iter().find_map(|shape| match &shape.shape {
|
|
egui::Shape::Text(text) => Some((text.galley.text().to_owned(), text.fallback_color)),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn tokens() -> Tokens {
|
|
Tokens::new(notescraft_themes::builtin("catppuccin-mocha").expect("theme"))
|
|
}
|
|
|
|
#[test]
|
|
fn the_pill_says_what_the_worker_does() {
|
|
let tokens = tokens();
|
|
let colors = &tokens.theme.colors;
|
|
let cases = [
|
|
(SyncPhase::Idle, 0, "Synced", color(colors.success)),
|
|
(SyncPhase::Syncing, 0, "Syncing", color(colors.warning)),
|
|
(SyncPhase::Idle, 3, "3 waiting", color(colors.warning)),
|
|
(SyncPhase::Offline, 0, "Offline", color(colors.text_muted)),
|
|
(SyncPhase::SignInAgain, 0, "Sign in again", color(colors.danger)),
|
|
(SyncPhase::Error, 0, "Sync error", color(colors.danger)),
|
|
];
|
|
for (phase, pending, word, tint) in cases {
|
|
let status = SyncStatus { bound: true, phase, pending, ..SyncStatus::default() };
|
|
let painted = paint_pill(&tokens, &status);
|
|
assert_eq!(painted.as_ref().map(|(text, _)| text.as_str()), Some(word));
|
|
assert_eq!(painted.map(|(_, tint)| tint), Some(tint), "{word}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unpaired_vault_has_no_pill() {
|
|
let tokens = tokens();
|
|
let status = SyncStatus { bound: false, ..SyncStatus::default() };
|
|
assert_eq!(paint_pill(&tokens, &status), None);
|
|
}
|