From acbdc20f38075db325e6a65038952db2e17ee4ec Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 11 Aug 2026 16:01:26 +0200 Subject: [PATCH 1/3] feat: env file --- src/config.rs | 47 ++++++++++++++++++++++ src/env_file.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ src/launcher.rs | 19 ++++++--- src/main.rs | 17 ++++++-- 4 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/env_file.rs diff --git a/src/config.rs b/src/config.rs index fb488ea..0240671 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Deserialize; +use crate::detect::detect_venvs; + /// A launcher entry as it appears in `defaults.json`, where `gui_file` and /// `server_file` may be omitted (falling back to the top-level defaults). #[derive(Debug, Deserialize)] @@ -44,10 +47,52 @@ pub struct LauncherEntry { pub struct Config { /// Base directory containing the source/app directories. pub deployments_base: PathBuf, + /// Default GUI entry point, relative to a deployment directory. + pub default_gui_file: PathBuf, + /// Default server entry point, relative to a deployment directory. + pub default_server_file: PathBuf, /// Resolved launcher entries, keyed by beamline. pub entries: HashMap, } +impl Config { + /// Discover launchable deployments on disk under `deployments_base`, one + /// entry per (deployment, virtualenv) pair, labelled `"/"`. + /// + /// These are the "custom" deployments that are present on the machine but + /// not named in the config. Entry points use the configured defaults. + pub fn discovered_entries(&self) -> Vec<(String, LauncherEntry)> { + let mut found = Vec::new(); + let Ok(dirs) = fs::read_dir(&self.deployments_base) else { + return found; + }; + + for deployment in dirs.flatten() { + if !deployment.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + 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(); + found.push(( + format!("{name}/{venv_name}"), + LauncherEntry { + python: venv.join("bin/python"), + gui_file: join_relative(&dir, &self.default_gui_file), + server_file: join_relative(&dir, &self.default_server_file), + }, + )); + } + } + found + } +} + /// Join `rel` onto `base`, treating `rel` as relative even if it starts with a /// leading separator (as the entry points in `defaults.json` could). fn join_relative(base: &Path, rel: &Path) -> PathBuf { @@ -99,6 +144,8 @@ pub fn load_config(json: &str) -> Result { Ok(Config { deployments_base: raw.deployments_base, + default_gui_file: raw.default_gui_file, + default_server_file: raw.default_server_file, entries, }) } diff --git a/src/env_file.rs b/src/env_file.rs new file mode 100644 index 0000000..16b175a --- /dev/null +++ b/src/env_file.rs @@ -0,0 +1,103 @@ +use std::path::Path; + +/// Load `key=value` pairs from an env file to add to a process's environment. +/// +/// If the file does not exist (or cannot be read) this logs a warning and +/// returns an empty set rather than failing — the env file is optional. +pub fn load_env_file(path: &Path) -> Vec<(String, String)> { + match std::fs::read_to_string(path) { + Ok(contents) => parse_env(&contents), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + eprintln!( + "warning: env file {} not found, continuing without it", + path.display() + ); + Vec::new() + } + Err(err) => { + eprintln!( + "warning: could not read env file {} ({err}), continuing without it", + path.display() + ); + Vec::new() + } + } +} + +/// Parse `key=value` lines into pairs. Blank lines, `#` comments, and lines +/// without a `=` are ignored. Keys and values are trimmed of surrounding +/// whitespace. +fn parse_env(contents: &str) -> Vec<(String, String)> { + contents + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + Some((key.trim().to_string(), value.trim().to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_basic_pairs() { + let env = parse_env("A=1\nB=two"); + assert_eq!( + env, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "two".to_string()) + ] + ); + } + + #[test] + fn ignores_blank_lines_and_comments() { + let env = parse_env("\n# a comment\nA=1\n\n # indented comment\nB=2\n"); + assert_eq!( + env, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()) + ] + ); + } + + #[test] + fn trims_surrounding_whitespace() { + let env = parse_env(" KEY = value "); + assert_eq!(env, vec![("KEY".to_string(), "value".to_string())]); + } + + #[test] + fn keeps_equals_signs_in_value() { + let env = parse_env("URL=postgres://h/db?a=1&b=2"); + assert_eq!( + env, + vec![("URL".to_string(), "postgres://h/db?a=1&b=2".to_string())] + ); + } + + #[test] + fn skips_lines_without_equals() { + let env = parse_env("A=1\ngarbage\nB=2"); + assert_eq!( + env, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()) + ] + ); + } + + #[test] + fn empty_input_yields_nothing() { + assert!(parse_env("").is_empty()); + } +} diff --git a/src/launcher.rs b/src/launcher.rs index ea0d5e6..7d775d4 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -5,10 +5,12 @@ use anyhow::{Context, Result, bail}; use crate::config::LauncherEntry; -/// Run `python ` on the system, waiting for it to finish. -pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { +/// Run `python ` on the system with `env` added to its environment, +/// waiting for it to finish. +fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> { let status = Command::new(python) .arg(entrypoint) + .envs(env.iter().map(|(key, value)| (key, value))) .status() .with_context(|| { format!( @@ -29,10 +31,17 @@ pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { Ok(()) } -pub fn launch_server(entry: &LauncherEntry) -> Result<()> { - launch(&entry.python, &entry.server_file) +/// Run `python ` on the system, waiting for it to finish. +pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { + run(python, entrypoint, &[]) +} + +/// Launch the server entry point with extra `env` variables added to its +/// environment. +pub fn launch_server(entry: &LauncherEntry, env: &[(String, String)]) -> Result<()> { + run(&entry.python, &entry.server_file, env) } pub fn launch_gui(entry: &LauncherEntry) -> Result<()> { - launch(&entry.python, &entry.gui_file) + run(&entry.python, &entry.gui_file, &[]) } diff --git a/src/main.rs b/src/main.rs index d345401..6483ac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod detect; +mod env_file; mod launcher; mod tui; @@ -14,6 +15,8 @@ use detect::detect_beamline; const INSTALLED_CONFIG: &str = "/etc/aarel/defaults.json"; /// Config file shipped in the source tree, used as a fallback. const LOCAL_CONFIG: &str = "data/defaults.json"; +/// Default env file added to the server's environment when launching. +const DEFAULT_ENV_FILE: &str = "/etc/aaredaq/aaredaq.env"; #[derive(Parser)] #[command(name = "aarel")] @@ -49,16 +52,21 @@ enum Command { /// Launch the AareGUI with default settings #[command(alias = "g")] Gui { - /// Config entry (beamline) to launch. Defaults to the detected beamline. + /// Config entry (deployment) to launch. Defaults to the detected beamline. #[arg(long)] entry: Option, }, /// Launch the AareDAQ server with default settings #[command(alias = "s")] Server { - /// Config entry (beamline) to launch. Defaults to the detected beamline. + /// Config entry (deployment) to launch. Defaults to the detected beamline. #[arg(long)] entry: Option, + + /// Env file (`key=value` per line) added to the server's environment. + /// Ignored with a warning if it does not exist. + #[arg(long, default_value = DEFAULT_ENV_FILE)] + env_file: PathBuf, }, } @@ -80,7 +88,10 @@ fn main() -> Result<()> { } } Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?, - Command::Server { entry } => launcher::launch_server(lookup_entry(&config, entry)?)?, + Command::Server { entry, env_file } => { + let env = env_file::load_env_file(&env_file); + launcher::launch_server(lookup_entry(&config, entry)?, &env)? + } } Ok(()) -- 2.54.0 From b8a6bc88a7160b0f9bdaaa69a377448627826e7b Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 12 Aug 2026 09:57:22 +0200 Subject: [PATCH 2/3] refactor: combine deployments in tui --- data/defaults.json | 4 +- src/launcher.rs | 9 +- src/main.rs | 9 +- src/tui/custom.rs | 208 --------------------------------------------- src/tui/entries.rs | 57 ++++++------- src/tui/mod.rs | 164 ++++++++++++++++++++--------------- 6 files changed, 137 insertions(+), 314 deletions(-) delete mode 100644 src/tui/custom.rs diff --git a/data/defaults.json b/data/defaults.json index 718f8d1..5ffce2f 100644 --- a/data/defaults.json +++ b/data/defaults.json @@ -1,8 +1,8 @@ { "deployments_base": "/sls/mx/applications/AareDAQ", "default_venv_name": ".venv", - "default_gui_file": "src/aare/gui/gui.py", - "default_server_file": "src/aare/daq/server.py", + "default_gui_file": "aaredaq/src/aare/gui/gui.py", + "default_server_file": "aaredaq/src/aare/daq/server.py", "map": { "x10sa": { "dir": "x10sa" diff --git a/src/launcher.rs b/src/launcher.rs index 7d775d4..5d23fd9 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -10,7 +10,7 @@ use crate::config::LauncherEntry; fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> { let status = Command::new(python) .arg(entrypoint) - .envs(env.iter().map(|(key, value)| (key, value))) + .envs(env.to_owned()) .status() .with_context(|| { format!( @@ -31,9 +31,10 @@ fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> Ok(()) } -/// Run `python ` on the system, waiting for it to finish. -pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { - run(python, entrypoint, &[]) +/// Run `python ` on the system with `env` added to its environment, +/// waiting for it to finish. +pub fn launch_with_env(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> { + run(python, entrypoint, env) } /// Launch the server entry point with extra `env` variables added to its diff --git a/src/main.rs b/src/main.rs index 6483ac3..f26467d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,8 +83,13 @@ fn main() -> Result<()> { match cli.command { Command::Pick => { - if let Some((python, entrypoint)) = tui::run(&config)? { - launcher::launch(&python, &entrypoint)?; + if let Some(launch) = tui::run(&config, DEFAULT_ENV_FILE)? { + let env = launch + .env_file + .as_deref() + .map(env_file::load_env_file) + .unwrap_or_default(); + launcher::launch_with_env(&launch.python, &launch.entrypoint, &env)?; } } Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?, diff --git a/src/tui/custom.rs b/src/tui/custom.rs deleted file mode 100644 index c50e8f2..0000000 --- a/src/tui/custom.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; - -use crate::detect::detect_venvs; - -/// Which of the two lists currently has focus. -#[derive(Clone, Copy, PartialEq)] -enum Focus { - Source, - Venv, -} - -/// The "Custom" tab: pick a deployment directory from `deployments_base`, then -/// one of the virtualenvs found inside it. -pub struct CustomTab { - /// Base directory the source deployments live under. - deployments_base: PathBuf, - /// Deployment directory names under `deployments_base`. - sources: Vec, - /// Virtualenv directory names inside the selected deployment. - venvs: Vec, - source_state: ListState, - venv_state: ListState, - focus: Focus, -} - -impl CustomTab { - pub fn new(deployments_base: &Path) -> Self { - let sources = list_dirs(deployments_base); - - let mut tab = CustomTab { - deployments_base: deployments_base.to_path_buf(), - source_state: initial_state(&sources), - venv_state: ListState::default(), - sources, - venvs: Vec::new(), - focus: Focus::Source, - }; - tab.refresh_venvs(); - tab - } - - /// Move focus between the source and venv lists. - pub fn toggle_focus(&mut self) { - self.focus = match self.focus { - Focus::Source => Focus::Venv, - Focus::Venv => Focus::Source, - }; - } - - pub fn next(&mut self) { - self.move_selection(1); - } - - pub fn previous(&mut self) { - self.move_selection(-1); - } - - /// Move the selection in the focused list. Moving between deployments - /// re-scans the venvs of the newly selected one. - fn move_selection(&mut self, delta: isize) { - match self.focus { - Focus::Source => { - select_relative(&self.sources, &mut self.source_state, delta); - self.refresh_venvs(); - } - Focus::Venv => select_relative(&self.venvs, &mut self.venv_state, delta), - } - } - - /// Recompute the venv list for the currently selected deployment. - fn refresh_venvs(&mut self) { - self.venvs = match self.selected_source_dir() { - Some(dir) => detect_venvs(&dir) - .unwrap_or_default() - .iter() - .filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned())) - .collect(), - None => Vec::new(), - }; - self.venv_state = initial_state(&self.venvs); - } - - /// Absolute path of the currently selected deployment directory. - fn selected_source_dir(&self) -> Option { - let name = self.sources.get(self.source_state.selected()?)?; - Some(self.deployments_base.join(name)) - } - - pub fn draw(&mut self, frame: &mut Frame, area: Rect) { - let [source_area, venv_area] = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .areas(area); - - let source_list = dir_list("Deployment", &self.sources, self.focus == Focus::Source); - frame.render_stateful_widget(source_list, source_area, &mut self.source_state); - - let venv_list = dir_list("Venv", &self.venvs, self.focus == Focus::Venv); - frame.render_stateful_widget(venv_list, venv_area, &mut self.venv_state); - } -} - -/// List the immediate subdirectories of `base`, sorted by name. Returns an -/// empty list if the directory can't be read. -fn list_dirs(base: &Path) -> Vec { - let mut dirs: Vec = fs::read_dir(base) - .into_iter() - .flatten() - .flatten() - .filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false)) - .filter_map(|entry| entry.file_name().into_string().ok()) - .collect(); - dirs.sort(); - dirs -} - -fn initial_state(items: &[String]) -> ListState { - let mut state = ListState::default(); - if !items.is_empty() { - state.select(Some(0)); - } - state -} - -fn select_relative(items: &[String], state: &mut ListState, delta: isize) { - if items.is_empty() { - return; - } - let len = items.len() as isize; - let current = state.selected().unwrap_or(0) as isize; - let next = (current + delta).rem_euclid(len) as usize; - state.select(Some(next)); -} - -fn dir_list<'a>(title: &'a str, items: &'a [String], focused: bool) -> List<'a> { - let border_style = if focused { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - let list_items: Vec = items.iter().map(|d| ListItem::new(d.as_str())).collect(); - - List::new(list_items) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(title), - ) - .highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)) - .highlight_symbol("> ") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn items(n: usize) -> Vec { - (0..n).map(|i| i.to_string()).collect() - } - - #[test] - fn initial_state_selects_first_when_nonempty() { - assert_eq!(initial_state(&items(3)).selected(), Some(0)); - } - - #[test] - fn initial_state_selects_nothing_when_empty() { - assert_eq!(initial_state(&items(0)).selected(), None); - } - - #[test] - fn select_relative_moves_within_bounds() { - let items = items(3); - let mut state = initial_state(&items); - select_relative(&items, &mut state, 1); - assert_eq!(state.selected(), Some(1)); - } - - #[test] - fn select_relative_wraps_forward_past_end() { - let items = items(3); - let mut state = ListState::default(); - state.select(Some(2)); - select_relative(&items, &mut state, 1); - assert_eq!(state.selected(), Some(0)); - } - - #[test] - fn select_relative_wraps_backward_past_start() { - let items = items(3); - let mut state = initial_state(&items); - select_relative(&items, &mut state, -1); - assert_eq!(state.selected(), Some(2)); - } - - #[test] - fn select_relative_on_empty_is_noop() { - let mut state = ListState::default(); - select_relative(&items(0), &mut state, 1); - assert_eq!(state.selected(), None); - } -} diff --git a/src/tui/entries.rs b/src/tui/entries.rs index 37c203a..5729e38 100644 --- a/src/tui/entries.rs +++ b/src/tui/entries.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::PathBuf; use ratatui::Frame; @@ -16,55 +15,50 @@ enum LaunchChoice { Server, } -/// The "Entries" tab: a list of config keys with a detail panel. -pub struct EntriesTab<'a> { - /// The available launcher entries. - config: &'a HashMap, - /// Config keys, sorted for stable display. - keys: Vec<&'a String>, +/// The entries list: configured entries plus discovered deployments, with a +/// detail panel. +pub struct EntriesTab { + /// Launcher entries, `(label, entry)`, sorted by label. + entries: Vec<(String, LauncherEntry)>, /// Selection state of the entry list. list_state: ListState, /// When `Some`, the launch-choice dialog is open with this selection. dialog: Option, } -impl<'a> EntriesTab<'a> { - pub fn new(config: &'a HashMap) -> Self { - let mut keys: Vec<&String> = config.keys().collect(); - keys.sort(); - +impl EntriesTab { + pub fn new(entries: Vec<(String, LauncherEntry)>) -> Self { let mut list_state = ListState::default(); - if !keys.is_empty() { + if !entries.is_empty() { list_state.select(Some(0)); } EntriesTab { - config, - keys, + entries, list_state, dialog: None, } } pub fn next(&mut self) { - if self.keys.is_empty() { + if self.entries.is_empty() { return; } let next = self .list_state .selected() - .map_or(0, |i| (i + 1) % self.keys.len()); + .map_or(0, |i| (i + 1) % self.entries.len()); self.list_state.select(Some(next)); } pub fn previous(&mut self) { - if self.keys.is_empty() { + if self.entries.is_empty() { return; } let previous = self .list_state .selected() - .map_or(0, |i| (i + self.keys.len() - 1) % self.keys.len()); + .map_or(0, |i| (i + self.entries.len() - 1) % self.entries.len()); self.list_state.select(Some(previous)); } @@ -96,21 +90,24 @@ impl<'a> EntriesTab<'a> { } } - /// Confirm the dialog, returning the `(python, entrypoint)` to launch. - pub fn confirm(&self) -> Option<(PathBuf, PathBuf)> { + /// Confirm the dialog, returning `(python, entrypoint, is_server)`. + pub fn confirm(&self) -> Option<(PathBuf, PathBuf, bool)> { let choice = self.dialog?; let entry = self.selected_entry()?; - let entrypoint = match choice { - LaunchChoice::Gui => entry.gui_file.clone(), - LaunchChoice::Server => entry.server_file.clone(), + let is_server = choice == LaunchChoice::Server; + let entrypoint = if is_server { + entry.server_file.clone() + } else { + entry.gui_file.clone() }; - Some((entry.python.clone(), entrypoint)) + Some((entry.python.clone(), entrypoint, is_server)) } /// The currently selected entry, if any. - fn selected_entry(&self) -> Option<&'a LauncherEntry> { - let key = self.keys.get(self.list_state.selected()?)?; - self.config.get(*key) + fn selected_entry(&self) -> Option<&LauncherEntry> { + self.entries + .get(self.list_state.selected()?) + .map(|(_, entry)| entry) } pub fn draw(&mut self, frame: &mut Frame, area: Rect) { @@ -118,9 +115,9 @@ impl<'a> EntriesTab<'a> { Layout::horizontal([Constraint::Percentage(30), Constraint::Min(0)]).areas(area); let items: Vec = self - .keys + .entries .iter() - .map(|k| ListItem::new(k.as_str())) + .map(|(label, _)| ListItem::new(label.as_str())) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("Entries")) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9c31d0b..6960587 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,4 +1,3 @@ -mod custom; mod entries; use std::path::PathBuf; @@ -7,51 +6,67 @@ use anyhow::Result; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Modifier, Style}; -use ratatui::widgets::{Block, Borders, Paragraph, Tabs}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::{DefaultTerminal, Frame}; -use crate::config::Config; -use custom::CustomTab; +use crate::config::{Config, LauncherEntry}; use entries::EntriesTab; -const TAB_TITLES: [&str; 2] = ["Entries", "Custom"]; - -struct App<'a> { - /// Index of the currently selected tab. - selected_tab: usize, - entries: EntriesTab<'a>, - custom: CustomTab, +/// What the user chose to launch in the TUI. +pub struct Launch { + pub python: PathBuf, + pub entrypoint: PathBuf, + /// Env file to apply, set only for server launches. + pub env_file: Option, } -impl<'a> App<'a> { - fn new(config: &'a Config) -> Self { +/// Which widget currently has keyboard focus. +#[derive(Clone, Copy, PartialEq)] +enum Focus { + List, + EnvFile, +} + +struct App { + entries: EntriesTab, + /// Editable path of the env file applied to server launches. + env_file: String, + focus: Focus, +} + +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 + .iter() + .map(|(label, entry)| (label.clone(), entry.clone())) + .collect(); + entries.extend(config.discovered_entries()); + App { - selected_tab: 0, - entries: EntriesTab::new(&config.entries), - custom: CustomTab::new(&config.deployments_base), + entries: EntriesTab::new(entries), + env_file: env_file.to_string(), + focus: Focus::List, } } - - fn next_tab(&mut self) { - self.selected_tab = (self.selected_tab + 1) % TAB_TITLES.len(); - } - - fn previous_tab(&mut self) { - self.selected_tab = (self.selected_tab + TAB_TITLES.len() - 1) % TAB_TITLES.len(); - } } -/// Run the launcher TUI, returning the command the user chose (or `None` if -/// they quit without choosing). -pub fn run(config: &Config) -> Result> { +/// Run the launcher TUI, returning the launch the user chose (or `None` if they +/// quit without choosing). +pub fn run(config: &Config, env_file: &str) -> Result> { let mut terminal = ratatui::init(); - let result = run_app(&mut terminal, config); + let result = run_app(&mut terminal, config, env_file); ratatui::restore(); result } -fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result> { - let mut app = App::new(config); +fn run_app( + terminal: &mut DefaultTerminal, + config: &Config, + env_file: &str, +) -> Result> { + let mut app = App::new(config, env_file); loop { terminal.draw(|frame| draw(frame, &mut app))?; @@ -65,8 +80,13 @@ fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result { - if let Some(command) = app.entries.confirm() { - return Ok(Some(command)); + if let Some((python, entrypoint, is_server)) = app.entries.confirm() { + let env_file = is_server.then(|| PathBuf::from(&app.env_file)); + return Ok(Some(Launch { + python, + entrypoint, + env_file, + })); } } KeyCode::Esc => app.entries.cancel_dialog(), @@ -78,25 +98,26 @@ fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result return Ok(None), - KeyCode::Tab => app.next_tab(), - KeyCode::BackTab => app.previous_tab(), - // Everything else is routed to the active tab. - _ => match app.selected_tab { - 0 => match key.code { - KeyCode::Down => app.entries.next(), - KeyCode::Up => app.entries.previous(), - KeyCode::Enter => app.entries.open_dialog(), - _ => {} - }, - _ => match key.code { - KeyCode::Left | KeyCode::Right => app.custom.toggle_focus(), - KeyCode::Down => app.custom.next(), - KeyCode::Up => app.custom.previous(), - _ => {} - }, + match app.focus { + // The entries list has focus: navigate and launch. + Focus::List => match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Tab | KeyCode::BackTab => app.focus = Focus::EnvFile, + KeyCode::Down => app.entries.next(), + KeyCode::Up => app.entries.previous(), + KeyCode::Enter => app.entries.open_dialog(), + _ => {} + }, + // The env-file box has focus: edit the path. + Focus::EnvFile => match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Tab | KeyCode::BackTab => { + app.focus = Focus::List + } + KeyCode::Char(c) => app.env_file.push(c), + KeyCode::Backspace => { + app.env_file.pop(); + } + _ => {} }, } } @@ -104,26 +125,33 @@ fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result app.entries.draw(frame, content_area), - _ => app.custom.draw(frame, content_area), + let focused = app.focus == Focus::EnvFile; + let border_style = if focused { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let env = Paragraph::new(app.env_file.as_str()).block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title("Env file (server)"), + ); + frame.render_widget(env, env_area); + if focused { + // Place the cursor at the end of the editable text. + let x = env_area.x + 1 + app.env_file.chars().count() as u16; + let max_x = env_area.x + env_area.width.saturating_sub(2); + frame.set_cursor_position((x.min(max_x), env_area.y + 1)); } let footer = Paragraph::new(help_text(app)).style(Style::default().add_modifier(Modifier::DIM)); @@ -134,9 +162,9 @@ fn draw(frame: &mut Frame, app: &mut App) { fn help_text(app: &App) -> &'static str { if app.entries.dialog_open() { "↑/↓: choose Enter: launch Esc: cancel" - } else if app.selected_tab == 0 { - "Tab: switch tab ↑/↓: select Enter: launch q: quit" + } else if app.focus == Focus::EnvFile { + "type to edit env file path Enter/Tab/Esc: back to list" } else { - "Tab: switch tab ←/→: switch list ↑/↓: select q: quit" + "↑/↓: select Enter: launch Tab: edit env file q: quit" } } -- 2.54.0 From be03e75eb6d1941424297c73797e5e84843cfa25 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 12 Aug 2026 11:07:44 +0200 Subject: [PATCH 3/3] build: rpm targets musl --- .gitea/workflows/check-and-lint.yaml | 10 +++++++--- .gitea/workflows/release.yaml | 10 +++++++--- Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/check-and-lint.yaml b/.gitea/workflows/check-and-lint.yaml index f1c1155..a319e44 100644 --- a/.gitea/workflows/check-and-lint.yaml +++ b/.gitea/workflows/check-and-lint.yaml @@ -47,10 +47,14 @@ jobs: steps: - uses: actions/checkout@v6 - uses: https://github.com/dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl - uses: https://github.com/Swatinem/rust-cache@v2 + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Build RPM run: | - cargo build --release - strip -s target/release/aarel + cargo build --release --target x86_64-unknown-linux-musl + strip -s target/x86_64-unknown-linux-musl/release/aarel cargo install cargo-generate-rpm - cargo generate-rpm + cargo generate-rpm --target x86_64-unknown-linux-musl diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 74ea8c8..0779e2a 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -54,16 +54,20 @@ jobs: steps: - uses: actions/checkout@v6 - uses: https://github.com/dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl - uses: https://github.com/Swatinem/rust-cache@v2 + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Build RPM run: | # Pick up the version bump semantic-release just committed. git pull - cargo build --release - strip -s target/release/aarel + cargo build --release --target x86_64-unknown-linux-musl + strip -s target/x86_64-unknown-linux-musl/release/aarel cargo install cargo-generate-rpm - cargo generate-rpm + cargo generate-rpm --target x86_64-unknown-linux-musl - name: Push to Gitea RPM registry run: | diff --git a/Cargo.toml b/Cargo.toml index bdcf696..fbad349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [package.metadata.generate-rpm] assets = [ - { source = "target/release/aarel", dest = "/usr/local/bin/aarel", mode = "755" }, + { source = "target/x86_64-unknown-linux-musl/release/aarel", dest = "/usr/local/bin/aarel", mode = "755" }, { source = "data/defaults.json", dest = "/etc/aarel/defaults.json", mode = "644", config = true }, ] -- 2.54.0