Feat/list venvs separately #25
No files matched your search
+1
-1
@@ -17,7 +17,7 @@ path = "src/main.rs"
|
||||
[package.metadata.generate-rpm]
|
||||
assets = [
|
||||
{ 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 },
|
||||
{ source = "data/defaults.json", dest = "/etc/aarel/defaults.json", mode = "644" },
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -42,6 +42,19 @@ pub struct LauncherEntry {
|
||||
pub server_file: PathBuf,
|
||||
}
|
||||
|
||||
impl LauncherEntry {
|
||||
/// Name of the virtualenv directory, derived from `python` which always
|
||||
/// lives at `<venv>/bin/python`.
|
||||
pub fn venv_name(&self) -> String {
|
||||
self.python
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.and_then(Path::file_name)
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved launcher configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -285,6 +298,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn venv_name_is_directory_holding_python() {
|
||||
let config = load_config(SAMPLE).unwrap();
|
||||
assert_eq!(config.entries["x10sa"].venv_name(), "pxii");
|
||||
assert_eq!(config.entries["x06da"].venv_name(), ".venv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_venv_overrides_default() {
|
||||
let config = load_config(SAMPLE).unwrap();
|
||||
|
||||
+258
-46
@@ -4,7 +4,7 @@ use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Text};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::config::LauncherEntry;
|
||||
|
||||
@@ -15,54 +15,95 @@ enum LaunchChoice {
|
||||
Gui,
|
||||
}
|
||||
|
||||
/// The entries list: configured entries plus discovered deployments, with a
|
||||
/// detail panel.
|
||||
/// Which of the two list panes has keyboard focus.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Pane {
|
||||
Deployments,
|
||||
Venvs,
|
||||
}
|
||||
|
||||
/// A launchable virtualenv within a deployment.
|
||||
struct Venv {
|
||||
name: String,
|
||||
entry: LauncherEntry,
|
||||
}
|
||||
|
||||
/// A deployment directory and the virtualenvs found inside it.
|
||||
struct Deployment {
|
||||
name: String,
|
||||
/// Sorted by name, never empty.
|
||||
venvs: Vec<Venv>,
|
||||
}
|
||||
|
||||
/// The entries view: a deployments pane, a venvs pane for the selected
|
||||
/// deployment, and 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,
|
||||
/// Deployments with production first, then alphabetical.
|
||||
deployments: Vec<Deployment>,
|
||||
/// Selection state of the deployments pane.
|
||||
deployment_state: ListState,
|
||||
/// Selection state of the venvs pane, into the selected deployment.
|
||||
venv_state: ListState,
|
||||
pane: Pane,
|
||||
/// When `Some`, the launch-choice dialog is open with this selection.
|
||||
dialog: Option<LaunchChoice>,
|
||||
}
|
||||
|
||||
impl EntriesTab {
|
||||
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)));
|
||||
pub fn new(entries: Vec<(String, LauncherEntry)>) -> Self {
|
||||
let deployments = group_by_deployment(entries);
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
if !entries.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
let mut deployment_state = ListState::default();
|
||||
let mut venv_state = ListState::default();
|
||||
if !deployments.is_empty() {
|
||||
deployment_state.select(Some(0));
|
||||
venv_state.select(Some(0));
|
||||
}
|
||||
|
||||
EntriesTab {
|
||||
entries,
|
||||
list_state,
|
||||
deployments,
|
||||
deployment_state,
|
||||
venv_state,
|
||||
pane: Pane::Deployments,
|
||||
dialog: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the selection in the focused pane down, wrapping around.
|
||||
pub fn next(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next = self
|
||||
.list_state
|
||||
.selected()
|
||||
.map_or(0, |i| (i + 1) % self.entries.len());
|
||||
self.list_state.select(Some(next));
|
||||
self.move_selection(1);
|
||||
}
|
||||
|
||||
/// Move the selection in the focused pane up, wrapping around.
|
||||
pub fn previous(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
self.move_selection(-1);
|
||||
}
|
||||
|
||||
/// Move the focused pane's selection by `delta`, wrapping around. Changing
|
||||
/// deployment resets the venv selection to its first entry.
|
||||
fn move_selection(&mut self, delta: isize) {
|
||||
match self.pane {
|
||||
Pane::Deployments => {
|
||||
step(&mut self.deployment_state, self.deployments.len(), delta);
|
||||
self.venv_state.select(Some(0));
|
||||
}
|
||||
Pane::Venvs => {
|
||||
let len = self.venv_count();
|
||||
step(&mut self.venv_state, len, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give focus to the deployments pane.
|
||||
pub fn focus_left(&mut self) {
|
||||
self.pane = Pane::Deployments;
|
||||
}
|
||||
|
||||
/// Give focus to the venvs pane. No-op when there is nothing to select.
|
||||
pub fn focus_right(&mut self) {
|
||||
if self.selected_deployment().is_some() {
|
||||
self.pane = Pane::Venvs;
|
||||
}
|
||||
let previous = self
|
||||
.list_state
|
||||
.selected()
|
||||
.map_or(0, |i| (i + self.entries.len() - 1) % self.entries.len());
|
||||
self.list_state.select(Some(previous));
|
||||
}
|
||||
|
||||
/// Whether the launch-choice dialog is currently open.
|
||||
@@ -106,38 +147,72 @@ impl EntriesTab {
|
||||
Some((entry.python.clone(), entrypoint, is_server))
|
||||
}
|
||||
|
||||
/// The currently selected entry, if any.
|
||||
/// The currently selected deployment, if any.
|
||||
fn selected_deployment(&self) -> Option<&Deployment> {
|
||||
self.deployments.get(self.deployment_state.selected()?)
|
||||
}
|
||||
|
||||
/// Number of venvs in the selected deployment.
|
||||
fn venv_count(&self) -> usize {
|
||||
self.selected_deployment().map_or(0, |d| d.venvs.len())
|
||||
}
|
||||
|
||||
/// The currently selected entry (venv of the selected deployment), if any.
|
||||
fn selected_entry(&self) -> Option<&LauncherEntry> {
|
||||
self.entries
|
||||
.get(self.list_state.selected()?)
|
||||
.map(|(_, entry)| entry)
|
||||
self.selected_deployment()?
|
||||
.venvs
|
||||
.get(self.venv_state.selected()?)
|
||||
.map(|venv| &venv.entry)
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let [list_area, detail_area] =
|
||||
Layout::horizontal([Constraint::Percentage(30), Constraint::Min(0)]).areas(area);
|
||||
let [deployments_area, venvs_area, detail_area] = Layout::horizontal([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let items: Vec<ListItem> = self
|
||||
.entries
|
||||
let deployments: Vec<ListItem> = self
|
||||
.deployments
|
||||
.iter()
|
||||
.map(|(label, _)| ListItem::new(label.as_str()))
|
||||
.map(|d| ListItem::new(d.name.as_str()))
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Entries"))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED))
|
||||
.highlight_symbol("> ");
|
||||
frame.render_stateful_widget(list, list_area, &mut self.list_state);
|
||||
// Index through the fields rather than `selected_deployment()` so the
|
||||
// list states can still be borrowed mutably for rendering below.
|
||||
let venvs: Vec<ListItem> = self
|
||||
.deployment_state
|
||||
.selected()
|
||||
.and_then(|i| self.deployments.get(i))
|
||||
.map(|d| {
|
||||
d.venvs
|
||||
.iter()
|
||||
.map(|v| ListItem::new(v.name.as_str()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let deployments = pane_list(deployments, "Deployments", self.pane == Pane::Deployments);
|
||||
frame.render_stateful_widget(deployments, deployments_area, &mut self.deployment_state);
|
||||
let venvs = pane_list(venvs, "Venvs", self.pane == Pane::Venvs);
|
||||
frame.render_stateful_widget(venvs, venvs_area, &mut self.venv_state);
|
||||
|
||||
let detail = Block::default().borders(Borders::ALL).title("Details");
|
||||
let text = match self.selected_entry() {
|
||||
Some(entry) => Text::from(vec![
|
||||
Line::from(format!("python: {}", entry.python.display())),
|
||||
Line::from("\n"),
|
||||
Line::from(format!("gui_file: {}", entry.gui_file.display())),
|
||||
Line::from("\n"),
|
||||
Line::from(format!("server_file: {}", entry.server_file.display())),
|
||||
]),
|
||||
None => Text::from("no entries"),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(text).block(detail), detail_area);
|
||||
// Paths easily outgrow the pane, so wrap rather than clip them.
|
||||
let detail = Paragraph::new(text)
|
||||
.block(detail)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(detail, detail_area);
|
||||
|
||||
if let Some(choice) = self.dialog {
|
||||
self.draw_dialog(frame, area, choice);
|
||||
@@ -171,6 +246,72 @@ impl EntriesTab {
|
||||
}
|
||||
}
|
||||
|
||||
/// A bordered list pane; the focused pane gets a bold border and a reversed
|
||||
/// highlight, an unfocused one keeps a bold highlight so the selection stays
|
||||
/// visible.
|
||||
fn pane_list<'a>(items: Vec<ListItem<'a>>, title: &'a str, focused: bool) -> List<'a> {
|
||||
let (border, highlight) = if focused {
|
||||
(
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)
|
||||
};
|
||||
List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border)
|
||||
.title(title),
|
||||
)
|
||||
.highlight_style(highlight)
|
||||
.highlight_symbol("> ")
|
||||
}
|
||||
|
||||
/// Move a list selection by `delta` within `len` items, wrapping around.
|
||||
fn step(state: &mut ListState, len: usize, delta: isize) {
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = state.selected().unwrap_or(0) as isize;
|
||||
let next = (current + delta).rem_euclid(len as isize) as usize;
|
||||
state.select(Some(next));
|
||||
}
|
||||
|
||||
/// Group flat `(label, entry)` pairs by deployment.
|
||||
///
|
||||
/// Labels are either `"<deployment>"` or `"<deployment>/<venv>"` (see
|
||||
/// `Config::discovered_entries`), so the deployment is the part before the
|
||||
/// first `/`. Deployments are ordered production first, then alphabetical;
|
||||
/// venvs alphabetical within each.
|
||||
fn group_by_deployment(entries: Vec<(String, LauncherEntry)>) -> Vec<Deployment> {
|
||||
let mut deployments: Vec<Deployment> = Vec::new();
|
||||
for (label, entry) in entries {
|
||||
let name = label.split_once('/').map_or(label.as_str(), |(d, _)| d);
|
||||
let venv = Venv {
|
||||
name: entry.venv_name(),
|
||||
entry,
|
||||
};
|
||||
match deployments.iter_mut().find(|d| d.name == name) {
|
||||
Some(deployment) => deployment.venvs.push(venv),
|
||||
None => deployments.push(Deployment {
|
||||
name: name.to_string(),
|
||||
venvs: vec![venv],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
deployments.sort_by(|a, b| sort_key(&a.name).cmp(&sort_key(&b.name)));
|
||||
for deployment in &mut deployments {
|
||||
deployment.venvs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
deployments
|
||||
}
|
||||
|
||||
/// Production deployment labels, pinned to the top of the list.
|
||||
const PRODUCTION: [&str; 3] = ["x06da", "x06sa", "x10sa"];
|
||||
|
||||
@@ -192,7 +333,15 @@ fn centered_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sort_key;
|
||||
use super::*;
|
||||
|
||||
fn entry(venv: &str) -> LauncherEntry {
|
||||
LauncherEntry {
|
||||
python: PathBuf::from(format!("/apps/d/{venv}/bin/python")),
|
||||
gui_file: PathBuf::from("/apps/d/gui.py"),
|
||||
server_file: PathBuf::from("/apps/d/server.py"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sorts_before_others() {
|
||||
@@ -205,4 +354,67 @@ mod tests {
|
||||
assert!(sort_key("x06da") < sort_key("x06sa"));
|
||||
assert!(sort_key("x06sa") < sort_key("x10sa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_disambiguated_labels_under_one_deployment() {
|
||||
let deployments = group_by_deployment(vec![
|
||||
("test/.venv".to_string(), entry(".venv")),
|
||||
("x10sa".to_string(), entry(".venv")),
|
||||
("test/other".to_string(), entry("other")),
|
||||
]);
|
||||
|
||||
let names: Vec<&str> = deployments.iter().map(|d| d.name.as_str()).collect();
|
||||
assert_eq!(names, ["x10sa", "test"]);
|
||||
|
||||
let venvs: Vec<&str> = deployments[1]
|
||||
.venvs
|
||||
.iter()
|
||||
.map(|v| v.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(venvs, [".venv", "other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_wraps_and_resets_venv_selection() {
|
||||
let mut tab = EntriesTab::new(vec![
|
||||
("a/x".to_string(), entry("x")),
|
||||
("a/y".to_string(), entry("y")),
|
||||
("b".to_string(), entry(".venv")),
|
||||
]);
|
||||
|
||||
tab.focus_right();
|
||||
tab.next();
|
||||
assert_eq!(tab.venv_state.selected(), Some(1));
|
||||
tab.next();
|
||||
assert_eq!(tab.venv_state.selected(), Some(0), "wraps within venvs");
|
||||
|
||||
tab.next();
|
||||
tab.focus_left();
|
||||
tab.next();
|
||||
assert_eq!(tab.deployment_state.selected(), Some(1));
|
||||
assert_eq!(
|
||||
tab.venv_state.selected(),
|
||||
Some(0),
|
||||
"reset on deployment change"
|
||||
);
|
||||
|
||||
tab.previous();
|
||||
tab.previous();
|
||||
assert_eq!(
|
||||
tab.deployment_state.selected(),
|
||||
Some(1),
|
||||
"wraps within deployments"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tab_has_no_selection_and_ignores_input() {
|
||||
let mut tab = EntriesTab::new(Vec::new());
|
||||
tab.next();
|
||||
tab.focus_right();
|
||||
tab.open_dialog();
|
||||
assert!(tab.selected_entry().is_none());
|
||||
assert_eq!(tab.pane, Pane::Deployments);
|
||||
assert!(!tab.dialog_open());
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -105,6 +105,8 @@ fn run_app(
|
||||
KeyCode::Tab | KeyCode::BackTab => app.focus = Focus::EnvFile,
|
||||
KeyCode::Down => app.entries.next(),
|
||||
KeyCode::Up => app.entries.previous(),
|
||||
KeyCode::Left => app.entries.focus_left(),
|
||||
KeyCode::Right => app.entries.focus_right(),
|
||||
KeyCode::Enter => app.entries.open_dialog(),
|
||||
_ => {}
|
||||
},
|
||||
@@ -165,6 +167,6 @@ fn help_text(app: &App) -> &'static str {
|
||||
} else if app.focus == Focus::EnvFile {
|
||||
"type to edit env file path Enter/Tab/Esc: back to list"
|
||||
} else {
|
||||
"↑/↓: select Enter: launch Tab: edit env file q: quit"
|
||||
"↑/↓: select ←/→: pane Enter: launch Tab: edit env file q: quit"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user