Files
Marcos Paulo 08cdea58d7
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: shell round: unlimited tabs with hibernation, hotkeys registry, menu keycaps, Explorer scroll and tooltips, Comments tab, delete confirm
Tabs: no cap (a 5000 bound on the saved list only); only the active and 5 recent tabs keep editor state, cold tabs keep caret, view and folds and reload on activation, dirty tabs are never dropped; the strip lays out at most 64 tabs and the overflow list is searchable and virtualised; restore loads only the active tab. Hotkeys: one registry with defaults for tab and panel navigation and opening Outline, Related, Backlinks, Assistant, Lint and Comments, shown in the tooltips; F6 cycles focus; Ctrl+h/l in vim mode; panels are accessibility landmarks. Menus: keycap letters that run the item, vim j/k/h/l/gg/G, focus into the menu and back on Esc, menu and menu-item roles. Explorer: horizontal scroll (Shift+wheel) and full-name tooltips. Comments tab in the right panel (MiniMax M3.1 Flash). Delete confirmation dialog from an earlier session, with its debug code and deadlock removed. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, all tests, layers, tree, i18n) green on a copy of the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:59:29 -03:00

165 lines
8.2 KiB
Rust

//! The Comments tab of the right card, driven headless: the threads of a note
//! in document order, the filters, a reply that reaches the sidecar, the
//! resolve and the read-only vault.
use std::path::PathBuf;
use egui::accesskit::Role;
use egui_kittest::kittest::{By, Queryable};
use notescraft_engine::{Registry, Session, register_core};
use notescraft_themes::builtin;
use notescraft_ui_egui::comment_panel::{CommentData, CommentResponse, parse_list};
use notescraft_ui_egui::theme::Tokens;
use serde_json::json;
/// The panel over a vault, with the engine that answers its commands.
struct Panel {
/// What the panel draws.
data: CommentData,
/// The theme of the app.
tokens: Tokens,
/// The vault the note is in.
session: Session,
/// The commands of the engine.
registry: Registry<Session>,
/// The note the threads belong to.
path: String,
/// The root of the vault, to read the sidecar.
root: PathBuf,
/// Whether the fonts are in, which takes one pass.
warmed: bool,
}
/// A vault with a note and two threads: the first one is closed, the second one
/// is orphaned because its quote left the note.
fn panel(name: &str, read_only: bool) -> egui_kittest::Harness<'static, Panel> {
let root = std::env::temp_dir().join(format!("notescraft-comment-panel-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("the temp vault is made");
std::fs::write(root.join("a.md"), "First line.\nSecond line.\n").expect("the note is written");
let mut session = Session::open(&root).expect("the temp vault opens");
let mut registry = Registry::new();
register_core(&mut registry).expect("the core commands are registered");
registry.run("comment.add", &mut session, &json!({ "path": "a.md", "quote": "First line.", "body": "Soften this claim." })).expect("the first thread");
registry.run("comment.add", &mut session, &json!({ "path": "a.md", "quote": "Second line.", "body": "This line is gone." })).expect("the second thread");
// The second quote leaves the note: its thread is orphaned.
std::fs::write(root.join("a.md"), "First line.\n").expect("the note is rewritten");
let list = registry.run("comment.list", &mut session, &json!({ "path": "a.md" })).expect("the threads");
let closed = parse_list(&list).iter().find(|thread| thread.body == "Soften this claim.").map(|thread| thread.id.clone()).expect("the first thread");
registry.run("comment.resolve", &mut session, &json!({ "path": "a.md", "id": closed, "resolved": true })).expect("the resolve");
let list = registry.run("comment.list", &mut session, &json!({ "path": "a.md" })).expect("the threads");
let data = CommentData { path: Some("a.md".to_owned()), threads: parse_list(&list), loaded: true, read_only, ..CommentData::default() };
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
egui_kittest::Harness::builder().with_size(egui::Vec2::new(420.0, 900.0)).with_max_steps(200).build_ui_state(
|ui, state: &mut Panel| {
if !state.warmed {
// Fonts bind one pass after installing; the first pass warms only.
notescraft_ui_egui::fonts::install(ui.ctx());
state.warmed = true;
return;
}
let response = notescraft_ui_egui::comment_panel::show(ui, &state.tokens, &state.data);
apply(state, response);
},
Panel { data, tokens, session, registry, path: "a.md".to_owned(), root, warmed: false },
)
}
/// Runs what the panel asked for and reads the threads again.
fn apply(state: &mut Panel, response: CommentResponse) {
let path = state.path.clone();
if let Some((quote, body)) = response.add {
let _ = state.registry.run("comment.add", &mut state.session, &json!({ "path": path, "quote": quote, "body": body }));
}
if let Some((id, body)) = response.reply {
let _ = state.registry.run("comment.reply", &mut state.session, &json!({ "path": path, "id": id, "body": body }));
}
if let Some((id, resolved)) = response.resolve {
let _ = state.registry.run("comment.resolve", &mut state.session, &json!({ "path": path, "id": id, "resolved": resolved }));
}
if let Some(id) = response.reanchor {
let _ = state.registry.run(
"comment.reanchor",
&mut state.session,
&json!({ "path": path, "id": id, "quote": "First line.", "start": 0, "end": "First line.".len() }),
);
}
if let Ok(list) = state.registry.run("comment.list", &mut state.session, &json!({ "path": path })) {
state.data.threads = parse_list(&list);
state.data.loaded = true;
}
}
/// The node that sits lowest on the screen.
fn lowest(left: &egui_kittest::Node<'_>, right: &egui_kittest::Node<'_>) -> std::cmp::Ordering {
left.rect().min.y.total_cmp(&right.rect().min.y)
}
/// The threads of the note are listed, the closed one first.
#[test]
fn the_threads_are_listed_in_document_order() {
let mut harness = panel("order", false);
harness.run();
assert_eq!(harness.query_all_by_label("Soften this claim.").count(), 1, "the closed thread is shown");
assert_eq!(harness.query_all_by_label("This line is gone.").count(), 1, "the orphan is shown");
let closed = harness.get_by_label("Soften this claim.").rect();
let orphan = harness.get_by_label("This line is gone.").rect();
assert!(closed.min.y < orphan.min.y, "the thread that still resolves comes first");
assert!(harness.query_all_by_label("Orphaned").count() >= 1, "the orphan carries its badge");
}
/// The Open filter leaves out the closed thread.
#[test]
fn the_open_filter_hides_the_closed_thread() {
let mut harness = panel("filter", false);
harness.run();
harness.get_by_label("Open").click();
harness.run();
assert_eq!(harness.query_all_by_label("Soften this claim.").count(), 0, "the closed thread is gone");
assert_eq!(harness.query_all_by_label("This line is gone.").count(), 1, "the open thread stays");
}
/// A reply typed in the panel reaches the sidecar of the note.
#[test]
fn a_reply_reaches_the_sidecar() {
let mut harness = panel("reply", false);
harness.run();
// The first thread opens, and its reply box appears.
harness.get_all_by_label("Reply").next().expect("the thread opens").click();
harness.run();
harness.get_all(By::new().role(Role::TextInput)).max_by(lowest).expect("a text field").click();
harness.run();
harness.get_all(By::new().role(Role::TextInput)).max_by(lowest).expect("a text field").type_text("Agreed.");
harness.run();
// The send button of the reply box is the lowest one: the composer sits above it.
harness.get_all_by_label("Send").max_by(lowest).expect("a send button").click();
harness.run();
let sidecar = std::fs::read_to_string(harness.state().root.join("a.md.comments.json")).expect("the sidecar is there");
assert!(sidecar.contains("Agreed."), "the reply is in the sidecar: {sidecar}");
}
/// The resolve button closes the thread and becomes a reopen.
#[test]
fn resolve_closes_the_thread() {
let mut harness = panel("resolve", false);
harness.run();
assert_eq!(harness.query_all_by_label("Reopen").count(), 1, "the closed thread offers to reopen");
assert_eq!(harness.query_all_by_label("Resolve").count(), 1, "the open thread offers to close");
harness.get_by_label("Resolve").click();
harness.run();
assert_eq!(harness.query_all_by_label("Reopen").count(), 2, "both threads are closed now");
let sidecar = std::fs::read_to_string(harness.state().root.join("a.md.comments.json")).expect("the sidecar is there");
assert_eq!(sidecar.matches("\"resolved\": true").count(), 2, "both threads are closed in the sidecar");
}
/// A read-only vault shows the threads without a single button.
#[test]
fn a_read_only_vault_has_no_buttons() {
let mut harness = panel("readonly", true);
harness.run();
assert_eq!(harness.query_all_by_label("Soften this claim.").count(), 1, "the threads are still listed");
for label in ["Resolve", "Reopen", "Re-anchor", "Send"] {
assert_eq!(harness.query_all_by_label(label).count(), 0, "no {label} button in a read-only vault");
}
}