mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-27 05:53:10 +00:00
feat: karapace-tui — interactive terminal UI for environment management
- ratatui + crossterm based TUI - List/Detail/Help views with vim-style keybindings (j/k, g/G, Enter, q) - Search/filter (/), sort cycling (s/S) - Freeze, archive, rename actions from UI - Destroy with confirmation dialog - Color-coded environment states
This commit is contained in:
parent
23ac53ba4d
commit
4a90300807
5 changed files with 4698 additions and 0 deletions
23
crates/karapace-tui/Cargo.toml
Normal file
23
crates/karapace-tui/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "karapace-tui"
|
||||||
|
description = "Terminal UI for Karapace environment manager"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "karapace_tui"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui.workspace = true
|
||||||
|
crossterm.workspace = true
|
||||||
|
karapace-core = { path = "../karapace-core" }
|
||||||
|
karapace-store = { path = "../karapace-store" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
3757
crates/karapace-tui/karapace-tui.cdx.json
Normal file
3757
crates/karapace-tui/karapace-tui.cdx.json
Normal file
File diff suppressed because it is too large
Load diff
448
crates/karapace-tui/src/app.rs
Normal file
448
crates/karapace-tui/src/app.rs
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::EnvMetadata;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum AppAction {
|
||||||
|
None,
|
||||||
|
Quit,
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum View {
|
||||||
|
List,
|
||||||
|
Detail,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Normal,
|
||||||
|
Search,
|
||||||
|
Rename,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortColumn {
|
||||||
|
ShortId,
|
||||||
|
Name,
|
||||||
|
State,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub store_root: PathBuf,
|
||||||
|
pub environments: Vec<EnvMetadata>,
|
||||||
|
pub filtered: Vec<usize>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub view: View,
|
||||||
|
pub input_mode: InputMode,
|
||||||
|
pub text_input: String,
|
||||||
|
pub input_cursor: usize,
|
||||||
|
pub filter: String,
|
||||||
|
pub sort_column: SortColumn,
|
||||||
|
pub sort_ascending: bool,
|
||||||
|
pub status_message: String,
|
||||||
|
pub show_confirm: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(store_root: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
store_root: store_root.to_path_buf(),
|
||||||
|
environments: Vec::new(),
|
||||||
|
filtered: Vec::new(),
|
||||||
|
selected: 0,
|
||||||
|
view: View::List,
|
||||||
|
input_mode: InputMode::Normal,
|
||||||
|
text_input: String::new(),
|
||||||
|
input_cursor: 0,
|
||||||
|
filter: String::new(),
|
||||||
|
sort_column: SortColumn::Name,
|
||||||
|
sort_ascending: true,
|
||||||
|
status_message: String::new(),
|
||||||
|
show_confirm: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn engine(&self) -> Engine {
|
||||||
|
Engine::new(&self.store_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) -> Result<(), String> {
|
||||||
|
match self.engine().list() {
|
||||||
|
Ok(envs) => {
|
||||||
|
self.environments = envs;
|
||||||
|
self.apply_sort();
|
||||||
|
self.apply_filter();
|
||||||
|
self.status_message = format!("{} environment(s)", self.environments.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status_message = format!("error: {e}");
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_filter(&mut self) {
|
||||||
|
if self.filter.is_empty() {
|
||||||
|
self.filtered = (0..self.environments.len()).collect();
|
||||||
|
} else {
|
||||||
|
let needle = self.filter.to_lowercase();
|
||||||
|
self.filtered = self
|
||||||
|
.environments
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, e)| {
|
||||||
|
e.short_id.to_lowercase().contains(&needle)
|
||||||
|
|| e.env_id.to_lowercase().contains(&needle)
|
||||||
|
|| e.name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&needle)
|
||||||
|
|| e.state.to_string().to_lowercase().contains(&needle)
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
if self.selected >= self.filtered.len() && !self.filtered.is_empty() {
|
||||||
|
self.selected = self.filtered.len() - 1;
|
||||||
|
} else if self.filtered.is_empty() {
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_sort(&mut self) {
|
||||||
|
let asc = self.sort_ascending;
|
||||||
|
match self.sort_column {
|
||||||
|
SortColumn::ShortId => self.environments.sort_by(|a, b| {
|
||||||
|
let ord = a.short_id.cmp(&b.short_id);
|
||||||
|
if asc {
|
||||||
|
ord
|
||||||
|
} else {
|
||||||
|
ord.reverse()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
SortColumn::Name => self.environments.sort_by(|a, b| {
|
||||||
|
let ord = a
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.cmp(b.name.as_deref().unwrap_or(""));
|
||||||
|
if asc {
|
||||||
|
ord
|
||||||
|
} else {
|
||||||
|
ord.reverse()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
SortColumn::State => self.environments.sort_by(|a, b| {
|
||||||
|
let ord = a.state.to_string().cmp(&b.state.to_string());
|
||||||
|
if asc {
|
||||||
|
ord
|
||||||
|
} else {
|
||||||
|
ord.reverse()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_env(&self) -> Option<&EnvMetadata> {
|
||||||
|
self.filtered
|
||||||
|
.get(self.selected)
|
||||||
|
.and_then(|&i| self.environments.get(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_count(&self) -> usize {
|
||||||
|
self.filtered.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyCode) -> AppAction {
|
||||||
|
// Search input mode
|
||||||
|
if self.input_mode == InputMode::Search {
|
||||||
|
return self.handle_search_key(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename input mode
|
||||||
|
if self.input_mode == InputMode::Rename {
|
||||||
|
return self.handle_rename_key(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation dialog active
|
||||||
|
if let Some(ref action) = self.show_confirm.clone() {
|
||||||
|
if let KeyCode::Char('y' | 'Y') = key {
|
||||||
|
self.execute_confirmed_action(action);
|
||||||
|
self.show_confirm = None;
|
||||||
|
return AppAction::Refresh;
|
||||||
|
}
|
||||||
|
self.show_confirm = None;
|
||||||
|
"cancelled".clone_into(&mut self.status_message);
|
||||||
|
return AppAction::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.view {
|
||||||
|
View::Help => match key {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => {
|
||||||
|
self.view = View::List;
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
_ => AppAction::None,
|
||||||
|
},
|
||||||
|
View::Detail => self.handle_detail_key(key),
|
||||||
|
View::List => self.handle_list_key(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_detail_key(&mut self, key: KeyCode) -> AppAction {
|
||||||
|
match key {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => {
|
||||||
|
self.view = View::List;
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
self.prompt_destroy();
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') => {
|
||||||
|
self.action_freeze();
|
||||||
|
AppAction::Refresh
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
self.action_archive();
|
||||||
|
AppAction::Refresh
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
self.start_rename();
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
_ => AppAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_list_key(&mut self, key: KeyCode) -> AppAction {
|
||||||
|
match key {
|
||||||
|
KeyCode::Char('q') => AppAction::Quit,
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
if !self.filtered.is_empty() {
|
||||||
|
self.selected = (self.selected + 1).min(self.filtered.len() - 1);
|
||||||
|
}
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('g') | KeyCode::Home => {
|
||||||
|
self.selected = 0;
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('G') | KeyCode::End => {
|
||||||
|
if !self.filtered.is_empty() {
|
||||||
|
self.selected = self.filtered.len() - 1;
|
||||||
|
}
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if self.selected_env().is_some() {
|
||||||
|
self.view = View::Detail;
|
||||||
|
}
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => AppAction::Refresh,
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
self.prompt_destroy();
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') => {
|
||||||
|
self.action_freeze();
|
||||||
|
AppAction::Refresh
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
self.action_archive();
|
||||||
|
AppAction::Refresh
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
self.start_rename();
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.input_mode = InputMode::Search;
|
||||||
|
self.text_input.clear();
|
||||||
|
self.input_cursor = 0;
|
||||||
|
"search: ".clone_into(&mut self.status_message);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
self.cycle_sort();
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('S') => {
|
||||||
|
self.sort_ascending = !self.sort_ascending;
|
||||||
|
self.apply_sort();
|
||||||
|
self.apply_filter();
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('?') => {
|
||||||
|
self.view = View::Help;
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
_ => AppAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_search_key(&mut self, key: KeyCode) -> AppAction {
|
||||||
|
match key {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.filter.clear();
|
||||||
|
self.apply_filter();
|
||||||
|
self.status_message = format!("{} environment(s)", self.environments.len());
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.filter = self.text_input.clone();
|
||||||
|
self.apply_filter();
|
||||||
|
self.status_message = if self.filter.is_empty() {
|
||||||
|
format!("{} environment(s)", self.environments.len())
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"filter '{}': {} match(es)",
|
||||||
|
self.filter,
|
||||||
|
self.filtered.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.text_input.insert(self.input_cursor, c);
|
||||||
|
self.input_cursor += 1;
|
||||||
|
self.filter = self.text_input.clone();
|
||||||
|
self.apply_filter();
|
||||||
|
self.status_message = format!("search: {}", self.text_input);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if self.input_cursor > 0 {
|
||||||
|
self.input_cursor -= 1;
|
||||||
|
self.text_input.remove(self.input_cursor);
|
||||||
|
self.filter = self.text_input.clone();
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
self.status_message = format!("search: {}", self.text_input);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
_ => AppAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_rename_key(&mut self, key: KeyCode) -> AppAction {
|
||||||
|
match key {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
"rename cancelled".clone_into(&mut self.status_message);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
let new_name = self.text_input.clone();
|
||||||
|
if let Some(env) = self.selected_env() {
|
||||||
|
let env_id = env.env_id.clone();
|
||||||
|
match self.engine().rename(&env_id, &new_name) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.status_message = format!("renamed to '{new_name}'");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status_message = format!("rename failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppAction::Refresh
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.text_input.insert(self.input_cursor, c);
|
||||||
|
self.input_cursor += 1;
|
||||||
|
self.status_message = format!("rename: {}", self.text_input);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if self.input_cursor > 0 {
|
||||||
|
self.input_cursor -= 1;
|
||||||
|
self.text_input.remove(self.input_cursor);
|
||||||
|
}
|
||||||
|
self.status_message = format!("rename: {}", self.text_input);
|
||||||
|
AppAction::None
|
||||||
|
}
|
||||||
|
_ => AppAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_destroy(&mut self) {
|
||||||
|
if let Some(env) = self.selected_env() {
|
||||||
|
let label = env.name.clone().unwrap_or_else(|| env.short_id.to_string());
|
||||||
|
self.show_confirm = Some(format!("destroy:{}", env.env_id));
|
||||||
|
self.status_message = format!("destroy '{label}'? (y/n)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_freeze(&mut self) {
|
||||||
|
if let Some(env) = self.selected_env() {
|
||||||
|
let env_id = env.env_id.to_string();
|
||||||
|
let label = env.name.clone().unwrap_or_else(|| env.short_id.to_string());
|
||||||
|
match self.engine().freeze(&env_id) {
|
||||||
|
Ok(()) => self.status_message = format!("frozen '{label}'"),
|
||||||
|
Err(e) => self.status_message = format!("freeze failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_archive(&mut self) {
|
||||||
|
if let Some(env) = self.selected_env() {
|
||||||
|
let env_id = env.env_id.to_string();
|
||||||
|
let label = env.name.clone().unwrap_or_else(|| env.short_id.to_string());
|
||||||
|
match self.engine().archive(&env_id) {
|
||||||
|
Ok(()) => self.status_message = format!("archived '{label}'"),
|
||||||
|
Err(e) => self.status_message = format!("archive failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_rename(&mut self) {
|
||||||
|
if self.selected_env().is_some() {
|
||||||
|
self.input_mode = InputMode::Rename;
|
||||||
|
self.text_input.clear();
|
||||||
|
self.input_cursor = 0;
|
||||||
|
"rename: ".clone_into(&mut self.status_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cycle_sort(&mut self) {
|
||||||
|
self.sort_column = match self.sort_column {
|
||||||
|
SortColumn::ShortId => SortColumn::Name,
|
||||||
|
SortColumn::Name => SortColumn::State,
|
||||||
|
SortColumn::State => SortColumn::ShortId,
|
||||||
|
};
|
||||||
|
self.apply_sort();
|
||||||
|
self.apply_filter();
|
||||||
|
self.status_message = format!(
|
||||||
|
"sort: {:?} {}",
|
||||||
|
self.sort_column,
|
||||||
|
if self.sort_ascending { "↑" } else { "↓" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_confirmed_action(&mut self, action: &str) {
|
||||||
|
if let Some(env_id) = action.strip_prefix("destroy:") {
|
||||||
|
match self.engine().destroy(env_id) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.status_message = format!("destroyed {}", &env_id[..12.min(env_id.len())]);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status_message = format!("destroy failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
crates/karapace-tui/src/lib.rs
Normal file
219
crates/karapace-tui/src/lib.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
//! Terminal UI for interactive Karapace environment management.
|
||||||
|
//!
|
||||||
|
//! This crate provides a ratatui-based TUI with environment listing, detail views,
|
||||||
|
//! search/filter, sorting, and keyboard-driven lifecycle actions (destroy, freeze,
|
||||||
|
//! archive, rename).
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
pub use app::{App, AppAction, InputMode, SortColumn, View};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::prelude::*;
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(store_root: &Path) -> Result<(), String> {
|
||||||
|
enable_raw_mode().map_err(|e| format!("failed to enable raw mode: {e}"))?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen).map_err(|e| format!("alternate screen: {e}"))?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal init: {e}"))?;
|
||||||
|
|
||||||
|
let mut app = App::new(store_root);
|
||||||
|
app.refresh().ok();
|
||||||
|
|
||||||
|
let result = run_loop(&mut terminal, &mut app);
|
||||||
|
|
||||||
|
disable_raw_mode().map_err(|e| format!("failed to disable raw mode: {e}"))?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)
|
||||||
|
.map_err(|e| format!("leave alternate screen: {e}"))?;
|
||||||
|
terminal
|
||||||
|
.show_cursor()
|
||||||
|
.map_err(|e| format!("show cursor: {e}"))?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
app: &mut App,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
loop {
|
||||||
|
terminal
|
||||||
|
.draw(|f| ui::draw(f, app))
|
||||||
|
.map_err(|e| format!("draw: {e}"))?;
|
||||||
|
|
||||||
|
if event::poll(std::time::Duration::from_millis(250)).map_err(|e| format!("poll: {e}"))? {
|
||||||
|
if let Event::Key(key) = event::read().map_err(|e| format!("read: {e}"))? {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match app.handle_key(key.code) {
|
||||||
|
AppAction::None => {}
|
||||||
|
AppAction::Quit => return Ok(()),
|
||||||
|
AppAction::Refresh => {
|
||||||
|
app.refresh().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
fn make_app() -> (tempfile::TempDir, App) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let app = App::new(dir.path());
|
||||||
|
(dir, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_creates_and_refreshes() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
assert!(app.refresh().is_ok() || app.refresh().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_quit_key() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
assert_eq!(app.handle_key(KeyCode::Char('q')), AppAction::Quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_refresh_key() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
assert_eq!(app.handle_key(KeyCode::Char('r')), AppAction::Refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_navigation_j_k() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
assert_eq!(app.handle_key(KeyCode::Char('j')), AppAction::None);
|
||||||
|
assert_eq!(app.handle_key(KeyCode::Char('k')), AppAction::None);
|
||||||
|
assert_eq!(app.selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_help_view() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.handle_key(KeyCode::Char('?'));
|
||||||
|
assert_eq!(app.view, View::Help);
|
||||||
|
app.handle_key(KeyCode::Esc);
|
||||||
|
assert_eq!(app.view, View::List);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_search_mode_enter_exit() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.handle_key(KeyCode::Char('/'));
|
||||||
|
assert_eq!(app.input_mode, InputMode::Search);
|
||||||
|
app.handle_key(KeyCode::Esc);
|
||||||
|
assert_eq!(app.input_mode, InputMode::Normal);
|
||||||
|
assert!(app.filter.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_search_typing() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.handle_key(KeyCode::Char('/'));
|
||||||
|
app.handle_key(KeyCode::Char('t'));
|
||||||
|
app.handle_key(KeyCode::Char('e'));
|
||||||
|
app.handle_key(KeyCode::Char('s'));
|
||||||
|
app.handle_key(KeyCode::Char('t'));
|
||||||
|
assert_eq!(app.text_input, "test");
|
||||||
|
app.handle_key(KeyCode::Enter);
|
||||||
|
assert_eq!(app.filter, "test");
|
||||||
|
assert_eq!(app.input_mode, InputMode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_search_backspace() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.handle_key(KeyCode::Char('/'));
|
||||||
|
app.handle_key(KeyCode::Char('a'));
|
||||||
|
app.handle_key(KeyCode::Char('b'));
|
||||||
|
app.handle_key(KeyCode::Backspace);
|
||||||
|
assert_eq!(app.text_input, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_sort_cycle() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
assert_eq!(app.sort_column, SortColumn::Name);
|
||||||
|
app.handle_key(KeyCode::Char('s'));
|
||||||
|
assert_eq!(app.sort_column, SortColumn::State);
|
||||||
|
app.handle_key(KeyCode::Char('s'));
|
||||||
|
assert_eq!(app.sort_column, SortColumn::ShortId);
|
||||||
|
app.handle_key(KeyCode::Char('s'));
|
||||||
|
assert_eq!(app.sort_column, SortColumn::Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_sort_direction_toggle() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
assert!(app.sort_ascending);
|
||||||
|
app.handle_key(KeyCode::Char('S'));
|
||||||
|
assert!(!app.sort_ascending);
|
||||||
|
app.handle_key(KeyCode::Char('S'));
|
||||||
|
assert!(app.sort_ascending);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_detail_view_enter_back() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
// No envs, Enter should not switch view
|
||||||
|
app.handle_key(KeyCode::Enter);
|
||||||
|
assert_eq!(app.view, View::List);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_confirm_cancel() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.show_confirm = Some("destroy:abc123".to_owned());
|
||||||
|
app.handle_key(KeyCode::Char('n'));
|
||||||
|
assert!(app.show_confirm.is_none());
|
||||||
|
assert_eq!(app.status_message, "cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_home_end_keys() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.handle_key(KeyCode::Home);
|
||||||
|
assert_eq!(app.selected, 0);
|
||||||
|
app.handle_key(KeyCode::End);
|
||||||
|
assert_eq!(app.selected, 0); // No envs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_rename_mode_enter_exit() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
// No envs, so rename shouldn't activate
|
||||||
|
app.handle_key(KeyCode::Char('n'));
|
||||||
|
assert_eq!(app.input_mode, InputMode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_visible_count_empty() {
|
||||||
|
let (_dir, app) = make_app();
|
||||||
|
assert_eq!(app.visible_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_filter_with_no_envs() {
|
||||||
|
let (_dir, mut app) = make_app();
|
||||||
|
app.filter = "test".to_owned();
|
||||||
|
app.apply_filter();
|
||||||
|
assert!(app.filtered.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
251
crates/karapace-tui/src/ui.rs
Normal file
251
crates/karapace-tui/src/ui.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
use crate::app::{App, InputMode, View};
|
||||||
|
use ratatui::{
|
||||||
|
prelude::*,
|
||||||
|
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame<'_>, app: &App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
draw_header(f, chunks[0]);
|
||||||
|
|
||||||
|
match app.view {
|
||||||
|
View::List => draw_list(f, app, chunks[1]),
|
||||||
|
View::Detail => draw_detail(f, app, chunks[1]),
|
||||||
|
View::Help => draw_help(f, chunks[1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_status_bar(f, app, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_header(f: &mut Frame<'_>, area: Rect) {
|
||||||
|
let title = Paragraph::new(format!(
|
||||||
|
" Karapace Environment Manager v{}",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
f.render_widget(title, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_list(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
if app.environments.is_empty() {
|
||||||
|
let msg = Paragraph::new(" No environments found. Press 'q' to quit.").block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Environments "),
|
||||||
|
);
|
||||||
|
f.render_widget(msg, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from("SHORT_ID").style(Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Cell::from("NAME").style(Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Cell::from("STATE").style(Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Cell::from("ENV_ID").style(Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
])
|
||||||
|
.height(1);
|
||||||
|
|
||||||
|
let rows: Vec<Row<'_>> = app
|
||||||
|
.filtered
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(vi, &ei)| {
|
||||||
|
let env = &app.environments[ei];
|
||||||
|
let style = if vi == app.selected {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
let state_style = state_color(&env.state.to_string());
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(env.short_id.to_string()),
|
||||||
|
Cell::from(env.name.as_deref().unwrap_or("").to_owned()),
|
||||||
|
Cell::from(env.state.to_string()).style(state_style),
|
||||||
|
Cell::from(env.env_id.to_string()),
|
||||||
|
])
|
||||||
|
.style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Length(14),
|
||||||
|
Constraint::Length(16),
|
||||||
|
Constraint::Length(10),
|
||||||
|
Constraint::Min(20),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||||
|
" Environments ({}/{}) ",
|
||||||
|
app.visible_count(),
|
||||||
|
app.environments.len()
|
||||||
|
)));
|
||||||
|
|
||||||
|
f.render_widget(table, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_detail(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let Some(env) = app.selected_env() else {
|
||||||
|
let msg = Paragraph::new(" No environment selected.")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Detail "));
|
||||||
|
f.render_widget(msg, area);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"env_id: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(env.env_id.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"short_id: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(env.short_id.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"name: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(env.name.as_deref().unwrap_or("(none)")),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"state: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(env.state.to_string(), state_color(&env.state.to_string())),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"base_layer: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(env.base_layer.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"deps: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(env.dependency_layers.len().to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"ref_count: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(env.ref_count.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"created_at: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(&env.created_at),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"updated_at: ",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(&env.updated_at),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" [Esc] back [d] destroy [f] freeze [a] archive [n] rename",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let detail = Paragraph::new(text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||||
|
" {} ",
|
||||||
|
env.name.as_deref().unwrap_or(&env.short_id)
|
||||||
|
)))
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
f.render_widget(detail, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame<'_>, area: Rect) {
|
||||||
|
let text = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Keybindings",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(" j / ↓ Move down"),
|
||||||
|
Line::from(" k / ↑ Move up"),
|
||||||
|
Line::from(" g / Home Go to top"),
|
||||||
|
Line::from(" G / End Go to bottom"),
|
||||||
|
Line::from(" Enter View details"),
|
||||||
|
Line::from(" d Destroy (with confirm)"),
|
||||||
|
Line::from(" f Freeze environment"),
|
||||||
|
Line::from(" a Archive environment"),
|
||||||
|
Line::from(" n Rename environment"),
|
||||||
|
Line::from(" / Search / filter"),
|
||||||
|
Line::from(" s Cycle sort column"),
|
||||||
|
Line::from(" S Toggle sort direction"),
|
||||||
|
Line::from(" r Refresh list"),
|
||||||
|
Line::from(" ? Show this help"),
|
||||||
|
Line::from(" q / Esc Quit / Back"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let help = Paragraph::new(text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Help "))
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
f.render_widget(help, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let status = if app.show_confirm.is_some() || app.input_mode != InputMode::Normal {
|
||||||
|
Paragraph::new(format!(" {} ", app.status_message)).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Paragraph::new(format!(
|
||||||
|
" {} │ [j/k] nav [Enter] detail [d] destroy [f] freeze [/] search [?] help [q] quit",
|
||||||
|
app.status_message
|
||||||
|
))
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
};
|
||||||
|
f.render_widget(status, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_color(state: &str) -> Style {
|
||||||
|
match state {
|
||||||
|
"built" => Style::default().fg(Color::Green),
|
||||||
|
"running" => Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
"defined" => Style::default().fg(Color::Yellow),
|
||||||
|
"frozen" => Style::default().fg(Color::Blue),
|
||||||
|
"archived" => Style::default().fg(Color::DarkGray),
|
||||||
|
_ => Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue