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