Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
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>
2026-10-20 07:46:24 -03:00

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);
}