Files
Marcos Paulo 5d4a2c8fe1
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: all files in the Explorer, publish and share links, image annotation core, last-edit line
- 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>
2026-10-20 14:38:47 -03:00

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