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); 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
24pub 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
42pub 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
157fn 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
176pub fn editor_key(m: &mut Model, mut editor: Editor, outcome: Outcome) -> Vec<Effect> {
178 match outcome {
179 Outcome::Cancel => {
180 m.modal = None;
181 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
270pub 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
285pub 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 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
324pub 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
346pub 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;