diff --git a/src/config.rs b/src/config.rs index 0240671..c6dddd5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,11 +56,13 @@ pub struct Config { } impl Config { - /// Discover launchable deployments on disk under `deployments_base`, one - /// entry per (deployment, virtualenv) pair, labelled `"/"`. + /// Discover launchable deployments on disk under `deployments_base`. /// /// These are the "custom" deployments that are present on the machine but - /// not named in the config. Entry points use the configured defaults. + /// not named in the config; entry points use the configured defaults. Each + /// is labelled by its deployment name, with the virtualenv kept inside the + /// entry — only when a deployment has more than one virtualenv is the venv + /// name appended (`"/"`) to keep the labels unique. pub fn discovered_entries(&self) -> Vec<(String, LauncherEntry)> { let mut found = Vec::new(); let Ok(dirs) = fs::read_dir(&self.deployments_base) else { @@ -74,13 +76,21 @@ impl Config { let dir = deployment.path(); let name = deployment.file_name().to_string_lossy().into_owned(); - for venv in detect_venvs(&dir).unwrap_or_default() { - let venv_name = venv - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); + let venvs = detect_venvs(&dir).unwrap_or_default(); + let disambiguate = venvs.len() > 1; + + for venv in venvs { + let label = if disambiguate { + let venv_name = venv + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{name}/{venv_name}") + } else { + name.clone() + }; found.push(( - format!("{name}/{venv_name}"), + label, LauncherEntry { python: venv.join("bin/python"), gui_file: join_relative(&dir, &self.default_gui_file), diff --git a/src/main.rs b/src/main.rs index 245b9ea..0d5985e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,16 +56,16 @@ enum Command { /// Launch the AareGUI with default settings #[command(alias = "g")] Gui { - /// Config entry (deployment) to launch. Defaults to the detected beamline. - #[arg(long)] - entry: Option, + /// Deployment to launch. Defaults to the detected beamline. + #[arg(short = 'd', long)] + deployment: Option, }, /// Launch the AareDAQ server with default settings #[command(alias = "s")] Server { - /// Config entry (deployment) to launch. Defaults to the detected beamline. - #[arg(long)] - entry: Option, + /// Deployment to launch. Defaults to the detected beamline. + #[arg(short = 'd', long)] + deployment: Option, /// Env file (`key=value` per line) added to the server's environment. /// Ignored with a warning if it does not exist. @@ -80,10 +80,15 @@ fn main() -> Result<()> { let config_path = resolve_config_path(cli.config); let json = std::fs::read_to_string(&config_path) .with_context(|| format!("failed to read config file {}", config_path.display()))?; - let config = load_config(&json)?; + let mut config = load_config(&json)?; check_base_exists("deployments_base", &config.deployments_base, &config_path)?; - check_base_exists("venvs_base", &config.deployments_base, &config_path)?; + + // Populate the config with deployments found on disk so they are available + // to both `lookup_entry` and the TUI. Configured entries take precedence. + for (label, entry) in config.discovered_entries() { + config.entries.entry(label).or_insert(entry); + } match cli.command { Command::Pick => { @@ -96,10 +101,13 @@ fn main() -> Result<()> { launcher::launch_with_env(&launch.python, &launch.entrypoint, &env)?; } } - Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?, - Command::Server { entry, env_file } => { + Command::Gui { deployment } => launcher::launch_gui(lookup_entry(&config, deployment)?)?, + Command::Server { + deployment, + env_file, + } => { let env = env_file::load_env_file(&env_file); - launcher::launch_server(lookup_entry(&config, entry)?, &env)? + launcher::launch_server(lookup_entry(&config, deployment)?, &env)? } } diff --git a/src/tui/entries.rs b/src/tui/entries.rs index 5729e38..b28f294 100644 --- a/src/tui/entries.rs +++ b/src/tui/entries.rs @@ -11,8 +11,8 @@ use crate::config::LauncherEntry; /// Which entry point to launch for the selected entry. #[derive(Clone, Copy, PartialEq)] enum LaunchChoice { - Gui, Server, + Gui, } /// The entries list: configured entries plus discovered deployments, with a @@ -27,7 +27,10 @@ pub struct EntriesTab { } impl EntriesTab { - pub fn new(entries: Vec<(String, LauncherEntry)>) -> Self { + pub fn new(mut entries: Vec<(String, LauncherEntry)>) -> Self { + // Production deployments first, then the rest, alphabetical within each. + entries.sort_by(|a, b| sort_key(&a.0).cmp(&sort_key(&b.0))); + let mut list_state = ListState::default(); if !entries.is_empty() { list_state.select(Some(0)); @@ -158,8 +161,8 @@ impl EntriesTab { }; let text = Text::from(vec![ - Line::styled(" Launch GUI ", gui_style), Line::styled(" Launch Server ", server_style), + Line::styled(" Launch GUI ", gui_style), Line::from(""), Line::from("↑/↓ choose · Enter confirm · Esc cancel"), ]); @@ -168,6 +171,14 @@ impl EntriesTab { } } +/// Production deployment labels, pinned to the top of the list. +const PRODUCTION: [&str; 3] = ["x06da", "x06sa", "x10sa"]; + +/// Sort key placing production deployments first, then alphabetical by label. +fn sort_key(label: &str) -> (bool, &str) { + (!PRODUCTION.contains(&label), label) +} + /// A rectangle centered within `area`, sized as a percentage of it. fn centered_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect { let [area] = Layout::vertical([Constraint::Percentage(height_pct)]) @@ -178,3 +189,20 @@ fn centered_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect { .areas(area); area } + +#[cfg(test)] +mod tests { + use super::sort_key; + + #[test] + fn production_sorts_before_others() { + // Production wins even though its label sorts later alphabetically. + assert!(sort_key("x10sa") < sort_key("aaa")); + } + + #[test] + fn production_ordered_alphabetically() { + assert!(sort_key("x06da") < sort_key("x06sa")); + assert!(sort_key("x06sa") < sort_key("x10sa")); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6960587..78e52b7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -36,13 +36,13 @@ struct App { impl App { fn new(config: &Config, env_file: &str) -> Self { - // Configured entries first, then the deployments discovered on disk. - let mut entries: Vec<(String, LauncherEntry)> = config + // Entries (configured + deployments discovered at startup) come straight + // from the config. + let entries: Vec<(String, LauncherEntry)> = config .entries .iter() .map(|(label, entry)| (label.clone(), entry.clone())) .collect(); - entries.extend(config.discovered_entries()); App { entries: EntriesTab::new(entries),