1use 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
16pub 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
25pub 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
43pub 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
158fn 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
177pub fn editor_key(m: &mut Model, mut editor: Editor, outcome: Outcome) -> Vec<Effect> {
179 match outcome {
180 Outcome::Cancel => {
181 m.modal = None;
182 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
271pub 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
286pub 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 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
325pub 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
347pub 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;