diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 70228b75c6..d39144390f 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -78,6 +78,25 @@ pub enum Action { id: String, settings_file: Option, }, + Install { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + preview_token: Option, + }, + PreviewRestore { + tool: Harness, + account: Option, + settings_file: Option, + }, + Restore { + tool: Harness, + account: Option, + settings_file: Option, + preview_token: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -178,6 +197,18 @@ pub struct RouteInfo { /// This field maps each harness to the reason it cannot use the route, or to `None` /// when it can. pub compatibility: BTreeMap>, + /// This field lists the harness settings files that select the route's public model ID. + pub dependents: Vec, +} + +/// Dependent names harness settings that select a route, so deleting the route breaks them. +#[derive(Clone, Serialize)] +pub struct Dependent { + pub tool: Harness, + pub label: &'static str, + /// This field is `None` for the harness's own settings and names a saved account otherwise. + pub account: Option, + pub files: Vec, } /// ToolInfo describes one harness whose settings the apps can change. @@ -490,6 +521,7 @@ impl Controller { }; self.route_reviews .retain(|key, _| listed.iter().any(|route| &route.name == key)); + let selections = harness_selections(); let mut routes = Vec::with_capacity(listed.len()); for route in listed { let revision = self.remember_route( @@ -521,6 +553,11 @@ impl Controller { .iter() .map(|&(tool, _)| (tool, login_mode(tool, &config, &route.name).err())) .collect(), + dependents: selections + .iter() + .filter(|(model, _)| model.as_str() == &*route.id) + .map(|(_, dependent)| dependent.clone()) + .collect(), editable: algorithm.is_some(), generated: config .generated_by("routes", &route.name) @@ -974,6 +1011,76 @@ impl Controller { data, }) } + Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + self.tool_review + .as_ref() + .ok_or("Preview these settings before applying them.")? + .check(preview_token.as_deref(), &inputs)?; + let config = self.routes()?; + let route = current_route(&config, &route, Some(&id))?; + harness::install_reviewed( + tool, + &files, + &self.config.server_url, + &route.id, + config.context_window(&route.key), + login_mode(tool, &config, &route.key)?, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) + .map(Reply::message) + } + Action::PreviewRestore { + tool, + account, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let review = Review::capture( + json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}), + harness::review_files(&files), + )?; + let mut data = harness::preview_restore(tool, &files)?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Restore { + tool, + account, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + self.tool_review.as_ref().ok_or("Preview the restore before applying it.")?.check(preview_token.as_deref(),&json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}))?; + match harness::restore_reviewed( + &files, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) { + Ok(message) => Ok(Reply::message(message)), + Err(error) if error.starts_with("Restored settings, but") => Ok(Reply { + message: error, + data: json!({"warning":true}), + }), + Err(error) => Err(error), + } + } 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())), @@ -1089,6 +1196,39 @@ fn target_label(target: &switchyard_runner::ConfiguredTarget) -> String { None => format!("{} (no target has this name)", target.name), } } +/// This function reads each harness and account's settings once and pairs each one with the +/// model it selects, so a snapshot does not reread every settings file for every route. +fn harness_selections() -> Vec<(String, Dependent)> { + let mut selections = Vec::new(); + for &(tool, label) in harness::HARNESSES { + let mut destinations = vec![(None, harness::paths(tool))]; + destinations.extend( + accounts::list(tool) + .into_iter() + .map(|(name, path)| (Some(name), accounts::config_paths(tool, Some(&path)))), + ); + for (account, files) in destinations { + let Ok(files) = files else { + continue; + }; + let Some(model) = harness::inspect(tool, &files).ok().and_then(|status| { + Some(status.lines().next()?.strip_prefix("Model: ")?.to_owned()) + }) else { + continue; + }; + selections.push(( + model, + Dependent { + tool, + label, + account, + files, + }, + )); + } + } + selections +} fn role_info(role: &crate::server_config::Role) -> RoleInfo { RoleInfo { label: role.label.clone(), diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs index aca2579c23..16620c31d1 100644 --- a/crates/switchyard-desktop/src/harness.rs +++ b/crates/switchyard-desktop/src/harness.rs @@ -4,6 +4,7 @@ //! This module installs and restores user settings for the supported harnesses. use serde_json::{Value, json}; +use std::io::Write; use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, value}; @@ -27,8 +28,8 @@ pub const HARNESSES: &[(Harness, &str)] = &[ (Harness::Pi, "Pi"), ]; -/// This function uses the selected harness's environment variable when it is present. -/// This function resolves the home directory only when that variable is absent. +/// This function uses the selected harness's environment variable when it is set and not empty. +/// This function resolves the home directory only when that variable is absent or empty. pub fn paths(tool: Harness) -> Result, String> { let (variable, default, files): (&str, &str, &[&str]) = match tool { Harness::CodexCli => ("CODEX_HOME", "~/.codex", &["sy.config.toml"]), @@ -40,8 +41,11 @@ pub fn paths(tool: Harness) -> Result, String> { &["models.json", "settings.json"], ), }; - let directory = match std::env::var_os(variable) { - Some(path) => PathBuf::from(path), + let directory = match std::env::var_os(variable).filter(|path| !path.is_empty()) { + // A relative directory would make Install and Restore write next to the app's working + // directory instead of the harness's settings. + Some(path) if Path::new(&path).is_absolute() => PathBuf::from(path), + Some(_) => return Err(format!("{variable} must be an absolute path.")), None => crate::config::expand_home(Path::new(default))?, }; Ok(files.iter().map(|file| directory.join(file)).collect()) @@ -519,7 +523,13 @@ fn configured( Harness::Pi => { let mut doc = object(originals[0].as_deref().unwrap_or_default())?; let providers = map(&mut doc, "providers")?; - providers["switchyard"] = json!({"baseUrl":format!("{root}/v1"), "api":"openai-completions", "apiKey":"switchyard-local", "headers":{"x-switchyard-origin":"pi"}, "models":[{"id":model,"name":model,"reasoning":true,"input":["text"],"contextWindow":context_window.unwrap_or(128000),"maxTokens":16384,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}); + // Only the fields shown in the preview change; other keys on the provider stay. + let provider = map(providers, "switchyard")?; + provider["baseUrl"] = json!(format!("{root}/v1")); + provider["api"] = json!("openai-completions"); + provider["apiKey"] = json!("switchyard-local"); + provider["headers"] = json!({"x-switchyard-origin":"pi"}); + provider["models"] = json!([{"id":model,"name":model,"reasoning":true,"input":["text"],"contextWindow":context_window.unwrap_or(128000),"maxTokens":16384,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]); incoming.push(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n"); let mut settings = object(originals[1].as_deref().unwrap_or_default())?; settings["defaultProvider"] = json!("switchyard"); @@ -531,6 +541,78 @@ fn configured( Ok(incoming) } +pub fn install_reviewed( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, + expected: Option<&[ReviewedFile]>, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore did not finish retiring its backups. \ + Retry Restore before installing another route." + .into()); + } + } + let originals = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let incoming: Vec<_> = incoming.into_iter().map(Some).collect(); + for (path, original) in files.iter().zip(&originals) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err("Harness settings changed after the preview. Review them again.".into()); + } + } + for (path, before) in files.iter().zip(&originals) { + let backup = backup_path(path); + let absent = absent_path(path); + if read_file(&backup)?.is_some() || read_file(&absent)?.is_some() { + continue; + } + let (destination, text) = match before { + Some(text) => (&backup, text.as_str()), + None => (&absent, ""), + }; + prepare(destination, text)? + .persist_noclobber(destination) + .map_err(|e| e.to_string())?; + } + replace(files, &originals, &incoming)?; + let next = match tool { + Harness::CodexCli => { + let profile = files[0] + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".config.toml")); + profile + .map(|name| format!(" Start Codex CLI with codex -p {name}.")) + .unwrap_or_else(|| " Restart Codex CLI.".into()) + } + Harness::Pi => " Reload Pi models with /model.".into(), + Harness::CodexApp => " Restart Codex app.".into(), + Harness::Claude => " Restart Claude Code.".into(), + }; + Ok(format!( + "Connected route {model}. Changed {}.{next}", + files + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )) +} + fn backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( "{}.switchyard-original", @@ -562,6 +644,228 @@ pub fn review_files(files: &[PathBuf]) -> Vec { .collect() } +pub fn preview_restore(tool: Harness, files: &[PathBuf]) -> Result { + let resuming = files.iter().any(|path| restore_pending_path(path).exists()); + let mut plan = Vec::new(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for path in files { + let current = read_file(path)?; + let pending = read_file(&restore_pending_path(path))?; + let original = match pending { + Some(text) => { + serde_json::from_str::>(&text).map_err(|error| error.to_string())? + } + None => match read_file(&backup_path(path))? { + Some(text) => Some(text), + None if read_file(&absent_path(path))?.is_some() => None, + None if resuming => current.clone(), + None => return Err("No original backup is available for this destination.".into()), + }, + }; + let action = if current == original { + "unchanged" + } else { + match (¤t, &original) { + (_, None) => "remove", + (None, _) => "create", + _ => "replace", + } + }; + before.push(current.unwrap_or_default()); + after.push(original.unwrap_or_default()); + plan.push(json!({"file":path,"action":action,"backup":backup_path(path)})); + } + Ok(json!({ + "files": plan, + "changes": settings_changes(tool, files, &before, &after).ok(), + "warning": "Restore replaces each listed file with its original version. \ + Later edits are kept in a recovery copy before replacement." + })) +} + +pub fn restore_reviewed( + files: &[PathBuf], + expected: Option<&[ReviewedFile]>, +) -> Result { + let pending = files + .iter() + .map(|path| read_file(&restore_pending_path(path))) + .collect::, _>>()?; + let resuming = pending.iter().any(Option::is_some); + let mut restored = Vec::new(); + for (path, pending) in files.iter().zip(pending) { + let original = read_file(&backup_path(path))?; + let absent = read_file(&absent_path(path))?.is_some(); + let original = match pending { + Some(text) => serde_json::from_str::>(&text) + .map_err(|e| format!("Read pending restore: {e}"))?, + None if original.is_none() && !absent => { + if !resuming { + return Err("No original backup is available for this installation.".into()); + } + // A cleared receipt means this file finished restoring, so a retry preserves later edits. + read_file(path)? + } + None => original, + }; + restored.push(original); + } + let current = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + for (path, original) in files.iter().zip(¤t) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err( + "Harness settings changed after the restore preview. Review them again.".into(), + ); + } + } + let mut kept_paths = Vec::new(); + for (path, before) in files.iter().zip(¤t) { + let Some(before) = before else { + continue; + }; + let mut recovery = tempfile::Builder::new() + .prefix("switchyard-before-restore-") + .tempfile_in(path.parent().ok_or("Settings need a parent directory")?) + .map_err(|e| e.to_string())?; + recovery + .write_all(before.as_bytes()) + .map_err(|e| e.to_string())?; + let (_, kept) = recovery.keep().map_err(|e| e.to_string())?; + kept_paths.push(kept.display().to_string()); + } + // Each private receipt stores the original contents or absence and blocks installation during cleanup. + for (path, original) in files.iter().zip(&restored) { + let pending = restore_pending_path(path); + if read_file(&pending)?.is_none() { + let text = serde_json::to_string(original).map_err(|e| e.to_string())?; + prepare(&pending, &text)? + .persist_noclobber(&pending) + .map_err(|e| e.to_string())?; + } + } + let recovery = format!("Previous settings: {}", kept_paths.join(", ")); + replace(files, ¤t, &restored).map_err(|error| format!("{error}. {recovery}"))?; + for path in files { + for retired in [backup_path(path), absent_path(path)] { + match std::fs::remove_file(&retired) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Restored settings, but could not retire {}: {error}. \ + Retry Restore before installing another route. {recovery}", + retired.display() + )); + } + } + } + } + for path in files { + let pending = restore_pending_path(path); + std::fs::remove_file(&pending).map_err(|error| { + format!( + "Restored settings, but could not clear {}: {error}. {recovery}", + pending.display() + ) + })?; + } + Ok(format!( + "Restored original settings. Restart the harness. {recovery}" + )) +} + +fn check_review(expected: Option<&[ReviewedFile]>) -> Result<(), String> { + if let Some(files) = expected { + for (path, expected) in files { + let current = match std::fs::read(path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + if ¤t != expected { + return Err(format!( + "{} changed after the preview. Review it again.", + path.display() + )); + } + } + } + Ok(()) +} + +fn prepare(path: &Path, text: &str) -> Result { + let parent = path.parent().ok_or("Settings need a parent directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|e| e.to_string())?; + Ok(temp) +} + +// Each file replacement is atomic, but the set of replacements is not. +// A later failure triggers an attempt to restore files already replaced. +fn replace( + files: &[PathBuf], + before: &[Option], + after: &[Option], +) -> Result<(), String> { + let staged = files + .iter() + .zip(after) + .map(|(path, text)| text.as_deref().map(|text| prepare(path, text)).transpose()) + .collect::, _>>()?; + for (path, expected) in files.iter().zip(before) { + if read_file(path)? != *expected { + return Err(format!( + "{} changed while installing. Refresh and try again.", + path.display() + )); + } + } + for (index, (path, temp)) in files.iter().zip(staged).enumerate() { + let result = match temp { + Some(temp) => temp.persist(path).map(|_| ()).map_err(|e| e.to_string()), + None if before[index].is_some() => { + std::fs::remove_file(path).map_err(|e| e.to_string()) + } + None => Ok(()), + }; + if let Err(error) = result { + // The rollback tries every replaced file and keeps the error that stopped the install. + let mut failures = Vec::new(); + for (path, original) in files[..index].iter().zip(before) { + let undone = match original { + Some(text) => prepare(path, text) + .and_then(|temp| temp.persist(path).map(|_| ()).map_err(|e| e.to_string())), + None => match std::fs::remove_file(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + if let Err(e) = undone { + failures.push(format!("{}: {e}", path.display())); + } + } + return Err(if failures.is_empty() { + error + } else { + format!("{error}. Could not undo: {}", failures.join("; ")) + }); + } + } + Ok(()) +} + pub fn binary(tool: Harness) -> Option { if tool == Harness::CodexApp { #[cfg(windows)] diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index fb24b3c6da..90b498b286 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -8,8 +8,8 @@ //! draws and reads input. use crate::controller::{ - AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, - Snapshot, ToolInfo, + AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Dependent, Reply, + RouteInfo, Snapshot, ToolInfo, }; use crate::history::Entry; use crate::pricing::Estimate; @@ -235,6 +235,22 @@ const ACTIONS: &[ActionRow] = &[ saved.", &[ActionKind::PreviewInstall], ), + ( + Tab::Harnesses, + 'i', + "install", + "Shows the settings diff, then saves it after you type INSTALL. A backup keeps the \ + original settings.", + &[ActionKind::PreviewInstall, ActionKind::Install], + ), + ( + Tab::Harnesses, + 'u', + "restore", + "Shows which files return to their original versions, then restores them after you \ + type RESTORE.", + &[ActionKind::PreviewRestore, ActionKind::Restore], + ), ( Tab::Settings, 'o', @@ -846,7 +862,13 @@ impl Form { FormKind::NewRoute { .. } | FormKind::NewRouteModels { .. } | FormKind::RouteIdentity { .. } => "Reviewing the route change", - FormKind::Confirm(_, _action) => "Saving the route", + // The reviewed action names what typing the confirmation word saves. + FormKind::Confirm(_, action) => match **action { + Action::Install { .. } => "Installing the harness settings", + Action::Restore { .. } => "Restoring the harness settings", + + _ => "Saving the route", + }, FormKind::Remove { .. } => "Deleting the route", FormKind::Models => "Loading models", FormKind::ModelKey(_) => "Saving the key and loading models", @@ -957,7 +979,30 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { choices, preview_token, }, - + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + }, + Action::PreviewRestore { + tool, + account, + settings_file, + } => Action::Restore { + tool, + account, + settings_file, + preview_token, + }, _ => return None, }) } @@ -1418,15 +1463,15 @@ impl App { } } } - let (action, _key) = busy + let (action, key) = busy .map(|busy| (busy.action, busy.key)) .unwrap_or((None, None)); let reply = reply?; - if matches!( - (&action, reply.is_ok()), - (Some(Action::PreviewInstall { .. }), false) - ) { - self.install_preview = None; + match (&action, reply.is_ok()) { + // Saved settings no longer match an earlier diff, and a failed preview has none. + (Some(Action::Install { .. } | Action::Restore { .. }), true) + | (Some(Action::PreviewInstall { .. }), false) => self.install_preview = None, + _ => {} } match (kind, reply) { (Some(ActionKind::ServerKeys), Err(error)) => { @@ -1460,7 +1505,8 @@ impl App { "Type UPDATE to start", ))); } - // The Harnesses page shows the diff while the selection still matches it. + // The p key shows the diff on the page. The i key also opens the review, unless the + // settings already match the route. (Some(ActionKind::PreviewInstall), Ok(reply)) => { let previewed = action?; let changed = reply.data["changes"] @@ -1472,6 +1518,24 @@ impl App { Tone::Done, popup_matches, ); + } else if popup_matches + && key == Some('i') + && let Some(install @ Action::Install { tool, .. }) = + reviewed(previewed.clone(), &reply.data) + { + let known = self.data.tools.iter().find(|known| known.tool == tool); + let label = known.map_or("the harness", |known| known.label); + let title = format!("Apply routing to {label}?"); + self.set_popup(Some(Popup::review( + install_lines(self.theme, &reply.data), + "Install", + FormKind::Confirm("INSTALL".into(), Box::new(install)), + title, + "Type INSTALL to save the settings", + ))); + } else { + let text = "Press i to apply this diff."; + self.report(text.into(), Tone::Done, popup_matches); } // The revision comes from the last snapshot. An older snapshot only hides the // preview early; edits made outside the app are caught by the install recheck. @@ -1483,6 +1547,19 @@ impl App { .map(|route| route.revision.clone())?; self.install_preview = Some((previewed, revision, reply.data)); } + (Some(ActionKind::PreviewRestore), Ok(reply)) if popup_matches => { + if let Some(restore) = action.and_then(|action| reviewed(action, &reply.data)) { + self.set_popup(Some(Popup::review( + restore_lines(self.theme, &reply.data), + "Restore", + FormKind::Confirm("RESTORE".into(), Box::new(restore)), + "Restore harness settings?", + "Type RESTORE to replace the files", + ))); + } + } + // The reply lists the roles of the chosen algorithm. The models form replaces the + // algorithm form only while that form is still open for the same route. (Some(ActionKind::Editor), Ok(reply)) if popup_matches => { if let Some(Action::Editor { route: key, @@ -2037,14 +2114,27 @@ impl App { }; self.open(form) } - ActionKind::PreviewInstall => { + // Both keys preview the install. App::finish shows the diff for p and opens the + // review, which submits Install, for i. + ActionKind::PreviewInstall | ActionKind::Install => { let label = match key { 'p' => "Previewing the settings", + 'i' => "Reviewing the settings", _ => return Err(format!("The {key} key does not preview harness settings.")), }; request(self.install_action()?, label) } - + // The review window lists the files that return to their backups and submits + // Restore. + ActionKind::PreviewRestore | ActionKind::Restore => { + let tool = self.harness()?.tool; + let restore = Action::PreviewRestore { + tool, + account: self.account(), + settings_file: self.custom_file(), + }; + request(restore, "Reading the original settings") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -3317,6 +3407,7 @@ fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(Line::from(format!( + "{label}{}: {}", + account.unwrap_or_default(), + files.join(", ") + ))); + } + } if let Some(generated) = &route.generated { lines.push(Line::default()); lines.push(Line::styled( @@ -4194,6 +4312,28 @@ fn install_lines(theme: Theme, preview: &Value) -> Vec> { lines } +/// This function shows a restore preview: what restoring does to each file, the backup it +/// uses, and the settings that change. +fn restore_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::from(text(preview, "warning"))]; + for file in preview["files"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::from(format!("{}: {}", text(file, "action"), text(file, "file"))), + Line::styled( + format!("Original backup: {}", text(file, "backup")), + theme.muted, + ), + ]); + } + // The controller leaves the diff out when a file cannot be compared. + if preview["changes"].is_array() { + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "restored")); + } + lines +} + 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| {