Feat/load env file #15
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
+1
-1
@@ -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
-2
@@ -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"
|
||||
|
||||
@@ -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<String, LauncherEntry>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Discover launchable deployments on disk under `deployments_base`, one
|
||||
/// entry per (deployment, virtualenv) pair, labelled `"<deployment>/<venv>"`.
|
||||
///
|
||||
/// 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<Config> {
|
||||
|
||||
Ok(Config {
|
||||
deployments_base: raw.deployments_base,
|
||||
default_gui_file: raw.default_gui_file,
|
||||
default_server_file: raw.default_server_file,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
+103
@@ -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());
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -5,10 +5,12 @@ use anyhow::{Context, Result, bail};
|
||||
|
||||
use crate::config::LauncherEntry;
|
||||
|
||||
/// Run `python <entrypoint>` on the system, waiting for it to finish.
|
||||
pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> {
|
||||
/// Run `python <entrypoint>` 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.to_owned())
|
||||
.status()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
@@ -29,10 +31,18 @@ pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn launch_server(entry: &LauncherEntry) -> Result<()> {
|
||||
launch(&entry.python, &entry.server_file)
|
||||
/// Run `python <entrypoint>` 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
|
||||
/// 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, &[])
|
||||
}
|
||||
|
||||
+21
-5
@@ -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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -75,12 +83,20 @@ 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)?)?,
|
||||
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(())
|
||||
|
||||
@@ -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<String>,
|
||||
/// Virtualenv directory names inside the selected deployment.
|
||||
venvs: Vec<String>,
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
let mut dirs: Vec<String> = 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<ListItem> = 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<String> {
|
||||
(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);
|
||||
}
|
||||
}
|
||||
+27
-30
@@ -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<String, LauncherEntry>,
|
||||
/// 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<LaunchChoice>,
|
||||
}
|
||||
|
||||
impl<'a> EntriesTab<'a> {
|
||||
pub fn new(config: &'a HashMap<String, LauncherEntry>) -> 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<ListItem> = 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"))
|
||||
|
||||
+96
-68
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
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<Option<(PathBuf, PathBuf)>> {
|
||||
/// 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<Option<Launch>> {
|
||||
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<Option<(PathBuf, PathBuf)>> {
|
||||
let mut app = App::new(config);
|
||||
fn run_app(
|
||||
terminal: &mut DefaultTerminal,
|
||||
config: &Config,
|
||||
env_file: &str,
|
||||
) -> Result<Option<Launch>> {
|
||||
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<Option<(Pa
|
||||
if app.entries.dialog_open() {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
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<Option<(Pa
|
||||
continue;
|
||||
}
|
||||
|
||||
// Global keys: quit and tab switching.
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => 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<Option<(Pa
|
||||
}
|
||||
|
||||
fn draw(frame: &mut Frame, app: &mut App) {
|
||||
let [tabs_area, content_area, footer_area] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
let [content_area, env_area, footer_area] = Layout::vertical([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(frame.area());
|
||||
|
||||
let tabs = Tabs::new(TAB_TITLES)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("aare launcher"),
|
||||
)
|
||||
.select(app.selected_tab)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED));
|
||||
frame.render_widget(tabs, tabs_area);
|
||||
app.entries.draw(frame, content_area);
|
||||
|
||||
match app.selected_tab {
|
||||
0 => 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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user