Skip to main content

taba_cli/tui/screens/profiles/
mod.rs

1//! The Profiles tab (doc 34, section 6.7): list, create, edit, remove, choose the default, test a connection and
2//! connect with another profile. It replaces the old profiles screen (D-TUI-8) and works on the same file
3//! (`config.yml`), through the effects `SaveProfiles`, `CheckProfile` and `Connect`.
4
5use crate::profile::{self, ConfigFile, Profile};
6use crate::tui::api::Severity;
7use crate::tui::editor::{Editor, Field, Outcome, Purpose};
8use crate::tui::effect::Effect;
9use crate::tui::keymap::Action;
10use crate::tui::model::{Confirm, Modal, Model, Pending};
11use crate::tui::msg::CheckResult;
12use crate::tui::screens::cluster::moved;
13
14pub mod view;
15
16/// The fields of a profile the form edits; the others (`output`, the timeouts) are kept as they are.
17pub const FORM_FIELDS: [(&str, &str); 4] = [
18    ("url", "editor.field.url"),
19    ("token-file", "editor.field.token-file"),
20    ("token", "editor.field.token"),
21    ("ca-file", "editor.field.ca-file"),
22];
23
24/// The index of the selected profile; the first one if the selection is gone.
25pub fn selected_index(m: &Model) -> Option<usize> {
26    let profiles = &m.profiles.file.profiles;
27    if profiles.is_empty() {
28        return None;
29    }
30    let found = m
31        .profiles
32        .selected
33        .as_ref()
34        .and_then(|name| profiles.iter().position(|(n, _)| n == name));
35    Some(found.unwrap_or(0))
36}
37
38pub fn selected(m: &Model) -> Option<(String, Profile)> {
39    selected_index(m).map(|i| m.profiles.file.profiles[i].clone())
40}
41
42/// The index of the profile a person most likely wants: the default one, or the first.
43pub fn preferred_index(file: &ConfigFile) -> usize {
44    file.default
45        .as_ref()
46        .and_then(|name| file.profiles.iter().position(|(n, _)| n == name))
47        .unwrap_or(0)
48}
49
50fn refuse(m: &mut Model, key: &str) -> Vec<Effect> {
51    let text = m.tx().t(key);
52    m.notify(Severity::Warning, text);
53    Vec::new()
54}
55
56pub fn on_action(m: &mut Model, action: Action) -> Vec<Effect> {
57    match action {
58        Action::Up => step(m, -1),
59        Action::Down => step(m, 1),
60        Action::PageUp => step(m, -10),
61        Action::PageDown => step(m, 10),
62        Action::First => step(m, i32::MIN / 2),
63        Action::Last => step(m, i32::MAX / 2),
64        Action::Enter => crate::tui::menu::open(m),
65        Action::ProfileNew => {
66            open_form(m, None);
67            Vec::new()
68        }
69        Action::ProfileEdit => match selected(m) {
70            Some(chosen) => {
71                open_form(m, Some(chosen));
72                Vec::new()
73            }
74            None => refuse(m, "gate.no-profile"),
75        },
76        Action::ProfileUse => match selected(m) {
77            Some((name, _)) => vec![Effect::Connect(name)],
78            None => refuse(m, "gate.no-profile"),
79        },
80        Action::ProfileDefault => match selected(m) {
81            Some((name, _)) => {
82                if m.profiles.file.default.as_deref() == Some(name.as_str()) {
83                    let text = m
84                        .tx()
85                        .tw("notice.profile-already-default", &[("name", &name)]);
86                    m.notify(Severity::Ok, text);
87                    return Vec::new();
88                }
89                let mut next = m.profiles.file.clone();
90                match next.set_default(&name) {
91                    Ok(()) => vec![Effect::SaveProfiles {
92                        file: next,
93                        select: Some(name),
94                    }],
95                    Err(e) => {
96                        m.notify(Severity::Critical, e);
97                        Vec::new()
98                    }
99                }
100            }
101            None => refuse(m, "gate.no-profile"),
102        },
103        Action::ProfileRemove => match selected(m) {
104            Some((name, _)) => {
105                m.modal = Some(Modal::Confirm(Confirm {
106                    pending: Pending::RemoveProfile(name),
107                    typed: None,
108                    ok_focused: false,
109                }));
110                Vec::new()
111            }
112            None => refuse(m, "gate.no-profile"),
113        },
114        Action::ProfileTest => match selected(m) {
115            Some((_, profile)) => vec![Effect::CheckProfile(profile)],
116            None => refuse(m, "gate.no-profile"),
117        },
118        _ => Vec::new(),
119    }
120}
121
122fn step(m: &mut Model, delta: i32) -> Vec<Effect> {
123    if let Some(current) = selected_index(m) {
124        let next = moved(current, delta, m.profiles.file.profiles.len());
125        m.profiles.selected = Some(m.profiles.file.profiles[next].0.clone());
126    }
127    Vec::new()
128}
129
130pub fn open_form(m: &mut Model, existing: Option<(String, Profile)>) {
131    let (name, profile, is_default, purpose) = match existing {
132        Some((name, profile)) => {
133            let is_default = m.profiles.file.default.as_deref() == Some(name.as_str());
134            let original = name.clone();
135            (name, profile, is_default, Purpose::ProfileEdit { original })
136        }
137        None => (
138            String::new(),
139            Profile::default(),
140            m.profiles.file.profiles.is_empty(),
141            Purpose::ProfileNew,
142        ),
143    };
144    let mut fields = vec![Field::text("name", "editor.field.name", &name)];
145    for (id, label) in FORM_FIELDS {
146        let value = profile.get(id).unwrap_or("");
147        fields.push(if id == "token" {
148            Field::secret(id, label, value)
149        } else {
150            Field::text(id, label, value)
151        });
152    }
153    fields.push(Field::check("default", "editor.field.default", is_default));
154    m.modal = Some(Modal::Editor(Editor::new(purpose, fields)));
155}
156
157/// What the form holds, as a profile: the form's fields over the profile it started from.
158fn profile_of(m: &Model, editor: &Editor) -> Result<Profile, (String, String)> {
159    let mut profile = match &editor.purpose {
160        Purpose::ProfileEdit { original } => {
161            m.profiles.file.get(original).cloned().unwrap_or_default()
162        }
163        _ => Profile::default(),
164    };
165    for (id, _) in FORM_FIELDS {
166        let value = editor.text(id);
167        if value.is_empty() {
168            profile.unset(id);
169        } else if let Err(e) = profile.set(id, value) {
170            return Err((id.to_owned(), e));
171        }
172    }
173    Ok(profile)
174}
175
176/// The keys of the form of a profile.
177pub fn editor_key(m: &mut Model, mut editor: Editor, outcome: Outcome) -> Vec<Effect> {
178    match outcome {
179        Outcome::Cancel => {
180            m.modal = None;
181            // Giving up the first profile goes on with the built-in address.
182            m.awaiting_profile = false;
183            Vec::new()
184        }
185        Outcome::Submit => submit(m, editor),
186        Outcome::Test => match profile_of(m, &editor) {
187            Ok(profile) => {
188                editor.note = None;
189                m.modal = Some(Modal::Editor(editor));
190                vec![Effect::CheckProfile(profile)]
191            }
192            Err((field, e)) => {
193                editor.focus_on(&field);
194                editor.error = Some(e);
195                m.modal = Some(Modal::Editor(editor));
196                Vec::new()
197            }
198        },
199        Outcome::None => {
200            editor.note = None;
201            m.modal = Some(Modal::Editor(editor));
202            Vec::new()
203        }
204    }
205}
206
207fn submit(m: &mut Model, mut editor: Editor) -> Vec<Effect> {
208    let name = editor.text("name").to_owned();
209    let original = match &editor.purpose {
210        Purpose::ProfileEdit { original } => Some(original.clone()),
211        _ => None,
212    };
213    let result = profile::validate_name(&name)
214        .map_err(|e| ("name".to_owned(), e))
215        .and_then(|()| {
216            if original.as_deref() != Some(name.as_str()) && m.profiles.file.get(&name).is_some() {
217                Err((
218                    "name".to_owned(),
219                    m.tx().tw("editor.error.profile-exists", &[("name", &name)]),
220                ))
221            } else {
222                Ok(())
223            }
224        })
225        .and_then(|()| profile_of(m, &editor));
226    let profile = match result {
227        Ok(profile) => profile,
228        Err((field, e)) => {
229            editor.focus_on(&field);
230            editor.error = Some(e);
231            m.modal = Some(Modal::Editor(editor));
232            return Vec::new();
233        }
234    };
235    let mut next = m.profiles.file.clone();
236    match &original {
237        None => {
238            if let Err(e) = next.add(&name, profile) {
239                editor.error = Some(e);
240                m.modal = Some(Modal::Editor(editor));
241                return Vec::new();
242            }
243        }
244        Some(old) => {
245            let Some(at) = next.profiles.iter().position(|(n, _)| n == old) else {
246                editor.error = Some(m.tx().tw("editor.error.profile-gone", &[("name", old)]));
247                m.modal = Some(Modal::Editor(editor));
248                return Vec::new();
249            };
250            next.profiles[at] = (name.clone(), profile);
251            if next.default.as_deref() == Some(old.as_str()) {
252                next.default = Some(name.clone());
253            }
254        }
255    }
256    if editor.checked("default") {
257        next.default = Some(name.clone());
258    } else if next.default.as_deref() == Some(name.as_str()) {
259        next.default = None;
260    }
261    editor.busy = true;
262    editor.error = None;
263    m.modal = Some(Modal::Editor(editor));
264    vec![Effect::SaveProfiles {
265        file: next,
266        select: Some(name),
267    }]
268}
269
270/// Removing a profile was confirmed.
271pub fn remove(m: &mut Model, name: &str) -> Vec<Effect> {
272    let mut next = m.profiles.file.clone();
273    match next.remove(name) {
274        Ok(()) => vec![Effect::SaveProfiles {
275            file: next,
276            select: None,
277        }],
278        Err(e) => {
279            m.notify(Severity::Critical, e);
280            Vec::new()
281        }
282    }
283}
284
285/// The profiles file was written (or not).
286pub fn saved(
287    m: &mut Model,
288    file: ConfigFile,
289    select: Option<String>,
290    result: Result<(), String>,
291) -> Vec<Effect> {
292    let tx = m.tx();
293    match result {
294        Ok(()) => {
295            m.profiles.file = file;
296            // The first profile of a session that was waiting for one: connect with it.
297            let connect = match (&select, m.awaiting_profile) {
298                (Some(name), true) => vec![Effect::Connect(name.clone())],
299                _ => Vec::new(),
300            };
301            if select.is_some() {
302                m.profiles.selected = select;
303            }
304            if matches!(m.modal, Some(Modal::Editor(_))) {
305                m.modal = None;
306            }
307            let text = tx.t("notice.profiles-saved");
308            m.notify(Severity::Ok, text);
309            return connect;
310        }
311        Err(e) => {
312            if let Some(Modal::Editor(editor)) = &mut m.modal {
313                editor.busy = false;
314                editor.error = Some(e);
315            } else {
316                let text = tx.tw("notice.profiles-not-saved", &[("error", &e)]);
317                m.notify(Severity::Critical, text);
318            }
319        }
320    }
321    Vec::new()
322}
323
324/// The text for the result of testing a connection, and whether it is a failure.
325pub fn check_text(m: &Model, result: &CheckResult) -> (bool, String) {
326    let tx = m.tx();
327    match result {
328        CheckResult::Reachable { url, millis } => {
329            (false, tx.tw("check.ok", &[("url", url), ("ms", millis)]))
330        }
331        CheckResult::Refused { url, status } => (
332            true,
333            tx.tw("check.refused", &[("url", url), ("status", status)]),
334        ),
335        CheckResult::Answered { url, status, code } => (
336            true,
337            tx.tw(
338                "check.answered",
339                &[("url", url), ("status", status), ("code", code)],
340            ),
341        ),
342        CheckResult::Failed(e) => (true, tx.tw("check.failed", &[("error", e)])),
343    }
344}
345
346/// The result of a connection test: in the form that asked for it, or in the footer.
347pub fn checked(m: &mut Model, result: &CheckResult) {
348    let (failed, text) = check_text(m, result);
349    if let Some(Modal::Editor(editor)) = &mut m.modal {
350        editor.note = Some((failed, text));
351        return;
352    }
353    m.notify(
354        if failed {
355            Severity::Warning
356        } else {
357            Severity::Ok
358        },
359        text,
360    );
361}
362
363#[cfg(test)]
364mod tests;