feat: list venvs separately
Check and Lint / Rustfmt (pull_request) Successful in 18s
Check and Lint / Check (pull_request) Successful in 29s
Check and Lint / Clippy (pull_request) Successful in 29s
Test and Coverage / Test (pull_request) Successful in 30s
Test and Coverage / Coverage (pull_request) Successful in 33s
Check and Lint / Build RPM (pull_request) Successful in 1m19s
Check and Lint / Rustfmt (push) Successful in 19s
Check and Lint / Check (push) Successful in 23s
Test and Coverage / Test (push) Successful in 19s
Check and Lint / Clippy (push) Successful in 29s
Test and Coverage / Coverage (push) Successful in 34s
Check and Lint / Build RPM (push) Successful in 1m10s
Release / Semantic release (push) Successful in 1m35s
Release / Build and push RPM (push) Successful in 1m15s

This commit was merged in pull request #25.
This commit is contained in:
perl_d committed 2026-10-27 09:27:02 +01:00
1 parent abf7d1cda6
commit e3b2967fc4
3 files changed
+281 -47

No files matched your search

+20
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}