use super::form::{window, Form, FormEvent};
use super::keys::Key;
use super::{Frame, Style};
use crate::profile::{ConfigFile, Profile};
pub const MIN_WIDTH: usize = 60;
pub const MIN_HEIGHT: usize = 16;
pub trait Services {
fn save(&mut self, file: &ConfigFile) -> Result<(), String>;
fn check(&mut self, profile: &Profile) -> Result<String, String>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flow {
Continue,
Quit,
}
enum Mode {
List,
ConfirmRemove(String),
Form(Box<Form>),
}
pub struct App {
file: ConfigFile,
path: String,
session: Option<String>,
selected: usize,
mode: Mode,
status: Option<(bool, String)>,
}
impl App {
pub fn new(file: ConfigFile, path: String, session: Option<String>) -> App {
App {
file,
path,
session,
selected: 0,
mode: Mode::List,
status: None,
}
}
pub fn file(&self) -> &ConfigFile {
&self.file
}
fn selected_name(&self) -> Option<String> {
self.file
.profiles
.get(self.selected)
.map(|(n, _)| n.clone())
}
pub fn handle(&mut self, key: Key, services: &mut dyn Services) -> Flow {
if key == Key::Ctrl('c') {
return Flow::Quit;
}
match std::mem::replace(&mut self.mode, Mode::List) {
Mode::List => self.handle_list(key, services),
Mode::ConfirmRemove(name) => {
if matches!(key, Key::Char('y' | 'Y')) {
self.remove(&name, services);
} else {
self.status = Some((false, "Nothing was removed.".to_owned()));
}
Flow::Continue
}
Mode::Form(mut form) => {
let event = form.handle(key);
self.mode = Mode::Form(form);
self.handle_form_event(event, services)
}
}
}
fn handle_list(&mut self, key: Key, services: &mut dyn Services) -> Flow {
let count = self.file.profiles.len();
match key {
Key::Up | Key::Char('k') => self.selected = self.selected.saturating_sub(1),
Key::Down | Key::Char('j') => {
self.selected = (self.selected + 1).min(count.saturating_sub(1))
}
Key::Home => self.selected = 0,
Key::End => self.selected = count.saturating_sub(1),
Key::Enter => {
if let Some((name, profile)) = self.file.profiles.get(self.selected) {
let is_default = self.file.default.as_deref() == Some(name.as_str());
let taken = self.file.names().iter().map(|n| n.to_string()).collect();
self.mode = Mode::Form(Box::new(Form::new(
Some((name, profile)),
is_default,
taken,
)));
self.status = None;
}
}
Key::Char('n' | 'N') => {
let taken = self.file.names().iter().map(|n| n.to_string()).collect();
let first = self.file.profiles.is_empty();
self.mode = Mode::Form(Box::new(Form::new(None, first, taken)));
self.status = None;
}
Key::Char('d' | 'D') => {
if let Some(name) = self.selected_name() {
let mut next = self.file.clone();
if next.set_default(&name).is_ok() {
self.persist(next, format!("`{name}` is the default profile."), services);
}
}
}
Key::Delete | Key::Char('r' | 'R') => {
if let Some(name) = self.selected_name() {
self.mode = Mode::ConfirmRemove(name);
self.status = None;
}
}
Key::Char('q' | 'Q') | Key::Esc | Key::Ctrl('d') => return Flow::Quit,
_ => {}
}
Flow::Continue
}
fn persist(
&mut self,
next: ConfigFile,
ok_message: String,
services: &mut dyn Services,
) -> bool {
match services.save(&next) {
Ok(()) => {
self.file = next;
self.selected = self
.selected
.min(self.file.profiles.len().saturating_sub(1));
self.status = Some((false, ok_message));
true
}
Err(e) => {
self.status = Some((true, e));
false
}
}
}
fn remove(&mut self, name: &str, services: &mut dyn Services) {
let mut next = self.file.clone();
if next.remove(name).is_ok() {
self.persist(next, format!("Profile `{name}` removed."), services);
}
}
fn handle_form_event(&mut self, event: FormEvent, services: &mut dyn Services) -> Flow {
match event {
FormEvent::None => Flow::Continue,
FormEvent::Quit => Flow::Quit,
FormEvent::Cancel => {
self.mode = Mode::List;
Flow::Continue
}
FormEvent::Test => {
if let Mode::Form(form) = &mut self.mode {
form.status = Some(if form.is_valid() {
match services.check(&form.profile()) {
Ok(message) => (false, message),
Err(message) => (true, message),
}
} else {
(true, "Fix the fields in red before testing.".to_owned())
});
}
Flow::Continue
}
FormEvent::Save => {
let Mode::Form(form) = &mut self.mode else {
return Flow::Continue;
};
if !form.is_valid() {
form.status = Some((true, "Fix the fields in red before saving.".to_owned()));
return Flow::Continue;
}
let name = form.name().to_owned();
let (original, profile, is_default) =
(form.original.clone(), form.profile(), form.is_default);
let mut next = self.file.clone();
let applied = match &original {
None => next.add(&name, profile),
Some(old) => rename_and_replace(&mut next, old, &name, profile),
};
if let Err(e) = applied {
if let Mode::Form(form) = &mut self.mode {
form.status = Some((true, e));
}
return Flow::Continue;
}
if is_default {
next.default = Some(name.clone());
} else if next.default.as_deref() == Some(name.as_str()) {
next.default = None;
}
match services.save(&next) {
Ok(()) => {
self.selected = next
.profiles
.iter()
.position(|(n, _)| *n == name)
.unwrap_or(0);
self.file = next;
self.mode = Mode::List;
self.status = Some((false, format!("Profile `{name}` saved.")));
}
Err(e) => {
if let Mode::Form(form) = &mut self.mode {
form.status = Some((true, e));
}
}
}
Flow::Continue
}
}
}
pub fn render(&self, style: Style, width: usize, height: usize) -> Frame {
if width < MIN_WIDTH || height < MIN_HEIGHT {
return Frame {
lines: vec![
style.paint("1;31", "Terminal too small."),
format!("I need at least {MIN_WIDTH} columns by {MIN_HEIGHT} lines (now: {width}x{height})."),
"Enlarge the window. Ctrl+C quits.".to_owned(),
],
cursor: None,
};
}
match &self.mode {
Mode::Form(form) => form.render(style, width, height),
_ => self.render_list(style, width, height),
}
}
fn render_list(&self, style: Style, width: usize, height: usize) -> Frame {
let mut lines = vec![
format!(
" {} {}",
style.paint("1", "taba-cli profiles"),
style.paint("2", &self.path)
),
String::new(),
];
if self.file.profiles.is_empty() {
lines.push(
" There are no profiles. Without a profile the built-in default applies (http://127.0.0.1:8080)."
.to_owned(),
);
lines.push(" Press n to create the first one.".to_owned());
} else {
let name_w = self
.file
.profiles
.iter()
.map(|(n, _)| n.chars().count() + session_suffix(&self.session, n).chars().count())
.max()
.unwrap_or(4)
.max(4);
let url_w = (width.saturating_sub(name_w + 36)).clamp(12, 40);
lines.push(style.paint(
"2",
&format!(
" {:<2}{:<name_w$} {:<url_w$} {:<12} default",
"", "name", "url", "token-file"
),
));
for (n, (name, profile)) in self.file.profiles.iter().enumerate() {
let mark = if self.file.default.as_deref() == Some(name.as_str()) {
"●"
} else {
"○"
};
let label = format!("{name}{}", session_suffix(&self.session, name));
let row = format!(
"{:<name_w$} {:<url_w$} {:<12} {mark}",
label,
cut(profile.get("url").unwrap_or("—"), url_w),
cut(profile.get("token-file").unwrap_or("—"), 12),
);
if n == self.selected {
lines.push(format!(
" {}{}",
style.paint("36", "▶ "),
style.paint("1", &row)
));
} else {
lines.push(format!(" {row}"));
}
}
}
lines.push(String::new());
if let Mode::ConfirmRemove(name) = &self.mode {
lines.push(format!(
" {}",
style.paint("33", &format!("Remove \"{name}\"? [y/N]"))
));
} else if let Some((is_error, message)) = &self.status {
lines.push(format!(
" {}",
style.paint(if *is_error { "31" } else { "32" }, message)
));
}
let footer = " ↑↓ choose Enter open n new d default Del remove q quit";
window(lines, 0, None, footer, style, width, height)
}
}
fn session_suffix(session: &Option<String>, name: &str) -> &'static str {
if session.as_deref() == Some(name) {
" (session)"
} else {
""
}
}
fn cut(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_owned()
} else {
format!(
"{}…",
text.chars().take(max.saturating_sub(1)).collect::<String>()
)
}
}
fn rename_and_replace(
file: &mut ConfigFile,
old: &str,
name: &str,
profile: Profile,
) -> Result<(), String> {
let at = file
.profiles
.iter()
.position(|(n, _)| n == old)
.ok_or_else(|| format!("the profile `{old}` no longer exists"))?;
if name != old && file.get(name).is_some() {
return Err(format!("a profile `{name}` already exists"));
}
file.profiles[at] = (name.to_owned(), profile);
if file.default.as_deref() == Some(old) {
file.default = Some(name.to_owned());
}
Ok(())
}
#[cfg(test)]
mod tests;