#![allow(dead_code)]
use std::path::PathBuf;
use std::sync::Arc;
use synaps_cli::skills::state::{PluginsState, InstalledPlugin, CachedPlugin};
use super::progress::{InstallProgress, InstallProgressHandle};
#[derive(Debug, PartialEq, Eq)]
pub enum LeftRow {
Installed,
Marketplace(String),
AddMarketplace,
}
pub enum RightRow<'a> {
Installed(&'a InstalledPlugin),
Browseable { plugin: &'a CachedPlugin, installed: bool },
}
pub enum Focus { Left, Right }
#[derive(Debug)]
pub enum RightMode {
List,
Detail { row_idx: usize },
AddMarketplaceEditor { buffer: String, error: Option<String> },
TrustPrompt { plugin_name: String, host: String, pending_source: String, summary: Vec<String> },
Confirm { prompt: String, on_yes: ConfirmAction, summary: Vec<String> },
Installing { progress: InstallProgressHandle },
PendingInstallConfirm {
plugin_name: String,
source_url: String,
subdir: Option<String>,
marketplace_name: Option<String>,
summary: Vec<String>,
installed_commit: String,
checksum_algorithm: Option<String>,
checksum_value: Option<String>,
temp_dir: std::path::PathBuf,
final_dir: std::path::PathBuf,
},
PendingUpdateConfirm {
plugin_name: String,
summary: Vec<String>,
installed_commit: String,
temp_dir: std::path::PathBuf,
final_dir: std::path::PathBuf,
},
}
#[derive(Debug)]
pub enum ConfirmAction {
Uninstall(String), EnablePlugin(String),
RemoveMarketplace(String),
}
pub struct PendingInstallTask {
pub join: tokio::task::JoinHandle<Result<(String, PathBuf), String>>,
pub progress: InstallProgressHandle,
pub plugin_name: String,
pub source_url: String,
pub subdir: Option<String>,
pub marketplace_name: Option<String>,
pub expected_checksum: Option<(String, String)>,
pub final_dir: PathBuf,
}
impl std::fmt::Debug for PendingInstallTask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingInstallTask")
.field("plugin_name", &self.plugin_name)
.field("source_url", &self.source_url)
.field("subdir", &self.subdir)
.field("marketplace_name", &self.marketplace_name)
.field("final_dir", &self.final_dir)
.field("join_finished", &self.join.is_finished())
.finish()
}
}
pub struct PluginsModalState {
pub file: PluginsState,
pub selected_left: usize,
pub selected_right: usize,
pub focus: Focus,
pub mode: RightMode,
pub row_error: Option<String>,
pub pending_install: Option<PendingInstallTask>,
}
impl PluginsModalState {
pub fn new(file: PluginsState) -> Self {
Self {
file,
selected_left: 0,
selected_right: 0,
focus: Focus::Left,
mode: RightMode::List,
row_error: None,
pending_install: None,
}
}
pub fn is_install_active(&self) -> bool {
self.pending_install.is_some()
}
pub fn install_task_finished(&self) -> bool {
self.pending_install
.as_ref()
.map(|p| p.join.is_finished())
.unwrap_or(false)
}
const MIN_INSTALL_DISPLAY_MS: u64 = 500;
fn min_install_display_ms() -> u64 {
std::env::var("SYNAPS_INSTALL_MIN_DISPLAY_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(Self::MIN_INSTALL_DISPLAY_MS)
}
pub fn install_ready_to_reap(&self) -> bool {
let Some(p) = self.pending_install.as_ref() else { return false };
if !p.join.is_finished() {
return false;
}
let Ok(prog) = p.progress.lock() else { return true };
prog.started_at.elapsed().as_millis() as u64 >= Self::min_install_display_ms()
}
pub fn tick_install_spinner(&mut self) {
if let Some(p) = &self.pending_install {
if let Ok(mut prog) = p.progress.lock() {
prog.tick_spinner();
}
}
}
pub(crate) fn new_from_settings(file: PluginsState) -> Self {
let has_marketplaces = !file.marketplaces.is_empty();
let mut st = Self::new(file);
st.selected_left = 1;
if has_marketplaces {
st.focus = Focus::Right;
}
st
}
pub fn left_rows(&self) -> Vec<LeftRow> {
let mut rows = vec![LeftRow::Installed];
for m in &self.file.marketplaces {
rows.push(LeftRow::Marketplace(m.name.clone()));
}
rows.push(LeftRow::AddMarketplace);
rows
}
pub fn right_rows(&self) -> Vec<RightRow<'_>> {
let left = self.left_rows();
match left.get(self.selected_left) {
Some(LeftRow::Installed) => self.file.installed.iter()
.map(RightRow::Installed).collect(),
Some(LeftRow::Marketplace(mname)) => {
let Some(m) = self.file.marketplaces.iter().find(|m| &m.name == mname) else {
return Vec::new();
};
m.cached_plugins.iter()
.map(|p| RightRow::Browseable {
plugin: p,
installed: self.file.installed.iter().any(|i| i.name == p.name),
})
.collect()
}
_ => Vec::new(),
}
}
pub fn move_left_down(&mut self) {
let n = self.left_rows().len();
if self.selected_left + 1 < n { self.selected_left += 1; self.selected_right = 0; }
}
pub fn move_left_up(&mut self) {
if self.selected_left > 0 { self.selected_left -= 1; self.selected_right = 0; }
}
pub fn move_right_down(&mut self) {
let n = self.right_rows().len();
if self.selected_right + 1 < n { self.selected_right += 1; }
}
pub fn move_right_up(&mut self) {
if self.selected_right > 0 { self.selected_right -= 1; }
}
}
#[cfg(test)]
mod tests {
use super::*;
use synaps_cli::skills::state::{PluginsState, Marketplace, CachedPlugin, InstalledPlugin};
fn mk_state() -> PluginsModalState {
let file = PluginsState {
marketplaces: vec![
Marketplace {
name: "pi".into(),
url: "https://github.com/m/pi".into(),
description: None,
last_refreshed: None,
cached_plugins: vec![
CachedPlugin {
name: "web".into(),
source: "https://github.com/m/web.git".into(),
version: None,
description: None,
index: None,
},
],
repo_url: None,
},
],
installed: vec![
InstalledPlugin {
name: "tools".into(),
marketplace: None,
source_url: "https://github.com/x/tools.git".into(),
installed_commit: "aaa".into(),
latest_commit: Some("aaa".into()),
installed_at: "now".into(),
source_subdir: None,
checksum_algorithm: None,
checksum_value: None,
setup_status: Default::default(),
},
],
trusted_hosts: vec![],
};
PluginsModalState::new(file)
}
#[test]
fn left_rows_include_installed_and_marketplaces_and_add() {
let s = mk_state();
let rows = s.left_rows();
assert_eq!(rows[0], LeftRow::Installed);
assert_eq!(rows[1], LeftRow::Marketplace("pi".into()));
assert_eq!(rows.last().unwrap(), &LeftRow::AddMarketplace);
}
#[test]
fn right_rows_when_installed_selected() {
let mut s = mk_state();
s.selected_left = 0; let rows = s.right_rows();
assert_eq!(rows.len(), 1);
assert!(matches!(&rows[0], RightRow::Installed(p) if p.name == "tools"));
}
#[test]
fn right_rows_when_marketplace_selected() {
let mut s = mk_state();
s.selected_left = 1; let rows = s.right_rows();
assert_eq!(rows.len(), 1);
assert!(matches!(&rows[0], RightRow::Browseable { plugin, installed: false } if plugin.name == "web"));
}
#[test]
fn right_rows_when_add_marketplace_selected() {
let mut s = mk_state();
s.selected_left = s.left_rows().len() - 1;
let rows = s.right_rows();
assert!(rows.is_empty());
}
#[test]
fn move_down_clamps_in_left_pane() {
let mut s = mk_state();
let last = s.left_rows().len() - 1;
for _ in 0..100 { s.move_left_down(); }
assert_eq!(s.selected_left, last);
}
#[test]
fn new_from_settings_with_marketplaces_focuses_right_and_selects_first() {
let file = PluginsState {
marketplaces: vec![Marketplace {
name: "pi".into(),
url: "https://github.com/m/pi".into(),
description: None,
last_refreshed: None,
cached_plugins: vec![],
repo_url: None,
}],
installed: vec![],
trusted_hosts: vec![],
};
let st = PluginsModalState::new_from_settings(file);
assert_eq!(st.selected_left, 1);
assert!(matches!(st.focus, Focus::Right));
}
#[test]
fn new_from_settings_without_marketplaces_stays_on_add_row() {
let file = PluginsState::default();
let st = PluginsModalState::new_from_settings(file);
assert_eq!(st.selected_left, 1);
}
#[tokio::test(flavor = "current_thread")]
async fn install_ready_to_reap_holds_until_min_display_elapsed() {
std::env::set_var("SYNAPS_INSTALL_MIN_DISPLAY_MS", "200");
let mut s = mk_state();
let progress = std::sync::Arc::new(std::sync::Mutex::new(
crate::chatui::plugins::progress::InstallProgress::new("test"),
));
let join = tokio::task::spawn_blocking(|| -> Result<(String, std::path::PathBuf), String> {
Ok(("sha".into(), std::path::PathBuf::from("/tmp/x")))
});
s.pending_install = Some(PendingInstallTask {
join,
progress: progress.clone(),
plugin_name: "p".into(),
source_url: "u".into(),
subdir: None,
marketplace_name: None,
expected_checksum: None,
final_dir: std::path::PathBuf::from("/tmp/p"),
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(s.install_task_finished(), "join should be done by now");
assert!(
!s.install_ready_to_reap(),
"must not reap before MIN_INSTALL_DISPLAY_MS elapses"
);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(
s.install_ready_to_reap(),
"should reap once min-display window has passed"
);
std::env::remove_var("SYNAPS_INSTALL_MIN_DISPLAY_MS");
}
}