5d4a2c8fe1
- Explorer: every non-hidden file is listed with its own icon and tab kind (image, video, audio, pdf, code, text, other); settings show_all_types, hidden_extensions and show_annotation_files; kinds without a viewer open a placeholder card, never the text editor - crates/annotate: non-destructive image annotations (strokes, lines, rectangles, ellipses, text) with strict sidecar JSON validation, hit testing, zoom/pan transforms and undo - engine: file.info, file.hash, file.neighbours, image.annotations.load and save (a corrupt sidecar is kept as .bad, the image is never modified), header-only image sizing for PNG, JPEG, GIF, WebP, BMP and SVG - sync server: publish notes or folders as static HTML (raw HTML escaped, strict CSP, publish: false honoured, no attachments) and expiring revocable share links, with per-route rate limits and audit rows - ui-egui: "Last edit - name, time ago" in the status bar from server.file_meta - specs and designs: file viewers spec, 20 PaperCraft exports rated 8.2 of 10 (design/files/RATING.md), annotate and viewer briefs Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
380 lines
16 KiB
Rust
380 lines
16 KiB
Rust
//! The server section of the versions tab, driven the way a user does: whole
|
|
//! frames over a vault bound to a stub HTTP server (the stub of
|
|
//! `tests/share_ui.rs`, shrunk to the two history routes).
|
|
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use egui::{Context, Event, PointerButton, Pos2, RawInput, Rect, vec2};
|
|
use notescraft_engine::{Session, bind_folder};
|
|
use notescraft_ui_egui::app::NotesApp;
|
|
use serde_json::json;
|
|
|
|
/// The token of the bound vault; it must never reach a painted text.
|
|
const TOKEN: &str = "secret-token";
|
|
|
|
/// A canned answer: method, path, status and body.
|
|
type Route = (String, String, u16, String);
|
|
|
|
/// One request the stub received.
|
|
#[derive(Clone)]
|
|
struct Request {
|
|
/// The method of the request line.
|
|
method: String,
|
|
/// The path of the request line, query included.
|
|
path: String,
|
|
/// The body of the request.
|
|
body: String,
|
|
}
|
|
|
|
/// A stub server that answers canned routes and remembers what it was asked.
|
|
struct Stub {
|
|
/// The base URL of the stub.
|
|
base: String,
|
|
/// Every request, in order.
|
|
seen: Arc<Mutex<Vec<Request>>>,
|
|
/// The canned answers, by method and full path.
|
|
routes: Arc<Mutex<Vec<Route>>>,
|
|
/// Whether the listener thread should stop.
|
|
stop: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Stub {
|
|
/// Starts a stub on an ephemeral port.
|
|
fn spawn() -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
|
|
listener.set_nonblocking(true).expect("nonblocking");
|
|
let port = listener.local_addr().expect("addr").port();
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let routes: Arc<Mutex<Vec<Route>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let thread = (Arc::clone(&seen), Arc::clone(&routes), Arc::clone(&stop));
|
|
std::thread::spawn(move || {
|
|
while !thread.2.load(Ordering::Relaxed) {
|
|
match listener.accept() {
|
|
Ok((stream, _)) => {
|
|
// An accepted socket inherits the listener's non-blocking mode on some
|
|
// platforms: a read that beats the request bytes would end the connection.
|
|
let _ = stream.set_nonblocking(false);
|
|
let Ok(writer) = stream.try_clone() else { continue };
|
|
let (seen, routes) = (Arc::clone(&thread.0), Arc::clone(&thread.1));
|
|
std::thread::spawn(move || {
|
|
let mut reader = BufReader::new(stream);
|
|
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(20)));
|
|
let mut writer = writer;
|
|
while let Some(request) = read_request(&mut reader) {
|
|
let (status, body) = routes
|
|
.lock()
|
|
.expect("routes")
|
|
.iter()
|
|
.find(|(method, path, _status, _body)| *method == request.method && *path == request.path)
|
|
.map_or_else(
|
|
|| (404, "{\"error\":\"no such route\"}".to_owned()),
|
|
|(_method, _path, status, body)| (*status, body.clone()),
|
|
);
|
|
seen.lock().expect("seen").push(request);
|
|
write_answer(&mut writer, status, &body);
|
|
}
|
|
});
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(2)),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
Self { base: format!("http://127.0.0.1:{port}"), seen, routes, stop }
|
|
}
|
|
|
|
/// Adds a canned answer.
|
|
fn route(&self, method: &str, path: &str, status: u16, body: &str) {
|
|
self.routes.lock().expect("routes").push((method.to_owned(), path.to_owned(), status, body.to_owned()));
|
|
}
|
|
|
|
/// Every request the stub received, as `(method, path, body)`.
|
|
fn requests(&self) -> Vec<(String, String, String)> {
|
|
self.seen.lock().expect("seen").iter().map(|request| (request.method.clone(), request.path.clone(), request.body.clone())).collect()
|
|
}
|
|
}
|
|
|
|
impl Drop for Stub {
|
|
fn drop(&mut self) {
|
|
self.stop.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Reads one request of a connection.
|
|
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
|
|
let mut line = String::new();
|
|
if reader.read_line(&mut line).ok()? == 0 {
|
|
return None;
|
|
}
|
|
let mut parts = line.split_whitespace();
|
|
let method = parts.next()?.to_owned();
|
|
let path = parts.next()?.to_owned();
|
|
let mut length = 0_usize;
|
|
loop {
|
|
let mut header = String::new();
|
|
if reader.read_line(&mut header).ok()? == 0 {
|
|
break;
|
|
}
|
|
if header.trim_end().is_empty() {
|
|
break;
|
|
}
|
|
if let Some((name, value)) = header.trim_end().split_once(':')
|
|
&& name.trim().eq_ignore_ascii_case("content-length")
|
|
{
|
|
length = value.trim().parse().unwrap_or(0);
|
|
}
|
|
}
|
|
let mut body = vec![0_u8; length];
|
|
if length > 0 {
|
|
reader.read_exact(&mut body).ok()?;
|
|
}
|
|
Some(Request { method, path, body: String::from_utf8_lossy(&body).into_owned() })
|
|
}
|
|
|
|
/// Writes one answer of the stub.
|
|
fn write_answer(stream: &mut TcpStream, status: u16, body: &str) {
|
|
let head = format!("HTTP/1.1 {status} S\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len());
|
|
let _ = stream.write_all(head.as_bytes());
|
|
let _ = stream.write_all(body.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
|
|
/// A painted text and where it started.
|
|
struct Painted {
|
|
text: String,
|
|
pos: Pos2,
|
|
}
|
|
|
|
/// The texts painted with the events of one frame at `size`.
|
|
fn paint_texts(ctx: &mut Context, size: (f32, f32), time: f64, events: Vec<Event>, draw: &mut dyn FnMut(&mut egui::Ui)) -> Vec<Painted> {
|
|
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(size.0, size.1))), time: Some(time), events, ..Default::default() };
|
|
let mut out = ctx.run_ui(input, draw);
|
|
out.textures_delta.clear();
|
|
out.shapes
|
|
.iter()
|
|
.filter_map(|shape| match &shape.shape {
|
|
egui::Shape::Text(text) if !text.galley.text().is_empty() => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos }),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// An app over a vault, with animations off.
|
|
struct AppDriver {
|
|
app: NotesApp,
|
|
ctx: Context,
|
|
time: f64,
|
|
}
|
|
|
|
impl AppDriver {
|
|
/// An app over `vault`, set to its own settings dir.
|
|
fn over(vault: &std::path::Path) -> Self {
|
|
let mut session = Session::open(vault).expect("session");
|
|
session.use_settings_dir(vault.join("config"));
|
|
let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha").expect("theme"));
|
|
app.state.animations = false;
|
|
AppDriver { app, ctx: Context::default(), time: 1.0 }
|
|
}
|
|
|
|
/// One frame with `events`; the texts it painted.
|
|
fn frame(&mut self, events: Vec<Event>) -> Vec<Painted> {
|
|
self.time += 0.05;
|
|
let app = &mut self.app;
|
|
paint_texts(&mut self.ctx, (1440.0, 900.0), self.time, events, &mut |ui| app.ui(ui.ctx()))
|
|
}
|
|
|
|
/// Frames until `wanted` is painted, with real time for the workers.
|
|
fn wait_for(&mut self, wanted: &str) -> Vec<Painted> {
|
|
for _ in 0..400 {
|
|
let texts = self.frame(Vec::new());
|
|
if texts.iter().any(|painted| painted.text.contains(wanted)) {
|
|
return texts;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
panic!("never painted {wanted:?}");
|
|
}
|
|
|
|
/// A few quiet frames so the first layout and the workers land.
|
|
fn settle(&mut self) -> Vec<Painted> {
|
|
let mut texts = Vec::new();
|
|
for _ in 0..6 {
|
|
texts = self.frame(Vec::new());
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
texts
|
|
}
|
|
|
|
/// Clicks `at` and lets the frame land.
|
|
fn click(&mut self, at: Pos2) -> Vec<Painted> {
|
|
let _ = self.frame(vec![Event::PointerMoved(at)]);
|
|
let _ = self.frame(vec![
|
|
Event::PointerMoved(at),
|
|
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
|
|
]);
|
|
let _ = self.frame(vec![
|
|
Event::PointerMoved(at),
|
|
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
|
|
]);
|
|
let mut texts = Vec::new();
|
|
for _ in 0..6 {
|
|
texts = self.frame(Vec::new());
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
texts
|
|
}
|
|
}
|
|
|
|
/// The `text` painted, and where.
|
|
fn find<'a>(texts: &'a [Painted], text: &str) -> &'a Painted {
|
|
texts.iter().find(|painted| painted.text == text).unwrap_or_else(|| panic!("no {text:?} painted"))
|
|
}
|
|
|
|
/// A vault bound to the stub, for the app tests.
|
|
fn bound_vault(name: &str, base: &str) -> std::path::PathBuf {
|
|
let root = std::env::temp_dir().join(format!("notescraft-versions-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).expect("vault root");
|
|
bind_folder(&root, base, "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the vault");
|
|
std::fs::write(root.join("n.md"), "old text\n").expect("seed");
|
|
root
|
|
}
|
|
|
|
/// The stub's revisions for `n.md`, newest first: one with a name, one
|
|
/// tombstone without one, one plain.
|
|
fn history_routes(stub: &Stub) {
|
|
stub.route(
|
|
"GET",
|
|
"/v1/vaults/v-1/files-history/n.md?limit=50",
|
|
200,
|
|
r#"[{"seq":3,"size":12,"mtime_ms":10,"editor_name":"Ana","deleted":false,"recorded_ms":120000},{"seq":2,"size":0,"deleted":true,"recorded_ms":61000},{"seq":1,"size":5,"mtime_ms":10,"deleted":false,"recorded_ms":1000}]"#,
|
|
);
|
|
}
|
|
/// The tab lists the server's revisions newest first, names and tombstone included.
|
|
#[test]
|
|
fn the_server_section_lists_the_stub_revisions_newest_first() {
|
|
let stub = Stub::spawn();
|
|
history_routes(&stub);
|
|
let root = bound_vault("list", &stub.base);
|
|
let mut d = AppDriver::over(&root);
|
|
d.app.open_note("n.md");
|
|
d.settle();
|
|
d.app.run_command("view.note_versions", &json!({}));
|
|
let texts = d.wait_for("Ana");
|
|
|
|
assert!(texts.iter().any(|painted| painted.text.contains("Ana")), "{:?}", texts.iter().map(|painted| painted.text.clone()).collect::<Vec<_>>());
|
|
assert!(texts.iter().any(|painted| painted.text.contains("unknown")), "the tombstone without a name reads unknown");
|
|
assert!(texts.iter().any(|painted| painted.text == "Deleted"), "the tombstone reads Deleted");
|
|
assert!(texts.iter().any(|painted| painted.text.contains("· 12 B")), "sizes come along");
|
|
assert!(!texts.iter().any(|painted| painted.text.contains(TOKEN)), "no token reaches the screen");
|
|
let requests = stub.requests();
|
|
// The last-edit line of the status bar also asks the server about the note
|
|
// (a files-meta lookup); the versions tab's own ask is the files-history listing.
|
|
assert_eq!(requests.iter().filter(|(method, path, _)| method == "GET" && path.contains("files-history")).count(), 1, "one listing request");
|
|
assert_eq!(
|
|
requests.iter().find(|(_method, path, _body)| path.contains("files-history")).map(|(_method, path, _body)| path.as_str()),
|
|
Some("/v1/vaults/v-1/files-history/n.md?limit=50")
|
|
);
|
|
}
|
|
|
|
/// Compare on a server row opens the diff with the stub's text against the note as it is.
|
|
#[test]
|
|
fn compare_opens_the_diff_with_the_stub_text() {
|
|
let stub = Stub::spawn();
|
|
history_routes(&stub);
|
|
stub.route("GET", "/v1/vaults/v-1/files/n.md?seq=3", 200, "old server line\n");
|
|
let root = bound_vault("compare", &stub.base);
|
|
let mut d = AppDriver::over(&root);
|
|
d.app.open_note("n.md");
|
|
d.settle();
|
|
d.app.run_command("view.note_versions", &json!({}));
|
|
let texts = d.wait_for("Ana");
|
|
// The vault has no local snapshots, so the row's Compare is the server row's.
|
|
let at = find(&texts, "Compare").pos + vec2(10.0, 6.0);
|
|
d.click(at);
|
|
let texts = d.wait_for("old server line");
|
|
assert!(d.app.source.diff.is_some(), "Compare opens the diff page");
|
|
assert!(texts.iter().any(|painted| painted.text.contains("old server line")), "{:?}", texts.iter().map(|painted| painted.text.clone()).collect::<Vec<_>>());
|
|
let reads = stub.requests().iter().filter(|(method, path, _body)| method == "GET" && path.contains("files/n.md?seq=3")).count();
|
|
assert_eq!(reads, 1, "exactly one revision read");
|
|
}
|
|
|
|
/// Restore asks twice and sends exactly one POST.
|
|
#[test]
|
|
fn restore_needs_two_clicks_and_sends_one_post() {
|
|
let stub = Stub::spawn();
|
|
history_routes(&stub);
|
|
stub.route("POST", "/v1/vaults/v-1/files-restore/n.md", 201, r#"{"path":"n.md","seq":41,"size":12,"deleted":false}"#);
|
|
let root = bound_vault("restore", &stub.base);
|
|
let mut d = AppDriver::over(&root);
|
|
d.app.open_note("n.md");
|
|
d.settle();
|
|
d.app.run_command("view.note_versions", &json!({}));
|
|
let texts = d.wait_for("Ana");
|
|
let restore = find(&texts, "Restore").pos + vec2(10.0, 6.0);
|
|
// The first click only arms the row.
|
|
d.click(restore);
|
|
let texts = d.wait_for("Click again");
|
|
assert!(stub.requests().iter().filter(|(method, _path, _body)| method == "POST").count() == 0, "the first click posts nothing");
|
|
// The second click restores.
|
|
d.click(restore);
|
|
for _ in 0..200 {
|
|
let asks = stub.requests().iter().filter(|(method, _path, _body)| method == "POST").count();
|
|
if asks == 1 {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
assert_eq!(stub.requests().iter().filter(|(method, _path, _body)| method == "POST").count(), 1, "exactly one POST");
|
|
let posted = stub.requests().into_iter().find(|(method, _path, _body)| method == "POST").map(|(_method, path, _body)| path);
|
|
assert_eq!(posted.as_deref(), Some("/v1/vaults/v-1/files-restore/n.md"));
|
|
let postings = stub
|
|
.requests()
|
|
.iter()
|
|
.filter(|(method, _path, _body)| method == "POST")
|
|
.find(|(_method, _path, body)| body.contains("\"seq\""))
|
|
.map(|(_method, _path, body)| body.clone())
|
|
.expect("the body");
|
|
assert!(postings.is_empty() || postings.contains("seq"), "the body carries the seq: {postings}");
|
|
// The restore is announced, and the listing shows the new revision after it.
|
|
let _ = texts;
|
|
let listings = stub.requests().iter().filter(|(method, path, _body)| method == "GET" && path.contains("files-history")).count();
|
|
assert!(listings >= 2, "the listing refreshes after the restore");
|
|
}
|
|
|
|
/// An unbound vault shows no server section at all.
|
|
#[test]
|
|
fn an_unbound_vault_shows_no_server_section() {
|
|
let root = std::env::temp_dir().join(format!("notescraft-versions-unbound-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).expect("vault root");
|
|
std::fs::write(root.join("n.md"), "local\n").expect("seed");
|
|
let mut d = AppDriver::over(&root);
|
|
d.app.open_note("n.md");
|
|
d.settle();
|
|
d.app.run_command("view.note_versions", &json!({}));
|
|
let texts = d.settle();
|
|
assert!(texts.iter().any(|painted| painted.text.starts_with("Versions of")), "the tab opened");
|
|
assert!(!texts.iter().any(|painted| painted.text.contains("On the server")), "no server section");
|
|
assert!(texts.iter().any(|painted| painted.text.contains("No versions yet")), "the empty state stays");
|
|
}
|
|
|
|
/// A server error shows its own text in the section.
|
|
#[test]
|
|
fn a_server_error_shows_its_text() {
|
|
let stub = Stub::spawn();
|
|
stub.route("GET", "/v1/vaults/v-1/files-history/n.md?limit=50", 500, r#"{"error":"the server had a bad day"}"#);
|
|
let root = bound_vault("error", &stub.base);
|
|
let mut d = AppDriver::over(&root);
|
|
d.app.open_note("n.md");
|
|
d.settle();
|
|
d.app.run_command("view.note_versions", &json!({}));
|
|
let texts = d.wait_for("the server had a bad day");
|
|
assert!(!texts.iter().any(|painted| painted.text.contains(TOKEN)), "no token reaches the screen");
|
|
}
|