1use super::form::{window, Form, FormEvent};
5use super::keys::Key;
6use super::{Frame, Style};
7use crate::profile::{ConfigFile, Profile};
8
9pub const MIN_WIDTH: usize = 60;
10pub const MIN_HEIGHT: usize = 16;
11
12pub trait Services {
14 fn save(&mut self, file: &ConfigFile) -> Result<(), String>;
16 fn check(&mut self, profile: &Profile) -> Result<String, String>;
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Flow {
22 Continue,
23 Quit,
24}
25
26enum Mode {
27 List,
28 ConfirmRemove(String),
30 Form(Box<Form>),
31}
32
33pub struct App {
34 file: ConfigFile,
35 path: String,
36 session: Option<String>,
37 selected: usize,
38 mode: Mode,
39 status: Option<(bool, String)>,
40}
41
42impl App {
43 pub fn new(file: ConfigFile, path: String, session: Option<String>) -> App {
45 App {
46 file,
47 path,
48 session,
49 selected: 0,
50 mode: Mode::List,
51 status: None,
52 }
53 }
54
55 pub fn file(&self) -> &ConfigFile {
56 &self.file
57 }
58
59 fn selected_name(&self) -> Option<String> {
60 self.file
61 .profiles
62 .get(self.selected)
63 .map(|(n, _)| n.clone())
64 }
65
66 pub fn handle(&mut self, key: Key, services: &mut dyn Services) -> Flow {
67 if key == Key::Ctrl('c') {
68 return Flow::Quit;
69 }
70 match std::mem::replace(&mut self.mode, Mode::List) {
71 Mode::List => self.handle_list(key, services),
72 Mode::ConfirmRemove(name) => {
73 if matches!(key, Key::Char('y' | 'Y')) {
74 self.remove(&name, services);
75 } else {
76 self.status = Some((false, "Nothing was removed.".to_owned()));
77 }
78 Flow::Continue
79 }
80 Mode::Form(mut form) => {
81 let event = form.handle(key);
82 self.mode = Mode::Form(form);
83 self.handle_form_event(event, services)
84 }
85 }
86 }
87
88 fn handle_list(&mut self, key: Key, services: &mut dyn Services) -> Flow {
89 let count = self.file.profiles.len();
90 match key {
91 Key::Up | Key::Char('k') => self.selected = self.selected.saturating_sub(1),
92 Key::Down | Key::Char('j') => {
93 self.selected = (self.selected + 1).min(count.saturating_sub(1))
94 }
95 Key::Home => self.selected = 0,
96 Key::End => self.selected = count.saturating_sub(1),
97 Key::Enter => {
98 if let Some((name, profile)) = self.file.profiles.get(self.selected) {
99 let is_default = self.file.default.as_deref() == Some(name.as_str());
100 let taken = self.file.names().iter().map(|n| n.to_string()).collect();
101 self.mode = Mode::Form(Box::new(Form::new(
102 Some((name, profile)),
103 is_default,
104 taken,
105 )));
106 self.status = None;
107 }
108 }
109 Key::Char('n' | 'N') => {
110 let taken = self.file.names().iter().map(|n| n.to_string()).collect();
111 let first = self.file.profiles.is_empty();
112 self.mode = Mode::Form(Box::new(Form::new(None, first, taken)));
113 self.status = None;
114 }
115 Key::Char('d' | 'D') => {
116 if let Some(name) = self.selected_name() {
117 let mut next = self.file.clone();
118 if next.set_default(&name).is_ok() {
119 self.persist(next, format!("`{name}` is the default profile."), services);
120 }
121 }
122 }
123 Key::Delete | Key::Char('r' | 'R') => {
124 if let Some(name) = self.selected_name() {
125 self.mode = Mode::ConfirmRemove(name);
126 self.status = None;
127 }
128 }
129 Key::Char('q' | 'Q') | Key::Esc | Key::Ctrl('d') => return Flow::Quit,
130 _ => {}
131 }
132 Flow::Continue
133 }
134
135 fn persist(
137 &mut self,
138 next: ConfigFile,
139 ok_message: String,
140 services: &mut dyn Services,
141 ) -> bool {
142 match services.save(&next) {
143 Ok(()) => {
144 self.file = next;
145 self.selected = self
146 .selected
147 .min(self.file.profiles.len().saturating_sub(1));
148 self.status = Some((false, ok_message));
149 true
150 }
151 Err(e) => {
152 self.status = Some((true, e));
153 false
154 }
155 }
156 }
157
158 fn remove(&mut self, name: &str, services: &mut dyn Services) {
159 let mut next = self.file.clone();
160 if next.remove(name).is_ok() {
161 self.persist(next, format!("Profile `{name}` removed."), services);
162 }
163 }
164
165 fn handle_form_event(&mut self, event: FormEvent, services: &mut dyn Services) -> Flow {
166 match event {
167 FormEvent::None => Flow::Continue,
168 FormEvent::Quit => Flow::Quit,
169 FormEvent::Cancel => {
170 self.mode = Mode::List;
171 Flow::Continue
172 }
173 FormEvent::Test => {
174 if let Mode::Form(form) = &mut self.mode {
175 form.status = Some(if form.is_valid() {
176 match services.check(&form.profile()) {
177 Ok(message) => (false, message),
178 Err(message) => (true, message),
179 }
180 } else {
181 (true, "Fix the fields in red before testing.".to_owned())
182 });
183 }
184 Flow::Continue
185 }
186 FormEvent::Save => {
187 let Mode::Form(form) = &mut self.mode else {
188 return Flow::Continue;
189 };
190 if !form.is_valid() {
191 form.status = Some((true, "Fix the fields in red before saving.".to_owned()));
192 return Flow::Continue;
193 }
194 let name = form.name().to_owned();
195 let (original, profile, is_default) =
196 (form.original.clone(), form.profile(), form.is_default);
197 let mut next = self.file.clone();
198 let applied = match &original {
199 None => next.add(&name, profile),
200 Some(old) => rename_and_replace(&mut next, old, &name, profile),
201 };
202 if let Err(e) = applied {
203 if let Mode::Form(form) = &mut self.mode {
204 form.status = Some((true, e));
205 }
206 return Flow::Continue;
207 }
208 if is_default {
209 next.default = Some(name.clone());
210 } else if next.default.as_deref() == Some(name.as_str()) {
211 next.default = None;
212 }
213 match services.save(&next) {
214 Ok(()) => {
215 self.selected = next
216 .profiles
217 .iter()
218 .position(|(n, _)| *n == name)
219 .unwrap_or(0);
220 self.file = next;
221 self.mode = Mode::List;
222 self.status = Some((false, format!("Profile `{name}` saved.")));
223 }
224 Err(e) => {
225 if let Mode::Form(form) = &mut self.mode {
226 form.status = Some((true, e));
227 }
228 }
229 }
230 Flow::Continue
231 }
232 }
233 }
234
235 pub fn render(&self, style: Style, width: usize, height: usize) -> Frame {
236 if width < MIN_WIDTH || height < MIN_HEIGHT {
237 return Frame {
238 lines: vec![
239 style.paint("1;31", "Terminal too small."),
240 format!("I need at least {MIN_WIDTH} columns by {MIN_HEIGHT} lines (now: {width}x{height})."),
241 "Enlarge the window. Ctrl+C quits.".to_owned(),
242 ],
243 cursor: None,
244 };
245 }
246 match &self.mode {
247 Mode::Form(form) => form.render(style, width, height),
248 _ => self.render_list(style, width, height),
249 }
250 }
251
252 fn render_list(&self, style: Style, width: usize, height: usize) -> Frame {
253 let mut lines = vec![
254 format!(
255 " {} {}",
256 style.paint("1", "taba-cli profiles"),
257 style.paint("2", &self.path)
258 ),
259 String::new(),
260 ];
261 if self.file.profiles.is_empty() {
262 lines.push(
263 " There are no profiles. Without a profile the built-in default applies (http://127.0.0.1:8080)."
264 .to_owned(),
265 );
266 lines.push(" Press n to create the first one.".to_owned());
267 } else {
268 let name_w = self
269 .file
270 .profiles
271 .iter()
272 .map(|(n, _)| n.chars().count() + session_suffix(&self.session, n).chars().count())
273 .max()
274 .unwrap_or(4)
275 .max(4);
276 let url_w = (width.saturating_sub(name_w + 36)).clamp(12, 40);
277 lines.push(style.paint(
278 "2",
279 &format!(
280 " {:<2}{:<name_w$} {:<url_w$} {:<12} default",
281 "", "name", "url", "token-file"
282 ),
283 ));
284 for (n, (name, profile)) in self.file.profiles.iter().enumerate() {
285 let mark = if self.file.default.as_deref() == Some(name.as_str()) {
286 "●"
287 } else {
288 "○"
289 };
290 let label = format!("{name}{}", session_suffix(&self.session, name));
291 let row = format!(
292 "{:<name_w$} {:<url_w$} {:<12} {mark}",
293 label,
294 cut(profile.get("url").unwrap_or("—"), url_w),
295 cut(profile.get("token-file").unwrap_or("—"), 12),
296 );
297 if n == self.selected {
298 lines.push(format!(
299 " {}{}",
300 style.paint("36", "▶ "),
301 style.paint("1", &row)
302 ));
303 } else {
304 lines.push(format!(" {row}"));
305 }
306 }
307 }
308 lines.push(String::new());
309 if let Mode::ConfirmRemove(name) = &self.mode {
310 lines.push(format!(
311 " {}",
312 style.paint("33", &format!("Remove \"{name}\"? [y/N]"))
313 ));
314 } else if let Some((is_error, message)) = &self.status {
315 lines.push(format!(
316 " {}",
317 style.paint(if *is_error { "31" } else { "32" }, message)
318 ));
319 }
320 let footer = " ↑↓ choose Enter open n new d default Del remove q quit";
321 window(lines, 0, None, footer, style, width, height)
322 }
323}
324
325fn session_suffix(session: &Option<String>, name: &str) -> &'static str {
326 if session.as_deref() == Some(name) {
327 " (session)"
328 } else {
329 ""
330 }
331}
332
333fn cut(text: &str, max: usize) -> String {
334 if text.chars().count() <= max {
335 text.to_owned()
336 } else {
337 format!(
338 "{}…",
339 text.chars().take(max.saturating_sub(1)).collect::<String>()
340 )
341 }
342}
343
344fn rename_and_replace(
347 file: &mut ConfigFile,
348 old: &str,
349 name: &str,
350 profile: Profile,
351) -> Result<(), String> {
352 let at = file
353 .profiles
354 .iter()
355 .position(|(n, _)| n == old)
356 .ok_or_else(|| format!("the profile `{old}` no longer exists"))?;
357 if name != old && file.get(name).is_some() {
358 return Err(format!("a profile `{name}` already exists"));
359 }
360 file.profiles[at] = (name.to_owned(), profile);
361 if file.default.as_deref() == Some(old) {
362 file.default = Some(name.to_owned());
363 }
364 Ok(())
365}
366
367#[cfg(test)]
368mod tests;