From 24e3bb54db579fb9aad8df2a591527dc77fe4cb4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 19:19:49 +0100 Subject: v4.0.7: finish the Phase 1 boundary with a command shell The terminal command line moves to client::shell: a typed line becomes a core Command or an adapter action (connect, login, register, set-credential, host maintenance). ClientCore loses submit_line and the last TUI-shaped effects (Interactive, SetPrompt, ListHosts, ForgetHost, AnimateRoulette); the adapter animates roulette from the Result view. Adds forget-login, which drops the stored credential for the pinned key and keeps trust, distinct from logout (session only) and forget-host (trust too). --- src/bin/client/repl.rs | 190 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 59 deletions(-) (limited to 'src/bin') diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index bc4238d..cfdcdd5 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -13,12 +13,14 @@ use crossterm::{ use sha2::{Digest, Sha256}; -use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; -use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor}; +use zagan_bet::client::core::{ClientCore, Effect}; +use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; +use zagan_bet::client::shell::{self, Shell}; +use zagan_bet::client::view::{RoulettePhase, View}; use zagan_bet::client::database; use zagan_bet::client::ui::{ - display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, - terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, + display_width, format_pong, help_lines, host_lines, input_cursor_column, + lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, }; use zagan_bet::protocol::io_failure_message; use zagan_bet::roulette::{WHEEL, color as roulette_color}; @@ -42,6 +44,8 @@ pub struct ClientState { terminal_size: (u16, u16), /// Size we already warned about, so a resize does not spam the transcript. size_warned: Option<(u16, u16)>, + /// Last roulette result the wheel animation ran for. + last_roulette: Option, } impl ClientState { @@ -67,6 +71,7 @@ impl ClientState { prompt_text: String::new(), terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), size_warned: None, + last_roulette: None, }) } @@ -129,7 +134,7 @@ impl ClientState { } else { match self.editor.handle(key) { Input::Quit => vec![Effect::Quit], - Input::Submit(line) => self.core.submit_line(&line), + Input::Submit(line) => self.submit_line(&line), Input::Print(lines) => vec![Effect::Print(lines)], Input::Prompt | Input::None => Vec::new(), } @@ -233,17 +238,26 @@ impl ClientState { } } Effect::Render(view) => { + if let View::Roulette(roulette) = &view { + if roulette.phase == RoulettePhase::Result { + if let Some(number) = roulette.number { + if self.last_roulette != Some(number) { + self.animate_wheel(number); + self.last_roulette = Some(number); + while crossterm::event::poll(std::time::Duration::ZERO) + .unwrap_or(false) + { + let _ = crossterm::event::read(); + } + } + } + } else { + self.last_roulette = None; + } + } let lines = zagan_bet::client::tui::render_view(&view); self.draw_region(&lines); } - Effect::AnimateRoulette { winning, view } => { - self.animate_wheel(winning); - let lines = zagan_bet::client::tui::render_view(&view); - self.draw_region(&lines); - while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { - let _ = crossterm::event::read(); - } - } Effect::ClearRegion => self.clear_region(), Effect::SendLine(line) => { let is_ping = line == "PING"; @@ -269,37 +283,11 @@ impl ClientState { return true; } } - Effect::Interactive(command) => { - self.commit_prompt_line(); - self.run_interactive(command); - } Effect::Disconnect => { self.stream = None; + self.core.reset_after_disconnect(); self.clear_region(); } - Effect::ListHosts => { - self.commit_prompt_line(); - self.list_hosts(); - } - Effect::ForgetHost(target) => { - self.commit_prompt_line(); - match database::forget_host(&self.conn, &target) { - Ok(outcome) if outcome.credentials_removed => println!( - "Cleared trust and stored credentials for {}.", - target - ), - Ok(outcome) => { - println!("Cleared trust for {}.", target); - if !outcome.siblings.is_empty() { - println!( - "Stored credentials kept: this server is still reachable as {}.", - outcome.siblings.join(", ") - ); - } - } - Err(error) => println!("Failed to forget host: {}", error), - } - } Effect::Quit => { let had_board = self.game_lines > 0; self.clear_region(); @@ -545,28 +533,112 @@ impl ClientState { } } - fn run_interactive(&mut self, command: InteractiveCommand) { - match command { - InteractiveCommand::Login => self.do_login(), - InteractiveCommand::Register => self.do_register(), - InteractiveCommand::SetCred => self.do_setcred(), - InteractiveCommand::Connect(target) => { - if self.stream.is_some() { - println!("Already connected. Disconnect first."); + /// Run one command line from the prompt. + fn submit_line(&mut self, line: &str) -> Vec { + match shell::parse(line) { + Shell::Run(command) => self.core.command(command), + Shell::Login => { + self.do_login(); + Vec::new() + } + Shell::Register => { + self.do_register(); + Vec::new() + } + Shell::SetCred => { + self.do_setcred(); + Vec::new() + } + Shell::Connect(target) => { + self.do_connect(target); + Vec::new() + } + Shell::Disconnect => { + self.stream = None; + self.core.reset_after_disconnect(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Disconnected".to_string()]), + ] + } + Shell::Hosts => { + self.list_hosts(); + Vec::new() + } + Shell::ForgetHost(target) => { + self.forget_host(target); + Vec::new() + } + Shell::ForgetLogin(target) => { + self.forget_login(target); + Vec::new() + } + Shell::Help => vec![Effect::Print(help_lines())], + Shell::Quit => vec![Effect::Quit], + Shell::Empty => Vec::new(), + Shell::Usage(message) => vec![Effect::Print(vec![message])], + } + } + + fn do_connect(&mut self, target: Option) { + if self.stream.is_some() { + println!("Already connected. Disconnect first."); + return; + } + let addr = match target { + Some(target) => self.normalize_addr(target), + None => match &self.addr { + Some(addr) => addr.clone(), + None => { + println!("No host specified. Usage: connect :"); return; } - let addr = match target { - Some(target) => self.normalize_addr(target), - None => match &self.addr { - Some(addr) => addr.clone(), - None => { - println!("No host specified. Usage: connect :"); - return; - } - }, - }; - self.connect_to(&addr); + }, + }; + self.connect_to(&addr); + } + + /// Drop the stored login for one server, keeping the pinned key. + fn forget_login(&mut self, target: Option) { + let Some(host) = target.or_else(|| self.addr.clone()) else { + println!("Usage: forget-login [ip:port] (or connect first)"); + return; + }; + let Some(identity) = database::get_fingerprint(&self.conn, &host) else { + println!("No trusted server is remembered as {}.", host); + return; + }; + match database::forget_credentials(&self.conn, &identity) { + Ok(true) => println!( + "Forgot the saved login for server {} ({}). Trust is unchanged.", + short_fingerprint(&identity), + host + ), + Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), + Err(error) => println!("Failed to forget the saved login: {}", error), + } + } + + fn forget_host(&mut self, target: Option) { + let Some(target) = target.or_else(|| self.addr.clone()) else { + println!("Usage: forget-host : (or connect first)"); + return; + }; + match database::forget_host(&self.conn, &target) { + Ok(outcome) if outcome.credentials_removed => println!( + "Cleared trust and stored credentials for {}.", + target + ), + Ok(outcome) => { + println!("Cleared trust for {}.", target); + if !outcome.siblings.is_empty() { + println!( + "Stored credentials kept: this server is still reachable as {}.", + outcome.siblings.join(", ") + ); + } } + Err(error) => println!("Failed to forget host: {}", error), } } -- cgit v1.2.3