feat(viewer): the agent dock and the first-run page (P11b)
D-27: a terminal panel along the bottom of the viewer (alacritty_terminal over a PTY) runs Claude Code (default) or Codex in the book's books home, resizable, collapsible, the choice remembered. The agent is an ordinary child process the owner describes (agent_start); the viewer stays read-only: the agent writes through the server like any other. A missing agent is offered through its vendor's installer (a command to copy), never bundled; no folder is marked trusted (Q-7, recommended answer: let Codex ask). Windows is not offered until its ConPTY checks are recorded. P11a found that onboarding with no terminal was false: the viewer opened only an existing book. With no books and an owner, wanderbooks gui now shows a first-run page: the private folder, the owner passphrase (typed in the viewer, optional), the first books, an agent, then the books open. connect and New books... make the books home. Tests: dock.rs, welcome.rs, books_home.rs (host and cli). Docs: testing.md record and For P12, architecture, parity, gui.md, README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
0442ab804e
commit
8f2e3378e8
15 files changed
+2620
-33
No files matched your search
@@ -151,24 +151,75 @@ pub struct App {
|
||||
menu: crate::menu::Menu,
|
||||
/// The owner's Books menu (owner.rs).
|
||||
owner_menu: crate::owner::OwnerMenu,
|
||||
/// The agent dock along the bottom (dock.rs).
|
||||
dock: crate::dock::Dock,
|
||||
/// The first-run page, while there are no books to show (welcome.rs).
|
||||
welcome: Option<crate::welcome::Welcome>,
|
||||
/// What a first run opens its books with: the data root named, and
|
||||
/// "today".
|
||||
first_run_source: (Option<std::path::PathBuf>, Option<chrono::NaiveDate>),
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The live app over a books directory.
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
||||
// The scheme kept from last time; before schemes, a light/dark
|
||||
// choice; else the system's mode (schemes INVARIANT 4).
|
||||
let mut app = Self::new_without_books(cc);
|
||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||
app
|
||||
}
|
||||
|
||||
/// The app on a first run: no books yet, so the page that sets them up
|
||||
/// (welcome.rs), which opens them once they are made. `root` is the data
|
||||
/// root named on the command line, if any.
|
||||
pub fn first_run(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
root: Option<std::path::PathBuf>,
|
||||
today: Option<chrono::NaiveDate>,
|
||||
state: crate::owner::FirstRun,
|
||||
) -> Self {
|
||||
let mut app = Self::new_without_books(cc);
|
||||
app.welcome = Some(crate::welcome::Welcome::new(state));
|
||||
app.first_run_source = (root, today);
|
||||
app
|
||||
}
|
||||
|
||||
/// An app with no books and no loader yet: the scheme kept from last
|
||||
/// time (before schemes, a light/dark choice; else the system's mode:
|
||||
/// schemes INVARIANT 4) and the dock as it was left.
|
||||
pub fn new_without_books(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let (scheme, filter) = restored(
|
||||
|key| cc.storage.and_then(|s| s.get_string(key)),
|
||||
cc.egui_ctx.global_style().visuals.dark_mode,
|
||||
);
|
||||
let mut app = Self::bare(&cc.egui_ctx, scheme);
|
||||
app.filter = filter;
|
||||
app.dock = crate::dock::Dock::restored(|key| cc.storage.and_then(|s| s.get_string(key)));
|
||||
app.receipts.thumb_dir = crate::receipts::thumb_dir();
|
||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||
app
|
||||
}
|
||||
|
||||
/// The first-run page, if it is up (tests).
|
||||
pub fn welcome(&self) -> Option<&crate::welcome::Welcome> {
|
||||
self.welcome.as_ref()
|
||||
}
|
||||
|
||||
/// Open the books `id` of the data root named, as the first-run page
|
||||
/// was asked to: the books come in as the loader reads them.
|
||||
fn open_first_books(&mut self, ctx: &egui::Context, id: &str) {
|
||||
let (root, today) = self.first_run_source.clone();
|
||||
match crate::book_in_root(root.as_deref(), Some(id)) {
|
||||
Ok(data_dir) => {
|
||||
self.loader = Some(Loader::spawn(Source { data_dir, today }, ctx.clone()));
|
||||
self.welcome = None;
|
||||
}
|
||||
Err(why) => {
|
||||
if let Some(w) = self.welcome.as_mut() {
|
||||
w.refused(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An app showing fixed books (tests and screenshots).
|
||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||
let mut app = Self::bare(ctx, schemes::own(dark));
|
||||
@@ -198,6 +249,28 @@ impl App {
|
||||
live_label: false,
|
||||
menu: Default::default(),
|
||||
owner_menu: Default::default(),
|
||||
dock: Default::default(),
|
||||
welcome: None,
|
||||
first_run_source: (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent dock (tests).
|
||||
pub fn dock_mut(&mut self) -> &mut crate::dock::Dock {
|
||||
&mut self.dock
|
||||
}
|
||||
|
||||
/// The dock along the bottom, for books of a data root with an owner
|
||||
/// to start the agent (dock.rs).
|
||||
fn dock(&mut self, ui: &mut Ui) {
|
||||
if !crate::dock::offered() {
|
||||
return;
|
||||
}
|
||||
let (Some(owner), Some(books)) = (crate::owner::current(), self.books.clone()) else {
|
||||
return;
|
||||
};
|
||||
if let Some(book) = crate::owner::book_id(&books) {
|
||||
self.dock.show(ui, &owner, &book);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,6 +629,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn handle_keys(&mut self, ui: &Ui) {
|
||||
// The agent in the dock has the keyboard while it is clicked on.
|
||||
if self.dock.has_keyboard() {
|
||||
return;
|
||||
}
|
||||
let (back, fwd, palette) = ui.input(|i| {
|
||||
(
|
||||
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|
||||
@@ -1636,6 +1713,22 @@ fn checks(b: &Books) -> Vec<(bool, String, String)> {
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
||||
// A first run: the page that sets the books up, until there are some.
|
||||
if self.welcome.is_some() && self.books.is_none() {
|
||||
let p = pal(ui.ctx());
|
||||
let owner = crate::owner::current();
|
||||
let mut open = None;
|
||||
if let (Some(w), Some(owner)) = (self.welcome.as_mut(), owner) {
|
||||
egui::CentralPanel::no_frame()
|
||||
.frame(Frame::new().fill(p.bg))
|
||||
.show(ui, |ui| w.show(ui, &owner));
|
||||
open = w.take_open();
|
||||
}
|
||||
if let Some(id) = open {
|
||||
self.open_first_books(&ui.ctx().clone(), &id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default();
|
||||
for u in updates {
|
||||
match u {
|
||||
@@ -1656,6 +1749,7 @@ impl eframe::App for App {
|
||||
let compact = ui.available_width() < COMPACT_BELOW;
|
||||
self.sidebar(ui, compact);
|
||||
self.topbar(ui);
|
||||
self.dock(ui);
|
||||
let p = pal(ui.ctx());
|
||||
let since_update = ui.input(|i| i.time) - self.updated_at;
|
||||
if since_update < 6.0 {
|
||||
@@ -1706,6 +1800,7 @@ impl eframe::App for App {
|
||||
storage.set_string(SCHEME_KEY, self.scheme.key());
|
||||
storage.set_string(FILTER_KEY, self.filter.name().to_string());
|
||||
storage.set_string("dark", self.scheme.dark.to_string());
|
||||
self.dock.keep(&mut |k, v| storage.set_string(k, v));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ pub mod books;
|
||||
pub mod charts;
|
||||
pub mod data;
|
||||
pub mod demo;
|
||||
pub mod dock;
|
||||
pub mod export;
|
||||
pub mod fmt;
|
||||
pub mod menu;
|
||||
@@ -22,6 +23,7 @@ mod schemes;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod theme;
|
||||
pub mod welcome;
|
||||
mod widgets;
|
||||
|
||||
pub use app::App;
|
||||
@@ -53,8 +55,10 @@ impl std::error::Error for Exit {}
|
||||
/// a book of the data root (plan 3.6), the only books it opens (D-44):
|
||||
/// `--root`, else the root `wanderbooks setup` made, never the current
|
||||
/// directory or a settings file; `--book` names one, else the root's only
|
||||
/// one (or the first, by id). `--demo` builds a demo company in a temp
|
||||
/// directory and opens that.
|
||||
/// one (or the first, by id). With no books yet, and an owner to set them
|
||||
/// up (`wanderbooks gui`), it opens the first-run page (welcome.rs) instead
|
||||
/// of ending. `--demo` builds a demo company in a temp directory and opens
|
||||
/// that.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
@@ -94,17 +98,26 @@ pub fn run(
|
||||
eprintln!("demo books: {}", dir.display());
|
||||
data_dir = Some(dir.join("books-data"));
|
||||
}
|
||||
// The books to show, or a first run (no books yet, and the program gave
|
||||
// the viewer an owner to set them up: plan P11b), or the reason not.
|
||||
let mut first_run = None;
|
||||
let data_dir = match data_dir {
|
||||
Some(d) => d,
|
||||
Some(d) => Some(d),
|
||||
None => match book_in_root(root.as_deref(), book.as_deref()) {
|
||||
Ok(d) => d,
|
||||
Err(why) => {
|
||||
eprintln!("{why}");
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
Ok(d) => Some(d),
|
||||
Err(why) => match owner::current() {
|
||||
// Named books that aren't there are a mistake, not a first run.
|
||||
Some(owner) if book.is_none() => {
|
||||
first_run = Some(owner.first_run());
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{why}");
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
let source = Source { data_dir, today };
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("wanderbooks")
|
||||
@@ -121,7 +134,12 @@ pub fn run(
|
||||
Ok(eframe::run_native(
|
||||
"wanderbooks",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
|
||||
Box::new(move |cc| {
|
||||
Ok(Box::new(match (data_dir, first_run) {
|
||||
(Some(data_dir), _) => App::new(cc, Source { data_dir, today }),
|
||||
(None, state) => App::first_run(cc, root, today, state.unwrap_or_default()),
|
||||
}))
|
||||
}),
|
||||
)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,24 @@ pub enum Agent {
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn label(self) -> &'static str {
|
||||
/// The key it is kept under (the viewer's preference) and the word
|
||||
/// the owner's commands know it by.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Agent::Claude => "claude",
|
||||
Agent::Codex => "codex",
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent kept under `key`.
|
||||
pub fn from_key(key: &str) -> Option<Agent> {
|
||||
[Agent::Claude, Agent::Codex]
|
||||
.into_iter()
|
||||
.find(|a| a.key() == key)
|
||||
}
|
||||
|
||||
/// Its name.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Agent::Claude => "Claude Code",
|
||||
Agent::Codex => "Codex",
|
||||
@@ -55,6 +72,51 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a first run stands: what exists already (the viewer's welcome
|
||||
/// page starts from the first missing step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FirstRun {
|
||||
/// The books' private folder is there.
|
||||
pub root: bool,
|
||||
/// The ids of the books in it.
|
||||
pub books: Vec<String>,
|
||||
/// The owner passphrase is set.
|
||||
pub passphrase_set: bool,
|
||||
}
|
||||
|
||||
/// How a person gets an agent's program: the vendor's own installer, never
|
||||
/// bundled (D-27).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Installer {
|
||||
/// The agent's name.
|
||||
pub label: String,
|
||||
/// The vendor's install command, for a terminal.
|
||||
pub command: String,
|
||||
/// The vendor's page about it.
|
||||
pub page: String,
|
||||
}
|
||||
|
||||
/// What it takes to run an agent in a books home (the dock runs it on a
|
||||
/// terminal of its own; nothing here starts a process).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AgentStart {
|
||||
/// Run `program args` in `dir` with `env` added.
|
||||
Ready {
|
||||
/// The agent's name.
|
||||
label: String,
|
||||
/// The program.
|
||||
program: std::path::PathBuf,
|
||||
/// Its arguments.
|
||||
args: Vec<String>,
|
||||
/// The books home it starts in.
|
||||
dir: std::path::PathBuf,
|
||||
/// Environment to add.
|
||||
env: Vec<(String, String)>,
|
||||
},
|
||||
/// The agent isn't installed: the vendor's installer.
|
||||
Missing(Installer),
|
||||
}
|
||||
|
||||
/// A restore previewed: what it would do, and what it is of (INVARIANT 3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RestorePlan {
|
||||
@@ -95,6 +157,26 @@ pub trait Owner: Send + Sync {
|
||||
/// The owner's go-ahead for the books `book`'s format upgrade, with the
|
||||
/// owner passphrase they typed (the server checks it).
|
||||
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String>;
|
||||
/// Where a first run stands (P11b: onboarding with no terminal).
|
||||
fn first_run(&self) -> FirstRun {
|
||||
FirstRun::default()
|
||||
}
|
||||
/// Make the books' private folder (what `wanderbooks setup` does).
|
||||
fn set_up(&self) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
/// Set the owner passphrase for the first time, as typed (twice) in the
|
||||
/// viewer.
|
||||
fn set_passphrase(&self, _new: &str) -> Result<(), String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
/// Hold the books `book` open while the viewer runs (the viewer's lease).
|
||||
fn hold(&self, _book: &str) {}
|
||||
/// How to run `agent` in the books home of `book` (made if need be): the
|
||||
/// dock starts it on a terminal of its own (D-27).
|
||||
fn agent_start(&self, _agent: Agent, _book: &str) -> Result<AgentStart, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
}
|
||||
|
||||
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
|
||||
@@ -104,6 +186,11 @@ pub fn set_owner(owner: Arc<dyn Owner>) {
|
||||
let _ = OWNER.set(owner);
|
||||
}
|
||||
|
||||
/// The viewer's owner, if the program gave it one.
|
||||
pub fn current() -> Option<Arc<dyn Owner>> {
|
||||
owner()
|
||||
}
|
||||
|
||||
fn owner() -> Option<Arc<dyn Owner>> {
|
||||
OWNER.get().cloned()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
//! The first run (plan P11b, found by P11a: "onboards and posts an entry
|
||||
//! with no terminal" was false while the viewer opened only an existing
|
||||
//! book and `setup` and `books create` needed a terminal): the page the
|
||||
//! viewer shows when there are no books yet. It sets up the books' private
|
||||
//! folder, asks for the owner passphrase, makes the first books and connects
|
||||
//! an agent, each through the [`Owner`], then opens the books.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a person who installed the app and opened it, with no terminal
|
||||
//! and no admin password, has books, an agent that can keep them and
|
||||
//! the view of them, in minutes.
|
||||
//! SOURCE: plan/served.md P11a As built (Done-when 1: the AppImage "onboards
|
||||
//! and posts an entry with no terminal"), P11b; D-19 (the viewer's
|
||||
//! "New books…" and "Connect" menu items; one app); D-27 (the books
|
||||
//! home, the dock, never required); D-29 (no passphrase handling
|
||||
//! beyond the owner's own: the owner passphrase is typed here and
|
||||
//! handed to the owner, as the upgrade dialog does).
|
||||
//! INVARIANTS:
|
||||
//! 1. The page only asks the [`Owner`]; nothing here writes a file of the
|
||||
//! books or of the data root.
|
||||
//! 2. Each step is shown from what exists now ([`FirstRun`]): a step done
|
||||
//! (by the command line, say) is not asked again, and the page opens at
|
||||
//! the first step missing.
|
||||
//! 3. The owner passphrase may be left for later (the books work without
|
||||
//! it); an agent may be left for later, or never: any agent reaches the
|
||||
//! books through the MCP.
|
||||
//! 4. A passphrase typed is wiped from memory once handed over, or when the
|
||||
//! page goes.
|
||||
//! 5. Each step runs off the UI thread and says what came of it, in
|
||||
//! words; a refusal stays on the page, with the step.
|
||||
//! 6. The books are opened only once they exist.
|
||||
//! ```
|
||||
|
||||
use crate::owner::{Agent, AgentStart, FirstRun, Owner};
|
||||
use egui::{RichText, Ui};
|
||||
use egui_phosphor::regular as icon;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
/// The least an owner passphrase may be (the store's rule, said here).
|
||||
pub const MIN_PASSPHRASE: usize = ledger_store::passphrase::MIN_CHARS;
|
||||
|
||||
/// What a step's worker sends back: what it said (or why it was refused),
|
||||
/// and where the first run stands now.
|
||||
type Done = (Result<Vec<String>, String>, FirstRun);
|
||||
|
||||
/// The first-run page's state.
|
||||
pub struct Welcome {
|
||||
state: FirstRun,
|
||||
/// The id and currency typed for the first books.
|
||||
id: String,
|
||||
currency: String,
|
||||
/// The passphrase typed, twice.
|
||||
pass: String,
|
||||
again: String,
|
||||
/// The passphrase step was left for later.
|
||||
pass_later: bool,
|
||||
/// The agents asked for, and what came of each.
|
||||
agents: Vec<(Agent, Result<Vec<String>, String>)>,
|
||||
/// The agents may be left for later.
|
||||
agent_later: bool,
|
||||
/// A step under way: what it is, the agent it connects (if it does),
|
||||
/// and its answer.
|
||||
job: Option<(String, Option<Agent>, Receiver<Done>)>,
|
||||
/// What the last step said or why it was refused.
|
||||
said: Option<Result<Vec<String>, String>>,
|
||||
/// The books to open, once the person says.
|
||||
open: Option<String>,
|
||||
}
|
||||
|
||||
impl Drop for Welcome {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.pass);
|
||||
zeroize::Zeroize::zeroize(&mut self.again);
|
||||
}
|
||||
}
|
||||
|
||||
impl Welcome {
|
||||
/// The page for a first run that stands at `state`.
|
||||
pub fn new(state: FirstRun) -> Self {
|
||||
Self {
|
||||
state,
|
||||
id: String::new(),
|
||||
currency: "USD".into(),
|
||||
pass: String::new(),
|
||||
again: String::new(),
|
||||
pass_later: false,
|
||||
agents: Vec::new(),
|
||||
agent_later: false,
|
||||
job: None,
|
||||
said: None,
|
||||
open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the first run stands.
|
||||
pub fn state(&self) -> &FirstRun {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// The books the person chose to open, once.
|
||||
pub fn take_open(&mut self) -> Option<String> {
|
||||
self.open.take()
|
||||
}
|
||||
|
||||
/// Show why the books could not be opened.
|
||||
pub fn refused(&mut self, why: String) {
|
||||
self.said = Some(Err(why));
|
||||
}
|
||||
|
||||
fn run(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
what: &str,
|
||||
agent: Option<Agent>,
|
||||
owner: &Arc<dyn Owner>,
|
||||
work: impl FnOnce(&dyn Owner) -> Result<Vec<String>, String> + Send + 'static,
|
||||
) {
|
||||
let (tx, rx) = channel();
|
||||
let (o, ctx) = (owner.clone(), ctx.clone());
|
||||
std::thread::spawn(move || {
|
||||
let done = work(o.as_ref());
|
||||
let _ = tx.send((done, o.first_run()));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
self.job = Some((what.to_string(), agent, rx));
|
||||
self.said = None;
|
||||
}
|
||||
|
||||
/// The page, in the window's central area.
|
||||
pub fn show(&mut self, ui: &mut Ui, owner: &Arc<dyn Owner>) {
|
||||
let p = crate::theme::pal(ui.ctx());
|
||||
if let Some((_, agent, rx)) = &self.job {
|
||||
let agent = *agent;
|
||||
match rx.try_recv() {
|
||||
Ok((done, state)) => {
|
||||
self.job = None;
|
||||
self.state = state;
|
||||
if let Some(agent) = agent {
|
||||
self.agents.retain(|(a, _)| *a != agent);
|
||||
self.agents.push((agent, done.clone()));
|
||||
}
|
||||
self.said = Some(done);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.job = None;
|
||||
self.said = Some(Err(
|
||||
"it ended without an answer: look again at what exists, and try again"
|
||||
.into(),
|
||||
));
|
||||
self.state = owner.first_run();
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
let busy = self.job.is_some();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.set_max_width(640.0);
|
||||
ui.add_space(36.0);
|
||||
ui.label(RichText::new("Welcome to wanderbooks").font(crate::theme::bold(28.0)));
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Your company's books, kept by an agent and checked by you. A few short steps; nothing here needs a terminal.",
|
||||
)
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(18.0);
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
self.steps(ui, owner, busy);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A step's frame: its number, title and whether it is done.
|
||||
fn card(
|
||||
ui: &mut Ui,
|
||||
n: usize,
|
||||
title: &str,
|
||||
done: bool,
|
||||
active: bool,
|
||||
body: impl FnOnce(&mut Ui),
|
||||
) {
|
||||
let p = crate::theme::pal(ui.ctx());
|
||||
egui::Frame::new()
|
||||
.fill(p.surface)
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
if active { p.accent } else { p.border },
|
||||
))
|
||||
.corner_radius(10)
|
||||
.inner_margin(egui::Margin::symmetric(18, 14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let (mark, color) = if done {
|
||||
(icon::CHECK_CIRCLE.to_string(), p.positive)
|
||||
} else {
|
||||
(format!("{n}."), if active { p.accent } else { p.faint })
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(mark)
|
||||
.font(crate::theme::semibold(16.0))
|
||||
.color(color),
|
||||
);
|
||||
ui.label(RichText::new(title).font(crate::theme::semibold(16.0)));
|
||||
});
|
||||
if active || done {
|
||||
body(ui);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
fn steps(&mut self, ui: &mut Ui, owner: &Arc<dyn Owner>, busy: bool) {
|
||||
let p = crate::theme::pal(ui.ctx());
|
||||
let st = self.state.clone();
|
||||
let have_books = !st.books.is_empty();
|
||||
// The first step missing is the one asked for (INVARIANT 2).
|
||||
let now = if !st.root {
|
||||
1
|
||||
} else if !st.passphrase_set && !self.pass_later {
|
||||
2
|
||||
} else if !have_books {
|
||||
3
|
||||
} else if self.agents.iter().all(|(_, r)| r.is_err()) && !self.agent_later {
|
||||
4
|
||||
} else {
|
||||
5
|
||||
};
|
||||
|
||||
// 1. The private folder.
|
||||
Self::card(
|
||||
ui,
|
||||
1,
|
||||
"A private place for your books",
|
||||
st.root,
|
||||
now == 1,
|
||||
|ui| {
|
||||
if st.root {
|
||||
ui.label(RichText::new("Made. Only your account can open it.").color(p.muted));
|
||||
} else {
|
||||
ui.label("Wanderbooks keeps your books in a folder that only your account can open. Your agent reaches them through the app, never by looking in the folder.");
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Set it up"))
|
||||
.clicked()
|
||||
{
|
||||
let ctx = ui.ctx().clone();
|
||||
self.run(&ctx, "Setting up", None, owner, |o| o.set_up());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 2. The owner passphrase.
|
||||
Self::card(
|
||||
ui,
|
||||
2,
|
||||
"Your owner passphrase",
|
||||
st.passphrase_set,
|
||||
now == 2,
|
||||
|ui| {
|
||||
if st.passphrase_set {
|
||||
ui.label(RichText::new("Set.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
ui.label(format!(
|
||||
"The books ask for it before anything only you may do, like moving them to a newer format. Pick one you will remember: {MIN_PASSPHRASE} characters or more. It is not kept anywhere you can read."
|
||||
));
|
||||
ui.add_enabled(
|
||||
!busy,
|
||||
egui::TextEdit::singleline(&mut self.pass)
|
||||
.password(true)
|
||||
.hint_text("passphrase")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_enabled(
|
||||
!busy,
|
||||
egui::TextEdit::singleline(&mut self.again)
|
||||
.password(true)
|
||||
.hint_text("the same again")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
let long = self.pass.chars().count() >= MIN_PASSPHRASE;
|
||||
let same = self.pass == self.again;
|
||||
if !self.pass.is_empty() && !long {
|
||||
ui.colored_label(
|
||||
p.warning,
|
||||
format!("{MIN_PASSPHRASE} characters or more, please."),
|
||||
);
|
||||
} else if !self.again.is_empty() && !same {
|
||||
ui.colored_label(p.warning, "The two don't match.");
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(!busy && long && same, egui::Button::new("Set passphrase"))
|
||||
.clicked()
|
||||
{
|
||||
// Handed over, and gone from the page (INVARIANT 4).
|
||||
let typed = zeroize::Zeroizing::new(std::mem::take(&mut self.pass));
|
||||
zeroize::Zeroize::zeroize(&mut self.again);
|
||||
let ctx = ui.ctx().clone();
|
||||
self.run(&ctx, "Setting the passphrase", None, owner, move |o| {
|
||||
o.set_passphrase(&typed)
|
||||
.map(|()| vec!["The owner passphrase is set.".to_string()])
|
||||
});
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Not now"))
|
||||
.on_hover_text(
|
||||
"The books work without it; `wanderbooks passphrase` sets it later",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.pass_later = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 3. The first books.
|
||||
let ready = st.root;
|
||||
Self::card(
|
||||
ui,
|
||||
3,
|
||||
"Your first books",
|
||||
have_books,
|
||||
ready && now == 3,
|
||||
|ui| {
|
||||
if have_books {
|
||||
ui.label(
|
||||
RichText::new(format!("Made: {}.", st.books.join(", "))).color(p.muted),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.label("A short name for the books: lowercase letters, digits and hyphens (your company's name, say). You can name the company itself afterwards, with your agent.");
|
||||
ui.add_enabled(
|
||||
!busy,
|
||||
egui::TextEdit::singleline(&mut self.id)
|
||||
.hint_text("my-company")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.label("Their base currency (fixed for good):");
|
||||
ui.add_enabled(
|
||||
!busy,
|
||||
egui::TextEdit::singleline(&mut self.currency).desired_width(120.0),
|
||||
);
|
||||
let ok = !self.id.trim().is_empty() && !self.currency.trim().is_empty();
|
||||
if ui
|
||||
.add_enabled(!busy && ok, egui::Button::new("Make the books"))
|
||||
.clicked()
|
||||
{
|
||||
let (id, cur) = (self.id.trim().to_string(), self.currency.trim().to_string());
|
||||
let ctx = ui.ctx().clone();
|
||||
self.run(&ctx, "Making the books", None, owner, move |o| {
|
||||
let made = o.new_books(&id, &cur)?;
|
||||
// Held open while the viewer runs.
|
||||
o.hold(&id);
|
||||
Ok(vec![made])
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 4. An agent.
|
||||
let book = st.books.first().cloned();
|
||||
let step4_active = have_books && now == 4;
|
||||
let agents = self.agents.clone();
|
||||
Self::card(
|
||||
ui,
|
||||
4,
|
||||
"An agent to keep them",
|
||||
agents.iter().any(|(_, r)| r.is_ok()),
|
||||
step4_active,
|
||||
|ui| {
|
||||
ui.label("An agent (Claude Code or Codex) records your transactions, checks receipts and answers questions, through the books' own tools. This connects it; the app also has a panel along the bottom of its window that runs it for you. You never need either: any agent you run yourself can reach the same books.");
|
||||
for (agent, result) in &agents {
|
||||
match result {
|
||||
Ok(lines) => {
|
||||
ui.label(RichText::new(lines.join(" ")).color(p.positive));
|
||||
}
|
||||
Err(why) => {
|
||||
ui.colored_label(p.warning, format!("{}: {why}", agent.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(book) = book.clone() else {
|
||||
return;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
for agent in [Agent::Claude, Agent::Codex] {
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy,
|
||||
egui::Button::new(format!("Connect {}", agent.label())),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let b = book.clone();
|
||||
let ctx = ui.ctx().clone();
|
||||
self.run(&ctx, "Connecting", Some(agent), owner, move |o| {
|
||||
match o.connect(agent, &b) {
|
||||
Ok(lines) => Ok(lines),
|
||||
// Not installed: how to get it.
|
||||
Err(why) => match o.agent_start(agent, &b) {
|
||||
Ok(AgentStart::Missing(i)) => Err(format!(
|
||||
"{why}. Install it with `{}` in a terminal ({}), then connect it here.",
|
||||
i.command, i.page
|
||||
)),
|
||||
_ => Err(why),
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("I'll use my own agent"))
|
||||
.clicked()
|
||||
{
|
||||
self.agent_later = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Open.
|
||||
if have_books {
|
||||
let id = st.books[0].clone();
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy,
|
||||
egui::Button::new(
|
||||
RichText::new(format!("{} Open the books", icon::BOOK_OPEN))
|
||||
.font(crate::theme::semibold(14.0)),
|
||||
)
|
||||
.min_size(egui::vec2(180.0, 36.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.open = Some(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// What the last step said.
|
||||
if let Some((what, _, _)) = &self.job {
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(format!("{what}…")).color(p.muted));
|
||||
} else if let Some(said) = &self.said {
|
||||
ui.add_space(8.0);
|
||||
match said {
|
||||
Ok(lines) => ui.label(RichText::new(lines.join(" ")).color(p.muted)),
|
||||
Err(why) => ui.colored_label(p.warning, format!("{} {why}", icon::WARNING)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! The agent dock (dock.rs, plan P11b, D-27): it runs the chosen agent in
|
||||
//! the book's books home, the agent's output reaches the panel, keys and
|
||||
//! Ctrl-C reach the agent, a resize redraws; an agent that isn't installed
|
||||
//! is offered through its vendor's installer; the dock writes nothing of
|
||||
//! the books. Linux and macOS (a PTY); Windows is not offered.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::Currency;
|
||||
use ledger_gui::App;
|
||||
use ledger_gui::data::{self, Source};
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_gui::owner::{Agent, AgentStart, Installer, Owner, RestorePlan};
|
||||
use ledger_store::Store;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// An owner that only answers `agent_start`, and remembers what it was asked.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
answer: Mutex<Option<Result<AgentStart, String>>>,
|
||||
asked: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Owner for Fake {
|
||||
fn close_books(&self) -> Result<String, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn connect(&self, _: Agent, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn agent_start(&self, agent: Agent, book: &str) -> Result<AgentStart, String> {
|
||||
self.asked.lock().unwrap().push(format!("{agent:?} {book}"));
|
||||
self.answer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| Err("no answer set".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One test at a time: they share the one owner.
|
||||
fn alone() -> std::sync::MutexGuard<'static, ()> {
|
||||
static ONE: Mutex<()> = Mutex::new(());
|
||||
ONE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn fake() -> Arc<Fake> {
|
||||
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
|
||||
FAKE.get_or_init(|| {
|
||||
let f = Arc::new(Fake::default());
|
||||
ledger_gui::owner::set_owner(f.clone());
|
||||
f
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Books in a data root, named `acme`.
|
||||
fn books() -> (tempfile::TempDir, PathBuf, ledger_gui::books::Books) {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-gui-dock-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
let book = dir.path().join("books").join("acme");
|
||||
std::fs::create_dir_all(&book).unwrap();
|
||||
drop(Store::create(&book, Currency::USD).unwrap());
|
||||
let b = data::load_once(&Source {
|
||||
data_dir: book.clone(),
|
||||
today: NaiveDate::from_ymd_opt(2026, 10, 15),
|
||||
})
|
||||
.unwrap();
|
||||
(dir, book, b)
|
||||
}
|
||||
|
||||
/// A stand-in `claude`: says where it is, answers a typed line, and reports
|
||||
/// Ctrl-C and a resize (`stty size`) without ending.
|
||||
fn fake_claude(dir: &Path) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = dir.join("claude");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"#!/bin/bash
|
||||
echo "fake-agent in $(basename "$(pwd)")"
|
||||
trap 'echo got-interrupt' INT
|
||||
trap 'echo "size $(stty size)"' WINCH
|
||||
echo ready
|
||||
while true; do
|
||||
if read -r -t 0.1 line; then echo "you-said: $line"; fi
|
||||
done
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn harness(b: ledger_gui::books::Books) -> Harness<'static, App> {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1200.0, 900.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// SHOT_DIR set: the window as drawn, to look at.
|
||||
fn shot(h: &mut Harness<'static, App>, name: &str) {
|
||||
if let Ok(out) = std::env::var("SHOT_DIR") {
|
||||
let out = PathBuf::from(out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
h.render()
|
||||
.unwrap()
|
||||
.save(out.join(format!("{name}.png")))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run until the dock's screen holds `want`.
|
||||
fn until(h: &mut Harness<'static, App>, want: &str) -> String {
|
||||
let end = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
h.run_steps(2);
|
||||
let screen = h.state_mut().dock_mut().screen().unwrap_or_default();
|
||||
if screen.contains(want) {
|
||||
return screen;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < end,
|
||||
"the dock never showed {want:?}; it shows:\n{screen}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_runs_the_chosen_agent() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
let tools = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let home = home.path().join("acme");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
*f.answer.lock().unwrap() = Some(Ok(AgentStart::Ready {
|
||||
label: "Claude Code".into(),
|
||||
program: fake_claude(tools.path()),
|
||||
args: vec![],
|
||||
dir: home.clone(),
|
||||
env: vec![("TERM".into(), "xterm-256color".into())],
|
||||
}));
|
||||
let (_dir, book, b) = books();
|
||||
let before = std::fs::read(book.join("journal.jsonl")).ok();
|
||||
let mut h = harness(b);
|
||||
assert!(
|
||||
h.state_mut().dock_mut().screen().is_none(),
|
||||
"nothing runs until the dock is opened"
|
||||
);
|
||||
|
||||
// Opened: the chosen agent (Claude Code, the default) is started for
|
||||
// this book, in its books home; its output reaches the panel.
|
||||
h.state_mut().dock_mut().open = true;
|
||||
let screen = until(&mut h, "ready");
|
||||
assert!(f.asked.lock().unwrap().contains(&"Claude acme".to_string()));
|
||||
assert!(screen.contains("fake-agent in acme"), "{screen}");
|
||||
|
||||
// Typed text reaches it (and the terminal shows it, as a terminal does).
|
||||
h.event(egui::Event::Text("hello".into()));
|
||||
h.key_press(Key::Enter);
|
||||
until(&mut h, "you-said: hello");
|
||||
|
||||
// Ctrl-C reaches the agent as an interrupt, and it goes on.
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::C);
|
||||
until(&mut h, "got-interrupt");
|
||||
|
||||
// A resize redraws: the program is told the new size.
|
||||
let before_size = h.state_mut().dock_mut().cells().unwrap();
|
||||
h.set_size(egui::vec2(800.0, 900.0));
|
||||
h.run_steps(4);
|
||||
let after = h.state_mut().dock_mut().cells().unwrap();
|
||||
assert!(
|
||||
after.cols < before_size.cols,
|
||||
"{before_size:?} -> {after:?}"
|
||||
);
|
||||
until(&mut h, &format!("size {} {}", after.rows, after.cols));
|
||||
|
||||
shot(&mut h, "dock-running");
|
||||
|
||||
// Closing the window ends the agent with it (the session is dropped).
|
||||
drop(h);
|
||||
// The dock wrote nothing of the books.
|
||||
assert_eq!(std::fs::read(book.join("journal.jsonl")).ok(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_missing_offers_the_installer() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
*f.answer.lock().unwrap() = Some(Ok(AgentStart::Missing(Installer {
|
||||
label: "Claude Code".into(),
|
||||
command: "curl -fsSL https://claude.ai/install.sh | bash".into(),
|
||||
page: "https://code.claude.com/docs/en/setup".into(),
|
||||
})));
|
||||
let (_dir, _book, b) = books();
|
||||
let mut h = harness(b);
|
||||
h.state_mut().dock_mut().open = true;
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if h.query_by_label_contains("isn't installed here").is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
h.get_by_label_contains("Claude Code isn't installed here");
|
||||
h.get_by_label_contains("curl -fsSL https://claude.ai/install.sh | bash");
|
||||
h.get_by_label_contains("Copy");
|
||||
shot(&mut h, "dock-missing");
|
||||
// Nothing runs; nothing was installed by the app.
|
||||
assert!(h.state_mut().dock_mut().screen().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_agent_and_an_open_dock_are_kept() {
|
||||
let _alone = alone();
|
||||
fake();
|
||||
// Preferences: the agent and whether it is open are the viewer's.
|
||||
let kept = |k: &str| match k {
|
||||
"dock_agent" => Some("codex".to_string()),
|
||||
"dock_open" => Some("true".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let dock = ledger_gui::dock::Dock::restored(kept);
|
||||
assert_eq!(dock.agent, Agent::Codex);
|
||||
assert!(dock.open);
|
||||
let mut seen = Vec::new();
|
||||
dock.keep(&mut |k, v| seen.push((k.to_string(), v)));
|
||||
assert!(seen.contains(&("dock_agent".to_string(), "codex".to_string())));
|
||||
assert!(seen.contains(&("dock_open".to_string(), "true".to_string())));
|
||||
let none = ledger_gui::dock::Dock::restored(|_| None);
|
||||
assert_eq!(none.agent, Agent::Claude, "Claude Code is the default");
|
||||
assert!(!none.open);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! The first-run page (welcome.rs, plan P11b): with no books yet, the viewer
|
||||
//! sets up the private folder, asks for the owner passphrase, makes the first
|
||||
//! books, connects an agent and opens the books, each through the owner and
|
||||
//! none needing a terminal.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::Currency;
|
||||
use ledger_gui::App;
|
||||
use ledger_gui::owner::{Agent, FirstRun, Owner, RestorePlan};
|
||||
use ledger_store::root::{BookId, Root};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// An owner over a real data root in a scratch folder, that records what it
|
||||
/// was asked (the first run's steps), and refuses what it should.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
place: Mutex<Option<(PathBuf, PathBuf)>>,
|
||||
asked: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn reset(&self, home: PathBuf, root: PathBuf) {
|
||||
*self.place.lock().unwrap() = Some((home, root));
|
||||
self.asked.lock().unwrap().clear();
|
||||
}
|
||||
fn said(&self, what: String) {
|
||||
self.asked.lock().unwrap().push(what);
|
||||
}
|
||||
fn root_path(&self) -> (PathBuf, PathBuf) {
|
||||
self.place.lock().unwrap().clone().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Owner for Fake {
|
||||
fn close_books(&self) -> Result<String, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
|
||||
self.said(format!("new {id} {currency}"));
|
||||
let (_, root) = self.root_path();
|
||||
let root = Root::find(Some(&root)).map_err(|e| e.to_string())?;
|
||||
let id = BookId::parse(id).map_err(|e| e.to_string())?;
|
||||
root.create_book(&id, Currency::new(currency).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!("Made new books \"{id}\" ({currency})."))
|
||||
}
|
||||
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String> {
|
||||
self.said(format!("connect {agent:?} {book}"));
|
||||
match agent {
|
||||
Agent::Claude => Ok(vec!["Claude Code reaches the books.".into()]),
|
||||
Agent::Codex => Err("Codex isn't installed here".into()),
|
||||
}
|
||||
}
|
||||
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not here".into())
|
||||
}
|
||||
fn first_run(&self) -> FirstRun {
|
||||
let (_, root) = self.root_path();
|
||||
let Ok(found) = Root::find(Some(&root)) else {
|
||||
return FirstRun::default();
|
||||
};
|
||||
FirstRun {
|
||||
root: true,
|
||||
books: found
|
||||
.list_books()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|b| b.as_str().to_string())
|
||||
.collect(),
|
||||
passphrase_set: ledger_store::passphrase::is_set(&found.auth_dir().unwrap()).unwrap(),
|
||||
}
|
||||
}
|
||||
fn set_up(&self) -> Result<Vec<String>, String> {
|
||||
self.said("set up".into());
|
||||
let (home, root) = self.root_path();
|
||||
Root::setup(&home, Some(&root), None).map_err(|e| e.to_string())?;
|
||||
Ok(vec!["Made the books' private folder.".into()])
|
||||
}
|
||||
fn set_passphrase(&self, new: &str) -> Result<(), String> {
|
||||
self.said(format!("passphrase of {} characters", new.chars().count()));
|
||||
let (_, root) = self.root_path();
|
||||
let found = Root::find(Some(&root)).map_err(|e| e.to_string())?;
|
||||
ledger_store::passphrase::set(&found.auth_dir().unwrap(), new, None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
fn hold(&self, book: &str) {
|
||||
self.said(format!("hold {book}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// One test at a time: they share the one owner.
|
||||
fn alone() -> std::sync::MutexGuard<'static, ()> {
|
||||
static ONE: Mutex<()> = Mutex::new(());
|
||||
ONE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn fake() -> Arc<Fake> {
|
||||
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
|
||||
FAKE.get_or_init(|| {
|
||||
let f = Arc::new(Fake::default());
|
||||
ledger_gui::owner::set_owner(f.clone());
|
||||
f
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn harness(root: PathBuf, state: FirstRun) -> Harness<'static, App> {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1000.0, 1100.0))
|
||||
.build_eframe(move |cc| {
|
||||
App::first_run(
|
||||
cc,
|
||||
Some(root.clone()),
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 10, 15),
|
||||
state.clone(),
|
||||
)
|
||||
});
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Run until `f` holds (a step's worker answered).
|
||||
fn until(h: &mut Harness<'static, App>, what: &str, f: impl Fn(&Harness<'static, App>) -> bool) {
|
||||
for _ in 0..300 {
|
||||
h.run_steps(2);
|
||||
if f(h) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
shot(h, "welcome-stuck");
|
||||
panic!("never: {what}");
|
||||
}
|
||||
|
||||
fn shot(h: &mut Harness<'static, App>, name: &str) {
|
||||
if let Ok(out) = std::env::var("SHOT_DIR") {
|
||||
let out = PathBuf::from(out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
h.render()
|
||||
.unwrap()
|
||||
.save(out.join(format!("{name}.png")))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-gui-welcome-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let home = dir.path().join("home");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let root = dir.path().join("data");
|
||||
(dir, home, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_onboards_with_no_terminal() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
let (_dir, home, root) = scratch();
|
||||
f.reset(home, root.clone());
|
||||
let mut h = harness(root.clone(), FirstRun::default());
|
||||
shot(&mut h, "welcome-1");
|
||||
|
||||
// 1. The private folder: set up from the page.
|
||||
h.get_by_label("Set it up").click();
|
||||
until(&mut h, "the folder is made", |h| {
|
||||
h.query_by_label_contains("Made. Only your account")
|
||||
.is_some()
|
||||
});
|
||||
assert!(h.state().welcome().unwrap().state().root);
|
||||
|
||||
// 2. The passphrase: typed twice, handed to the owner, gone from the page.
|
||||
assert_eq!(
|
||||
h.query_all_by_role(egui::accesskit::Role::PasswordInput)
|
||||
.count(),
|
||||
2,
|
||||
"two password fields"
|
||||
);
|
||||
for i in 0..2 {
|
||||
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
||||
.nth(i)
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
||||
.nth(i)
|
||||
.unwrap()
|
||||
.type_text("a long enough phrase");
|
||||
h.run_steps(2);
|
||||
}
|
||||
shot(&mut h, "welcome-2");
|
||||
h.get_by_label("Set passphrase").click();
|
||||
until(&mut h, "the passphrase is set", |h| {
|
||||
h.state().welcome().unwrap().state().passphrase_set
|
||||
});
|
||||
|
||||
// 3. The first books.
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.next()
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.next()
|
||||
.unwrap()
|
||||
.type_text("acme");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Make the books").click();
|
||||
until(&mut h, "the books are made", |h| {
|
||||
h.state().welcome().unwrap().state().books == ["acme"]
|
||||
});
|
||||
shot(&mut h, "welcome-3");
|
||||
|
||||
// 4. An agent: Claude Code connects; Codex isn't installed, and says so.
|
||||
h.get_by_label("Connect Codex").click();
|
||||
until(&mut h, "Codex is refused", |h| {
|
||||
h.query_all_by_label_contains("Codex isn't installed here")
|
||||
.next()
|
||||
.is_some()
|
||||
});
|
||||
h.get_by_label("Connect Claude Code").click();
|
||||
until(&mut h, "Claude Code is connected", |h| {
|
||||
h.query_all_by_label_contains("Claude Code reaches the books")
|
||||
.next()
|
||||
.is_some()
|
||||
});
|
||||
shot(&mut h, "welcome-4");
|
||||
|
||||
// 5. Open the books: the viewer shows them.
|
||||
h.get_by_label_contains("Open the books").click();
|
||||
until(&mut h, "the books are shown", |h| {
|
||||
h.query_by_label_contains("Needs attention").is_some()
|
||||
});
|
||||
assert!(h.state().welcome().is_none(), "the page is gone");
|
||||
shot(&mut h, "welcome-5");
|
||||
|
||||
// What the owner was asked, in order, and nothing else.
|
||||
let asked = f.asked.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
asked,
|
||||
[
|
||||
"set up",
|
||||
"passphrase of 20 characters",
|
||||
"new acme USD",
|
||||
"hold acme",
|
||||
"connect Codex acme",
|
||||
"connect Claude acme",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_starts_at_the_first_step_missing_and_asks_nothing_done_again() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
let (_dir, home, root) = scratch();
|
||||
f.reset(home.clone(), root.clone());
|
||||
// Done at a terminal already: the folder and the passphrase.
|
||||
f.set_up().unwrap();
|
||||
f.set_passphrase("a long enough phrase").unwrap();
|
||||
f.asked.lock().unwrap().clear();
|
||||
let state = f.first_run();
|
||||
assert_eq!(
|
||||
state,
|
||||
FirstRun {
|
||||
root: true,
|
||||
books: vec![],
|
||||
passphrase_set: true
|
||||
}
|
||||
);
|
||||
let h = harness(root, state);
|
||||
// No folder or passphrase to ask for; the books are.
|
||||
assert!(h.query_by_label("Set it up").is_none());
|
||||
assert!(h.query_by_label("Set passphrase").is_none());
|
||||
h.get_by_label("Make the books");
|
||||
assert!(f.asked.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_passphrase_too_short_or_not_repeated_is_not_sent_and_can_wait() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
let (_dir, home, root) = scratch();
|
||||
f.reset(home, root.clone());
|
||||
f.set_up().unwrap();
|
||||
f.asked.lock().unwrap().clear();
|
||||
let mut h = harness(root, f.first_run());
|
||||
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
||||
.next()
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
||||
.next()
|
||||
.unwrap()
|
||||
.type_text("short");
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("characters or more, please");
|
||||
// Too short: the button does nothing.
|
||||
h.get_by_label("Set passphrase").click();
|
||||
h.run_steps(3);
|
||||
assert!(f.asked.lock().unwrap().is_empty());
|
||||
// Not now: the books are asked for next, and nothing was sent.
|
||||
h.get_by_label("Not now").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Make the books");
|
||||
assert!(f.asked.lock().unwrap().is_empty());
|
||||
assert!(!f.first_run().passphrase_set);
|
||||
}
|
||||
Reference in new issue
Block a user