mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-26 21:43:09 +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