diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 06b458550..567da30ce 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -13,6 +13,11 @@ use std::{collections::BTreeMap, path::PathBuf}; #[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Action { + Restart {}, + PreviewUpdate {}, + Update { + preview_token: Option, + }, OpenLogs {}, OpenUpdateLog {}, OpenConfig {}, @@ -20,6 +25,20 @@ pub enum Action { OpenSettings {}, CheckConfig {}, ServerKeys {}, + SaveServerKey { + name: String, + key: String, + }, + ImportServerKey { + name: String, + }, + RemoveServerKey { + name: String, + }, + SavePreferences { + baseline_model: String, + refresh_seconds: u64, + }, } /// Snapshot holds the data shown by the apps. The terminal app destructures every field, @@ -116,6 +135,7 @@ pub struct Controller { config: Config, reader: rollup::Reader, health_probe: crate::health::Probe, + update_review: Option<(String, Value)>, } impl Controller { pub fn new(config: Config, settings: PathBuf) -> Self { @@ -124,12 +144,25 @@ impl Controller { settings, reader: rollup::Reader::default(), health_probe: crate::health::Probe::default(), + update_review: None, } } fn reload(&mut self) -> Result<(), String> { self.config = Config::load(&self.settings)?; Ok(()) } + // Key actions accept only names that the server config declares. + fn declared_key(&self, name: &str) -> Result<(), String> { + if switchyard_desktop::install::credentials::variables(&self.config.config_file)? + .contains(name) + { + Ok(()) + } else { + Err(format!( + "The server config does not name {name} with api_key_env. Refresh and try again." + )) + } + } pub fn snapshot(&mut self) -> Result { self.reload()?; let mut errors = Vec::new(); @@ -274,13 +307,83 @@ impl Controller { server::command("open", &[&logs_dir()?.display().to_string()])?; Ok(Reply::message("Opened Switchyard logs.".into())) } + Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), Action::ServerKeys {} => Ok(Reply { message: String::new(), data: json!({"keys": server::server_keys(&self.config)?, "store": switchyard_desktop::install::credentials::STORE}), }), + Action::SaveServerKey { name, key } => { + self.declared_key(&name)?; + server::save_server_key(&name, &key)?; + Ok(Reply::message(format!( + "Saved the key for {name}. Restart the server to use it." + ))) + } + Action::ImportServerKey { name } => { + self.declared_key(&name)?; + server::import_server_key(&name)?; + Ok(Reply::message(format!( + "Saved {name} from your login shell. Restart the server to use it." + ))) + } + Action::RemoveServerKey { name } => { + self.declared_key(&name)?; + server::remove_server_key(&name)?; + Ok(Reply::message(format!( + "Removed the saved key for {name}. The server will not start until it has one." + ))) + } + Action::SavePreferences { + baseline_model, + refresh_seconds, + } => { + crate::config::save_preferences(&self.settings, &baseline_model, refresh_seconds)?; + self.reload()?; + Ok(Reply::message(format!( + "Changed {}.", + self.settings.display() + ))) + } Action::OpenSettings {} => unreachable!(), + Action::PreviewUpdate {} => { + let mut data = switchyard_desktop::install::preview_update(&install_metadata( + &self.settings, + )?)?; + let token = uuid::Uuid::now_v7().to_string(); + let mut reviewed = data.clone(); + reviewed + .as_object_mut() + .ok_or("Invalid update preview.")? + .remove("status"); + self.update_review = Some((token.clone(), reviewed)); + data["preview_token"] = json!(token); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Update { preview_token } => { + let (token, reviewed) = self + .update_review + .as_ref() + .ok_or("Review the source update first.")?; + let mut current = switchyard_desktop::install::preview_update(&install_metadata( + &self.settings, + )?)?; + current + .as_object_mut() + .ok_or("Invalid update preview.")? + .remove("status"); + if preview_token.as_deref() != Some(token.as_str()) || ¤t != reviewed { + return Err( + "The source checkout or installation changed. Review the update again." + .into(), + ); + } + update(&self.settings).map(Reply::message) + } Action::OpenUpdateLog {} => { let log = self.update_log()?; server::command("open", &[&log.display().to_string()])?; @@ -323,6 +426,16 @@ fn install_metadata(settings: &std::path::Path) -> Result { }; Ok(metadata) } +fn update(settings: &std::path::Path) -> Result { + let metadata = install_metadata(settings)?; + let log = switchyard_desktop::install::start_update(&metadata)?; + Ok(format!( + "Started rebuilding and reinstalling from the saved source checkout. \ + Progress and errors are recorded in {}. \ + The app restarts after a successful install.", + log.display() + )) +} #[cfg(test)] mod tests { diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index f2ed8a78b..9a6429026 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -473,6 +473,17 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> { Ok(()) } +pub fn restart(s: &Settings) -> Result<(), String> { + stop_task(s, false)?; + stop_images(&[s.binary("switchyard-server")])?; + // The terminal app calls this, so the scheduler command must not print onto its screen. + if task_succeeds(s, false, "/Run")? { + Ok(()) + } else { + Err(format!("schtasks /Run /TN {} failed.", task_name(s, false))) + } +} + pub fn open(value: &str) -> Result<(), String> { use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}; let value = wide(std::ffi::OsStr::new(value)); diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index cdb286a04..e289dd952 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -6,10 +6,15 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use switchyard_desktop::install::credentials::KeyStatus; use crate::config::Config; +use crate::health::{Probe, ServerStatus}; + +/// RESTART_WAIT limits how long the app waits for the restarted server to answer `/health`. +const RESTART_WAIT: Duration = Duration::from_secs(10); /// This function finds the installed helper, which is the only program that reads or writes /// server keys. @@ -43,10 +48,14 @@ fn key_command(args: &[&str], key: Option<&str>) -> Result { helper.display() ) })?; - if let (Some(key), Some(mut stdin)) = (key, child.stdin.take()) { - stdin - .write_all(key.as_bytes()) - .map_err(|error| format!("Could not pass the key to the helper: {error}"))?; + if let (Some(key), Some(mut stdin)) = (key, child.stdin.take()) + && let Err(error) = stdin.write_all(key.as_bytes()) + { + // The app tries to stop and reap the helper while preserving the original input error. + // Cleanup errors may occur if the helper already exited. + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("Could not pass the key to the helper: {error}")); } let output = child .wait_with_output() @@ -67,6 +76,18 @@ pub fn server_keys(settings: &Config) -> Result, String> { serde_json::from_str(&output).map_err(|error| error.to_string()) } +pub fn save_server_key(name: &str, key: &str) -> Result<(), String> { + key_command(&["set", name], Some(key)).map(drop) +} + +pub fn import_server_key(name: &str) -> Result<(), String> { + key_command(&["import", name], None).map(drop) +} + +pub fn remove_server_key(name: &str) -> Result<(), String> { + key_command(&["remove", name], None).map(drop) +} + /// This function finds the installed server from the app's installation metadata. fn server_binary() -> Result { let executable = std::env::current_exe().map_err(|error| { @@ -121,6 +142,55 @@ pub fn check_config(settings: &Config) -> Result<(), String> { check(&server_binary()?, &settings.config_file) } +pub fn restart_checked(settings: &Config) -> Result { + check_config(settings)?; + restart(&settings.launchd_label)?; + if !wait_for_health(&settings.server_url) { + return Err(format!( + "Switchyard did not answer {}/health within {} seconds. Open the server log and retry.", + settings.server_url.trim_end_matches('/'), + RESTART_WAIT.as_secs() + )); + } + Ok("Restarted Switchyard.".into()) +} + +/// This function restarts the server's LaunchAgent, systemd user service, or Windows scheduled task. +pub fn restart(launchd_label: &str) -> Result<(), String> { + #[cfg(windows)] + { + let _ = launchd_label; + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let settings = switchyard_desktop::install::Settings::load( + &switchyard_desktop::install::bundle_metadata(&exe), + )?; + switchyard_desktop::install::windows::restart(&settings) + } + #[cfg(not(windows))] + { + if cfg!(target_os = "linux") { + return command("systemctl", &["--user", "restart", "switchyard.service"]).map(|_| ()); + } + let uid = command("id", &["-u"])?; + let target = format!("gui/{}/{launchd_label}", uid.trim()); + command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) + } +} + +pub(crate) fn wait_for_health(server_url: &str) -> bool { + let deadline = Instant::now() + RESTART_WAIT; + let probe = Probe::default(); + loop { + if probe.probe(server_url) == ServerStatus::Running { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } +} + /// This function runs a command, returning its stdout or a message naming what failed. pub fn command(program: &str, args: &[&str]) -> Result { if program == "open" { diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index ff49926ee..b3db4bf69 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -102,7 +102,18 @@ const FILTER_KEYS: &[Key] = &[ ("esc", "clear", "Clear the filter."), ("ctrl-u", "erase", "Erase the filter text."), ]; +const FORM_KEYS: &[Key] = &[ + ("⏎", "submit", ""), + ("tab", "next field", ""), + ("esc", "cancel", ""), +]; +const UPDATE_KEYS: &[Key] = &[ + ("⏎", "start", ""), + ("↑↓", "scroll", ""), + ("esc", "cancel", ""), +]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; +const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; /// The Settings page uses these entries for action labels, help text, and shortcut keys. const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ @@ -118,12 +129,25 @@ const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ "Runs the installed server's config check. The running server keeps running.", ActionKind::CheckConfig, ), + ( + 'b', + "Restart server", + "Checks the server config, then restarts the Switchyard service after you confirm. \ + Requests in progress fail.", + ActionKind::Restart, + ), ( 'a', "Open app settings", "Opens this app's settings file in $VISUAL or $EDITOR.", ActionKind::OpenSettings, ), + ( + 'e', + "Change preferences", + "Changes the refresh interval and the baseline model used to estimate savings.", + ActionKind::SavePreferences, + ), ( 'p', "Open model prices", @@ -137,6 +161,31 @@ const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ "Lists the API key variables that the server config names and whether each is saved.", ActionKind::ServerKeys, ), + ( + 's', + "Save a server key", + "Saves an API key for a variable that the server config names. Restart the server to \ + use it.", + ActionKind::SaveServerKey, + ), + ( + 'i', + "Import a server key", + "Saves an API key from your login shell's environment. Restart the server to use it.", + ActionKind::ImportServerKey, + ), + ( + 'x', + "Remove a server key", + "Removes a saved API key. The server does not start until the key is saved again.", + ActionKind::RemoveServerKey, + ), + ( + 'u', + "Update from source", + "Shows what the saved source checkout would install. Type UPDATE to start it.", + ActionKind::PreviewUpdate, + ), ( 'l', "Open update log", @@ -275,6 +324,139 @@ impl Period { } } +#[derive(Clone, Copy, Debug, PartialEq)] +enum FormKind { + Preferences, + SaveKey, + ImportKey, + RemoveKey, + Update, +} +/// Form holds the text fields for an action that needs input. +struct Form { + kind: FormKind, + title: &'static str, + fields: Vec<(&'static str, String)>, + index: usize, + /// This field counts characters, not bytes, from the start of the selected field. + cursor: usize, + /// This field shows the last validation or controller error below the fields. + error: String, +} +impl Form { + fn new(kind: FormKind, title: &'static str, fields: Vec<(&'static str, String)>) -> Self { + let cursor = fields.first().map_or(0, |field| field.1.chars().count()); + Self { + kind, + title, + fields, + index: 0, + cursor, + error: String::new(), + } + } + fn byte_cursor(&self) -> usize { + let text = &self.fields[self.index].1; + text.char_indices() + .nth(self.cursor) + .map_or(text.len(), |(index, _)| index) + } + fn insert(&mut self, text: &str) { + let text = text.replace(['\r', '\n'], ""); + let index = self.byte_cursor(); + self.fields[self.index].1.insert_str(index, &text); + self.cursor += text.chars().count(); + } + fn edit(&mut self, key: KeyEvent) { + let length = self.fields[self.index].1.chars().count(); + match key.code { + KeyCode::Tab | KeyCode::BackTab => { + let count = self.fields.len(); + self.index = if key.code == KeyCode::Tab { + (self.index + 1) % count + } else { + (self.index + count - 1) % count + }; + self.cursor = self.fields[self.index].1.chars().count(); + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(length), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = length, + KeyCode::Backspace if self.cursor > 0 => { + self.cursor -= 1; + let index = self.byte_cursor(); + self.fields[self.index].1.remove(index); + } + KeyCode::Delete if self.cursor < length => { + let index = self.byte_cursor(); + self.fields[self.index].1.remove(index); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.fields[self.index].1.clear(); + self.cursor = 0; + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.insert(&c.to_string()); + } + _ => {} + } + } + fn value(&self, index: usize) -> String { + self.fields[index].1.trim().to_string() + } + /// This function builds the controller action that the form submits. An update needs the + /// token from the preview that the user read. + fn action(&self, preview: Option<&Value>) -> Result { + Ok(match self.kind { + FormKind::Preferences => Action::SavePreferences { + baseline_model: self.value(0), + refresh_seconds: self.value(1).parse().map_err(|_| { + "Enter the refresh interval as a whole number of seconds.".to_string() + })?, + }, + FormKind::SaveKey => { + if self.value(1).is_empty() { + return Err("Enter a key first.".into()); + } + Action::SaveServerKey { + name: self.value(0), + key: self.value(1), + } + } + FormKind::ImportKey => Action::ImportServerKey { + name: self.value(0), + }, + FormKind::RemoveKey => Action::RemoveServerKey { + name: self.value(0), + }, + FormKind::Update => { + if self.value(0) != "UPDATE" { + return Err("Type UPDATE to start the reviewed source update.".into()); + } + Action::Update { + preview_token: preview + .and_then(|preview| preview["preview_token"].as_str()) + .map(str::to_owned), + } + } + }) + } + fn label(&self) -> &'static str { + match self.kind { + FormKind::Preferences => "Saving the preferences", + FormKind::SaveKey => "Saving the server key", + FormKind::ImportKey => "Importing the server key", + FormKind::RemoveKey => "Removing the server key", + FormKind::Update => "Starting the update", + } + } +} + enum Popup { Help { scroll: u16, @@ -284,6 +466,13 @@ enum Popup { entry: Box, scroll: u16, }, + Restart, + Form(Form), + Update { + preview: Value, + scroll: u16, + confirm: Form, + }, Message { title: &'static str, text: String, @@ -292,11 +481,19 @@ enum Popup { }, } impl Popup { - fn scroll_mut(&mut self) -> &mut u16 { + fn scroll_mut(&mut self) -> Option<&mut u16> { match self { Popup::Help { scroll } | Popup::Detail { scroll, .. } - | Popup::Message { scroll, .. } => scroll, + | Popup::Update { scroll, .. } + | Popup::Message { scroll, .. } => Some(scroll), + Popup::Restart | Popup::Form(_) => None, + } + } + fn form_mut(&mut self) -> Option<&mut Form> { + match self { + Popup::Form(form) | Popup::Update { confirm: form, .. } => Some(form), + _ => None, } } } @@ -339,8 +536,11 @@ struct Busy { label: &'static str, /// This field names the running action, so its reply can update the right view. kind: Option, + /// This field records the popup generation when the action starts. + popup_generation: u64, started: Instant, - /// The UI retains the refresh origin so automatic failures preserve the current popup. + /// Automatic refreshes report errors in the status bar, and automatic key checks update + /// Settings without opening a popup. automatic: bool, } struct Toast { @@ -366,6 +566,8 @@ struct App { /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, popup: Option, + /// This counter changes whenever a popup opens, closes, or is replaced. + popup_generation: u64, toast: Option, busy: Option, /// A quit request waits for the active refresh to finish. @@ -391,6 +593,7 @@ impl App { action: 0, server_keys: None, popup: None, + popup_generation: 0, toast: None, busy: None, quit: false, @@ -424,18 +627,37 @@ impl App { .map(|&index| &self.data.entries[index]) } + // Every replacement or dismissal changes the generation, even when the same form reopens. + fn set_popup(&mut self, popup: Option) { + self.popup_generation += 1; + self.popup = popup; + } + + /// A reply from a dismissed or replaced popup uses the status bar to preserve the current draft. + fn report(&mut self, text: String, error: bool, popup_matches: bool) { + if popup_matches { + self.notify(text, error); + } else if !text.is_empty() { + self.toast = Some(Toast { + text, + error, + shown: Instant::now(), + }); + } + } + /// Short single-line messages appear in the status bar; others open a scrollable window. fn notify(&mut self, text: String, error: bool) { if text.is_empty() { return; } if text.contains('\n') || text.chars().count() + 8 > usize::from(self.width) * 2 / 3 { - self.popup = Some(Popup::Message { + self.set_popup(Some(Popup::Message { title: if error { "Error" } else { "Done" }, text, error, scroll: 0, - }); + })); } else { self.toast = Some(Toast { text, @@ -449,10 +671,14 @@ impl App { &mut self, reply: Option>, snapshot: Result, String>, - ) { + ) -> Option { let busy = self.busy.take(); let automatic = busy.as_ref().is_some_and(|busy| busy.automatic); - let kind = busy.and_then(|busy| busy.kind); + let kind = busy.as_ref().and_then(|busy| busy.kind); + // The reply's popup must be identified before snapshot errors can change the popup. + let popup_matches = busy + .as_ref() + .is_none_or(|busy| !busy.automatic && busy.popup_generation == self.popup_generation); match snapshot { Ok(data) => { // A refresh follows the newest call or restores an older selection if it is still visible. @@ -479,7 +705,7 @@ impl App { } // Overview keeps the error after the status message is dismissed. self.data.errors = vec![error.clone()]; - if automatic { + if automatic || !popup_matches || self.popup.is_some() { self.toast = Some(Toast { text: error.lines().next().unwrap_or_default().into(), error: true, @@ -490,15 +716,40 @@ impl App { } } } - let Some(reply) = reply else { - return; - }; + let reply = reply?; match (kind, reply) { (Some(ActionKind::ServerKeys), Err(error)) => { self.server_keys = None; - self.notify(error, true); + self.report(error, true, popup_matches); + } + // A form stays open with the error, so the user can fix the input and submit again. + (_, Err(error)) => { + if popup_matches && let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { + form.error = error; + } else { + self.report(error, true, popup_matches); + } + } + (Some(ActionKind::PreviewUpdate), Ok(reply)) => { + if !popup_matches { + self.report( + "The source update preview finished. Press u in Settings to review it." + .into(), + false, + false, + ); + return None; + } + self.set_popup(Some(Popup::Update { + preview: reply.data, + scroll: 0, + confirm: Form::new( + FormKind::Update, + "Update from source?", + vec![("Type UPDATE to start", String::new())], + ), + })); } - (_, Err(error)) => self.notify(error, true), (Some(ActionKind::ServerKeys), Ok(reply)) => { let text = key_lines(self.theme, Some(&reply.data)) .iter() @@ -506,36 +757,80 @@ impl App { .collect::>() .join("\n"); self.server_keys = Some(reply.data); - self.popup = Some(Popup::Message { + if !popup_matches { + if !automatic { + self.report("Checked the saved server keys.".into(), false, false); + } + return None; + } + self.set_popup(Some(Popup::Message { title: "Server keys", text, error: false, scroll: 0, - }); + })); + } + ( + Some( + ActionKind::SaveServerKey + | ActionKind::ImportServerKey + | ActionKind::RemoveServerKey, + ), + Ok(reply), + ) => { + if popup_matches { + self.set_popup(None); + } + self.report(reply.message, false, popup_matches); + let mut request = Request::run(Action::ServerKeys {}, "Checking the server keys"); + request.automatic = true; + return Some(request); } (_, Ok(reply)) => { + if popup_matches + && matches!(self.popup, Some(Popup::Form(_) | Popup::Update { .. })) + { + self.set_popup(None); + } let details: Vec<&str> = reply.data["details"] .as_array() .map(|items| items.iter().filter_map(Value::as_str).collect()) .unwrap_or_default(); if details.is_empty() { - self.notify(reply.message, false); + self.report(reply.message, false, popup_matches); + } else if !popup_matches { + self.report( + format!("{}\n\n{}", reply.message, details.join("\n")), + false, + false, + ); } else { - self.popup = Some(Popup::Message { + self.set_popup(Some(Popup::Message { title: "Details", text: format!("{}\n\n{}", reply.message, details.join("\n")), error: false, scroll: 0, - }); + })); } } } + None } - fn poll_results(&mut self, results: &mpsc::Receiver) -> Result, String> { + fn poll_results( + &mut self, + results: &mpsc::Receiver, + jobs: &mpsc::Sender, + ) -> Result, String> { loop { match results.try_recv() { - Ok(Done::Finished { reply, snapshot }) => self.finish(reply, snapshot), + Ok(Done::Finished { reply, snapshot }) => { + if let Some(request) = self.finish(reply, snapshot) + && !self.quit + { + self.start(jobs, request)?; + } + } Ok(Done::Editor(path)) => { self.busy = None; return Ok((!self.quit).then_some(path)); @@ -576,12 +871,16 @@ impl App { kind, started: Instant::now(), automatic: request.automatic, + popup_generation: self.popup_generation, }); Ok(()) } fn keys(&self) -> &'static [Key] { match (&self.popup, self.filtering, self.tab) { + (Some(Popup::Restart), _, _) => CONFIRM_KEYS, + (Some(Popup::Update { .. }), _, _) => UPDATE_KEYS, + (Some(Popup::Form(_)), _, _) => FORM_KEYS, (Some(_), _, _) => POPUP_KEYS, (None, true, _) => FILTER_KEYS, (None, false, Tab::Overview) => &[], @@ -592,7 +891,9 @@ impl App { } fn paste(&mut self, text: &str) { - if self.filtering && self.popup.is_none() { + if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { + form.insert(text); + } else if self.filtering && self.popup.is_none() { self.filter.extend(text.chars().filter(|c| !c.is_control())); self.selected = 0; self.rebuild_calls(); @@ -613,7 +914,7 @@ impl App { } match key.code { KeyCode::Char('q') => self.quit = true, - KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), + KeyCode::Char('?') => self.set_popup(Some(Popup::Help { scroll: 0 })), KeyCode::Char(digit @ '1'..='4') => { self.tab = TABS[digit as usize - '1' as usize]; } @@ -653,10 +954,10 @@ impl App { KeyCode::Char('/') => self.filtering = true, KeyCode::Enter => { if let Some(entry) = self.selected_entry() { - self.popup = Some(Popup::Detail { + self.set_popup(Some(Popup::Detail { entry: Box::new(entry.clone()), scroll: 0, - }); + })); } } KeyCode::Char('s') => { @@ -726,17 +1027,67 @@ impl App { /// This function starts a Settings action. The match has no wildcard arm, so adding a /// controller action breaks the build until the terminal app can run it. fn start_action(&mut self, kind: ActionKind) -> Option { + let form = |kind, title, fields| Some(Popup::Form(Form::new(kind, title, fields))); let request = |action, label| Some(Request::run(action, label)); match kind { ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") } + ActionKind::Restart => { + self.set_popup(Some(Popup::Restart)); + None + } ActionKind::OpenSettings => { request(Action::OpenSettings {}, "Opening the app settings") } + ActionKind::SavePreferences => { + let (baseline, refresh) = self + .data + .settings + .as_ref() + .map(|s| (s.baseline_model.clone(), s.refresh_seconds.to_string())) + .unwrap_or_default(); + self.set_popup(form( + FormKind::Preferences, + "Change preferences", + vec![ + ("Baseline model ID", baseline), + ("Refresh interval in seconds", refresh), + ], + )); + None + } ActionKind::OpenPrices => request(Action::OpenPrices {}, "Opening the model prices"), ActionKind::ServerKeys => request(Action::ServerKeys {}, "Checking the server keys"), + ActionKind::SaveServerKey => { + self.set_popup(form( + FormKind::SaveKey, + "Save a server key", + vec![("Key name", String::new()), ("Key", String::new())], + )); + None + } + ActionKind::ImportServerKey => { + self.set_popup(form( + FormKind::ImportKey, + "Import a server key from your login shell", + vec![("Key name", String::new())], + )); + None + } + ActionKind::RemoveServerKey => { + self.set_popup(form( + FormKind::RemoveKey, + "Remove a saved server key", + vec![("Key name", String::new())], + )); + None + } + // The update window submits Update after the user reads this preview. + ActionKind::PreviewUpdate | ActionKind::Update => { + request(Action::PreviewUpdate {}, "Reading the source checkout") + } ActionKind::OpenUpdateLog => { request(Action::OpenUpdateLog {}, "Opening the update log") } @@ -746,12 +1097,63 @@ impl App { fn popup_key(&mut self, key: KeyEvent) -> Option { let popup = self.popup.as_mut()?; + match popup { + Popup::Restart => { + match key.code { + KeyCode::Char('y' | 'Y') => { + self.set_popup(None); + return Some(Request::run(Action::Restart {}, "Restarting the server")); + } + KeyCode::Char('n' | 'N' | 'q') | KeyCode::Esc | KeyCode::Enter => { + self.set_popup(None); + } + _ => {} + } + return None; + } + Popup::Form(form) | Popup::Update { confirm: form, .. } => { + match key.code { + KeyCode::Esc => self.set_popup(None), + KeyCode::Enter => { + let preview = match &self.popup { + Some(Popup::Update { preview, .. }) => Some(preview), + _ => None, + }; + let form = match &self.popup { + Some(Popup::Form(form) | Popup::Update { confirm: form, .. }) => form, + _ => return None, + }; + match form.action(preview) { + Ok(action) => return Some(Request::run(action, form.label())), + Err(error) => { + if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { + form.error = error; + } + } + } + } + // Only the update window scrolls; its text field ignores the arrow keys. + KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + if let Some(scroll) = popup.scroll_mut() + && let Some(next) = + step(key.code, usize::from(*scroll), usize::from(u16::MAX)) + { + *scroll = next as u16; + } + } + _ => form.edit(key), + } + return None; + } + _ => {} + } match key.code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | '?') => self.popup = None, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | '?') => self.set_popup(None), // Drawing clamps the scroll to the text, so the last line here has no fixed value. code => { - let scroll = popup.scroll_mut(); - if let Some(next) = step(code, usize::from(*scroll), usize::from(u16::MAX)) { + if let Some(scroll) = popup.scroll_mut() + && let Some(next) = step(code, usize::from(*scroll), usize::from(u16::MAX)) + { *scroll = next as u16; } } @@ -1901,6 +2303,44 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat Line::from(spans) } +/// This function draws a form's fields with a cursor in the selected one, then its error. +fn form_lines(theme: Theme, form: &Form) -> Vec> { + let mut lines: Vec = form + .fields + .iter() + .enumerate() + .map(|(index, (label, value))| { + // The key field shows one bullet per character, so the key never appears on screen. + let mut value = if form.kind == FormKind::SaveKey && index == 1 { + "•".repeat(value.chars().count()) + } else { + value.clone() + }; + let selected = index == form.index; + if selected { + let cursor = value + .char_indices() + .nth(form.cursor) + .map_or(value.len(), |(byte, _)| byte); + value.insert(cursor, '▏'); + } + Line::from(vec![ + Span::styled( + if selected { "› " } else { " " }, + if selected { theme.key } else { theme.muted }, + ), + Span::styled(format!("{label}: "), theme.muted), + Span::raw(value), + ]) + }) + .collect(); + if !form.error.is_empty() { + lines.push(Line::default()); + lines.push(Line::styled(form.error.clone(), theme.error)); + } + lines +} + fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { let theme = app.theme; let Some(popup) = &app.popup else { @@ -1932,6 +2372,53 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { 72, close, ), + Popup::Restart => ( + "Restart the server?", + vec![ + Line::from(format!( + "This checks the server config, then restarts the Switchyard service at {}.", + app.data.metrics.server_url + )), + Line::from("Requests in progress fail."), + ], + Vec::new(), + theme.warn, + 64, + footer(&[("y", "Restart"), ("n", "Cancel")]), + ), + Popup::Form(form) => { + let mut lines = Vec::new(); + if matches!( + form.kind, + FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey + ) { + lines.push(Line::styled("Server keys", theme.muted)); + lines.extend(key_lines(theme, app.server_keys.as_ref())); + lines.push(Line::default()); + } + ( + form.title, + lines, + form_lines(theme, form), + theme.key, + 72, + footer(&[("⏎", "Submit"), ("esc", "Cancel")]), + ) + } + Popup::Update { + preview, confirm, .. + } => { + let mut pinned = vec![Line::default()]; + pinned.extend(form_lines(theme, confirm)); + ( + confirm.title, + update_lines(theme, preview), + pinned, + theme.warn, + 84, + footer(&[("⏎", "Start"), ("esc", "Cancel")]), + ) + } Popup::Message { title, text, error, .. } => ( @@ -1967,7 +2454,7 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { .areas(outer.inner(rect)); // Scrolling stops at the last line, so scrolling back up responds at once. let last = rows.saturating_sub(text_area.height); - let scroll = match app.popup.as_mut().map(Popup::scroll_mut) { + let scroll = match app.popup.as_mut().and_then(Popup::scroll_mut) { Some(scroll) => { *scroll = (*scroll).min(last); *scroll @@ -1980,6 +2467,45 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(pinned, pinned_area); } +fn update_lines(theme: Theme, preview: &Value) -> Vec> { + // `git status --short` starts some lines with a space, so only trailing space is removed. + let field = |name: &str| { + preview[name] + .as_str() + .unwrap_or_default() + .trim_end() + .to_string() + }; + let row = |name: &str| labeled(theme, name, 10, field(name.to_lowercase().as_str())); + let mut lines = vec![ + row("Source"), + row("Branch"), + row("Revision"), + Line::default(), + Line::styled("Uncommitted changes", theme.muted), + ]; + let changes = field("changes"); + if changes.is_empty() { + lines.push(Line::from("None")); + } else { + lines.extend(changes.lines().map(|line| Line::from(line.to_string()))); + } + lines.push(Line::default()); + lines.push(Line::styled("Plan", theme.muted)); + lines.extend( + field("plan") + .lines() + .map(|line| Line::from(line.to_string())), + ); + if let Some(message) = preview["status"]["message"].as_str() { + lines.push(Line::default()); + lines.push(Line::styled(format!("Last update: {message}"), theme.warn)); + } + lines.push(Line::default()); + lines.push(Line::from("The app restarts after a successful install.")); + lines +} + /// The help window indents wrapped descriptions under their first line, so keys stay in one column. fn help_lines(theme: Theme, width: usize) -> Vec> { const KEY_WIDTH: usize = 8; @@ -2138,7 +2664,7 @@ pub fn run(mut controller: Controller) -> Result<(), String> { // The initial refresh starts after terminal setup so a blocked read cannot delay drawing. app.start(&jobs, Request::refresh(true))?; loop { - if let Some(path) = app.poll_results(&results)? { + if let Some(path) = app.poll_results(&results, &jobs)? { match edit_in_foreground(terminal, &path).map_err(|error| error.to_string())? { Ok(()) => { app.notify(format!("Closed the editor for {}.", path.display()), false) @@ -2191,12 +2717,14 @@ pub fn run(mut controller: Controller) -> Result<(), String> { #[cfg(test)] mod tests { use super::{ - App, Availability, BTreeMap, Done, Estimate, KeyCode, KeyEvent, KeyModifiers, ModelTokens, - Popup, Request, Snapshot, Tab, Theme, Totals, Usage, draw, mpsc, + ACTIONS, Action, ActionKind, App, Availability, BTreeMap, Done, Estimate, Form, FormKind, + KeyCode, KeyEvent, KeyModifiers, ModelTokens, Popup, Request, Snapshot, Tab, Theme, Totals, + Usage, draw, mpsc, }; use crate::history::Entry; use ratatui::{Terminal, backend::TestBackend}; use serde_json::json; + use strum::IntoEnumIterator; fn screen(app: &mut App, tab: Tab) -> String { app.tab = tab; @@ -2338,6 +2866,229 @@ mod tests { assert!(text.contains("API_KEY_19 not saved"), "{text}"); } + // A late reply must preserve a new draft even when the user reopens the same kind of form. + #[test] + fn action_replies_only_change_the_popup_that_submitted_them() { + for action in ['e', 's'] { + for success in [false, true] { + for replacement in [ + None, + Some(None), + Some(Some('e')), + Some(Some('s')), + Some(Some('?')), + ] { + let mut app = App::new(Theme::new(false)); + app.tab = Tab::Settings; + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + app.key(key(KeyCode::Char(action))); + app.paste("original"); + app.key(key(KeyCode::Tab)); + app.paste(if action == 'e' { "30" } else { "secret" }); + let request = app.key(key(KeyCode::Enter)).expect("submit form"); + let (jobs, _inbox) = mpsc::channel(); + let (done, results) = mpsc::channel(); + app.start(&jobs, request).expect("start action"); + if let Some(next) = replacement { + app.key(key(KeyCode::Esc)); + if let Some(letter) = next { + app.key(key(KeyCode::Char(letter))); + app.paste("new draft"); + } + } + let message = "The original action completed with a result that is too long for a short status message."; + done.send(Done::Finished { + reply: Some(if success { + Ok(super::Reply { + message: message.into(), + data: json!({"details": ["Original details"]}), + }) + } else { + Err(message.into()) + }), + snapshot: Ok(Box::new(Snapshot::default())), + }) + .expect("completion"); + app.poll_results(&results, &jobs).expect("result"); + match replacement { + None if !success => { + let form = app + .popup + .as_mut() + .and_then(Popup::form_mut) + .expect("original form"); + assert_eq!(form.error, message); + assert_eq!(form.value(0), "original"); + } + None => assert!(matches!(app.popup, Some(Popup::Message { .. }))), + Some(None) => assert!(app.popup.is_none()), + Some(Some('?')) => assert!(matches!(app.popup, Some(Popup::Help { .. }))), + Some(Some(letter)) => { + let form = app + .popup + .as_mut() + .and_then(Popup::form_mut) + .expect("replacement form"); + assert_eq!(form.value(0), "new draft"); + assert!(form.error.is_empty(), "{letter}: {}", form.error); + let text = screen(&mut app, Tab::Settings); + assert!(text.contains("new draft"), "{text}"); + } + } + if replacement.is_some() { + let toast = app.toast.as_ref().expect("reported completion"); + assert!(toast.text.contains(message)); + assert_eq!(toast.error, !success); + } + } + } + } + } + + // The automatic key check must preserve the form opened while the save was running. + #[test] + fn key_refresh_after_saving_does_not_replace_a_new_form() { + let mut app = App::new(Theme::new(false)); + app.tab = Tab::Settings; + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + app.key(key(KeyCode::Char('s'))); + app.paste("API_KEY"); + app.key(key(KeyCode::Tab)); + app.paste("secret"); + let request = app.key(key(KeyCode::Enter)).expect("save key"); + let (jobs, inbox) = mpsc::channel(); + let (done, results) = mpsc::channel(); + app.start(&jobs, request).expect("start save"); + inbox.recv().expect("save job"); + app.key(key(KeyCode::Esc)); + app.key(key(KeyCode::Char('e'))); + app.paste("new draft"); + for reply in [ + super::Reply { + message: "Saved the key.".into(), + data: json!(null), + }, + super::Reply { + message: String::new(), + data: json!({"keys": [{"name": "API_KEY", "saved": true}]}), + }, + ] { + done.send(Done::Finished { + reply: Some(Ok(reply)), + snapshot: Ok(Box::new(Snapshot::default())), + }) + .expect("completion"); + app.poll_results(&results, &jobs).expect("result"); + let form = app + .popup + .as_mut() + .and_then(Popup::form_mut) + .expect("new form"); + assert_eq!(form.value(0), "new draft"); + assert!(form.error.is_empty()); + } + assert!(matches!( + inbox.try_recv(), + Ok(super::Job::Run(Action::ServerKeys {})) + )); + assert_eq!( + app.server_keys.as_ref().expect("key status")["keys"][0]["saved"], + true + ); + } + + // A late preview must preserve a replacement popup instead of opening update confirmation. + #[test] + fn update_previews_preserve_replacement_popups_and_require_confirmation() { + for replacement in [None, Some('e'), Some('?')] { + let mut app = App::new(Theme::new(false)); + app.tab = Tab::Settings; + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + let request = app.key(key(KeyCode::Char('u'))).expect("preview request"); + let (jobs, _inbox) = mpsc::channel(); + let (done, results) = mpsc::channel(); + app.start(&jobs, request).expect("start preview"); + if let Some(letter) = replacement { + app.key(key(KeyCode::Char(letter))); + app.paste("new draft"); + } + done.send(Done::Finished { + reply: Some(Ok(super::Reply { + message: String::new(), + data: json!({"preview_token": "reviewed-token"}), + })), + snapshot: Ok(Box::new(Snapshot::default())), + }) + .expect("preview result"); + app.poll_results(&results, &jobs).expect("result"); + match replacement { + None => { + assert!(matches!(app.popup, Some(Popup::Update { .. }))); + assert!(app.key(key(KeyCode::Enter)).is_none()); + app.paste("UPDATE"); + let request = app.key(key(KeyCode::Enter)).expect("confirmed update"); + assert!(matches!( + request.job, + super::Job::Run(Action::Update { preview_token: Some(token) }) + if token == "reviewed-token" + )); + } + Some('e') => { + let form = app.popup.as_mut().and_then(Popup::form_mut).expect("draft"); + assert_eq!(form.value(0), "new draft"); + assert!(form.error.is_empty()); + assert!(app.toast.is_some()); + } + Some('?') => { + assert!(matches!(app.popup, Some(Popup::Help { .. }))); + assert!(app.toast.is_some()); + } + Some(_) => unreachable!(), + } + } + } + + // A snapshot failure must preserve a replacement draft and leave a dismissed popup closed. + #[test] + fn snapshot_and_action_errors_preserve_dismissed_and_replacement_forms() { + for replacement in [false, true] { + let mut app = App::new(Theme::new(false)); + app.tab = Tab::Settings; + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + app.key(key(KeyCode::Char('e'))); + app.paste("original"); + app.key(key(KeyCode::Tab)); + app.paste("30"); + let request = app.key(key(KeyCode::Enter)).expect("preferences request"); + let (jobs, _inbox) = mpsc::channel(); + let (done, results) = mpsc::channel(); + app.start(&jobs, request).expect("start preferences"); + app.key(key(KeyCode::Esc)); + if replacement { + app.key(key(KeyCode::Char('s'))); + app.paste("new draft"); + } + done.send(Done::Finished { + reply: Some(Err("The original action failed.".into())), + snapshot: Err("Could not read settings.\nCheck the TOML file.".into()), + }) + .expect("failed result"); + app.poll_results(&results, &jobs).expect("result"); + if replacement { + let form = app.popup.as_mut().and_then(Popup::form_mut).expect("draft"); + assert_eq!(form.value(0), "new draft"); + assert!(form.error.is_empty()); + } else { + assert!(app.popup.is_none()); + } + assert_eq!( + app.data.errors, + ["Could not read settings.\nCheck the TOML file."] + ); + assert!(app.toast.as_ref().expect("failure").error); + } + } + // These fixtures check the parsed arguments without requiring a Windows editor to run. #[test] fn editor_commands_preserve_platform_quoting_and_paths() { @@ -2443,10 +3194,10 @@ mod tests { let (jobs, _inbox) = mpsc::channel(); app.start(&jobs, Request::refresh(false)).expect("refresh"); let (done, results) = mpsc::channel(); - assert!(app.poll_results(&results).is_ok()); + assert!(app.poll_results(&results, &jobs).is_ok()); drop(done); assert_eq!( - app.poll_results(&results), + app.poll_results(&results, &jobs), Err("The terminal operation worker stopped unexpectedly.".into()) ); } @@ -2464,7 +3215,7 @@ mod tests { snapshot: Err(error.into()), }) .expect("result"); - app.poll_results(&results).expect("manual result"); + app.poll_results(&results, &jobs).expect("manual result"); assert!(matches!(app.popup, Some(Popup::Message { .. }))); app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); for help in [false, true] { @@ -2479,7 +3230,7 @@ mod tests { snapshot: Err(error.into()), }) .expect("result"); - app.poll_results(&results).expect("automatic result"); + app.poll_results(&results, &jobs).expect("automatic result"); assert!(if help { matches!(app.popup, Some(Popup::Help { scroll: 1 })) } else { @@ -2570,4 +3321,35 @@ mod tests { assert!(overview.contains("Spent unknown")); assert!(overview.contains("no recorded cost")); } + + #[test] + // Every controller action needs a Settings key, or the desktop app can do something + // that the terminal app cannot. + fn every_controller_action_has_a_settings_key() { + let mut keys: Vec = ACTIONS.iter().map(|&(key, ..)| key).collect(); + // The Settings page reads its letters before the global keys and the j, g, and G moves. + let reserved = ['q', 'r', '?', '1', '2', '3', '4', 'j', 'g', 'G']; + keys.extend(reserved); + keys.sort_unstable(); + keys.dedup(); + assert_eq!( + keys.len(), + ACTIONS.len() + reserved.len(), + "Settings keys must be unique and must not use a global or movement key" + ); + + // Update is the only action without its own key: the u key reviews the update + // first, and the review window submits Update. + let missing: Vec = ActionKind::iter() + .filter(|&kind| kind != ActionKind::Update && !ACTIONS.iter().any(|&(.., k)| k == kind)) + .collect(); + assert!(missing.is_empty(), "No Settings key runs {missing:?}"); + let mut review = Form::new(FormKind::Update, "", vec![("", "UPDATE".into())]); + assert!(matches!( + review.action(Some(&json!({"preview_token": "t"}))), + Ok(Action::Update { preview_token: Some(token) }) if token == "t" + )); + review.fields[0].1 = "update".into(); + assert!(review.action(None).is_err()); + } }