1use iocraft::prelude::*;
6use std::sync::Arc;
7
8use crate::tasks::delete_task::DeleteParams;
9use crate::tasks::delete_task::delete_task;
10use crate::tasks::fetch_task::fetch_all;
11use crate::tasks::fetch_task::refresh_task;
12use crate::tasks::submit_task::SubmitParams;
13use crate::tasks::submit_task::submit_task;
14use crate::ui::constants::RECORD_TYPES;
15use crate::ui::state::{AppState, AppView};
16use crate::ui::status::StatusMessage;
17
18const FORM_FIELD_COUNT: usize = 6;
20
21#[derive(Clone)]
23pub struct AppCtx {
24 pub view: State<AppView>,
25 pub should_exit: State<bool>,
26
27 pub form_focus: State<i32>,
29 pub form_type: State<String>,
30 pub form_name: State<String>,
31 pub form_content: State<String>,
32 pub form_ttl: State<String>,
33 pub form_proxied: State<String>,
34 pub is_submitting: State<bool>,
35 pub editing_record_id: State<String>,
36
37 pub ip_sel_idx: State<usize>,
39 pub ip_sel_open: State<bool>,
40
41 pub list_sel_idx: State<usize>,
43 pub is_deleting: State<bool>,
44
45 pub is_refreshing: State<bool>,
47
48 pub records_display: State<String>,
50 pub status: State<String>,
51 pub state: Arc<AppState>,
52}
53
54pub fn use_app_events(hooks: &mut Hooks<'_, '_>, ctx: &AppCtx) {
56 hooks.use_future({
58 let mut st = ctx.status;
59 async move {
60 loop {
61 #[allow(clippy::cmp_owned)]
62 let val = st.to_string();
63 if val.is_empty() || !StatusMessage::is_transient(&val) {
64 smol::Timer::after(std::time::Duration::from_secs(5)).await;
65 continue;
66 }
67
68 smol::Timer::after(std::time::Duration::from_secs(3)).await;
70 #[allow(clippy::cmp_owned)]
71 if st.to_string() == val {
72 st.set("".to_string());
73 }
74 }
75 }
76 });
77
78 hooks.use_future({
80 let state = ctx.state.clone();
81 let client = state.client.clone();
82 let mut rd = ctx.records_display;
83 let mut st = ctx.status;
84 async move {
85 fetch_all(&client, &state, &mut rd, &mut st).await;
86 }
87 });
88
89 hooks.use_terminal_events({
91 let mut should_exit = ctx.should_exit;
92 let mut view = ctx.view;
93 let mut ff = ctx.form_focus;
94 let mut ft = ctx.form_type;
95 let mut form_name = ctx.form_name;
96 let mut form_content = ctx.form_content;
97 let mut ftl = ctx.form_ttl;
98 let mut fp = ctx.form_proxied;
99 let mut eid = ctx.editing_record_id;
100 move |event| {
101 if let TerminalEvent::Key(KeyEvent { code, kind, .. }) = event {
102 if kind == KeyEventKind::Release {
103 return;
104 }
105 if (code == KeyCode::Char('q') || code == KeyCode::Char('Q'))
106 && view.get() == AppView::List
107 {
108 should_exit.set(true);
109 }
110 if (code == KeyCode::Char('c') || code == KeyCode::Char('C'))
111 && view.get() == AppView::List
112 {
113 view.set(AppView::Create);
114 eid.set("".to_string());
115 ff.set(0);
116 ft.set("A".to_string());
117 form_name.set("".to_string());
118 form_content.set("".to_string());
119 ftl.set("1".to_string());
120 fp.set("false".to_string());
121 }
122 }
123 }
124 });
125
126 hooks.use_terminal_events({
128 let state = ctx.state.clone();
129 let mut lsi = ctx.list_sel_idx;
130 let mut view = ctx.view;
131 let mut eid = ctx.editing_record_id;
132 let mut ft = ctx.form_type;
133 let mut form_name = ctx.form_name;
134 let mut form_content = ctx.form_content;
135 let mut ftl = ctx.form_ttl;
136 let mut fp = ctx.form_proxied;
137 let mut ff = ctx.form_focus;
138 let rd = ctx.records_display;
139 let mut st = ctx.status;
140 let mut is_refreshing = ctx.is_refreshing;
141 move |event| {
142 if view.get() != AppView::List {
143 return;
144 }
145 if let TerminalEvent::Key(KeyEvent { code, kind, .. }) = event {
146 if kind == KeyEventKind::Release {
147 return;
148 }
149 match code {
150 KeyCode::Up => {
151 let recs = state.records.lock().unwrap();
152 let len = recs.len();
153 if len > 0 {
154 let idx = lsi.get();
155 drop(recs);
156 lsi.set(if idx > 0 { idx - 1 } else { len - 1 });
157 }
158 }
159 KeyCode::Down => {
160 let recs = state.records.lock().unwrap();
161 let len = recs.len();
162 if len > 0 {
163 let idx = lsi.get();
164 drop(recs);
165 lsi.set(if idx < len - 1 { idx + 1 } else { 0 });
166 }
167 }
168 KeyCode::Char('r') | KeyCode::Char('R') => {
169 if is_refreshing.get() {
170 return;
171 }
172 is_refreshing.set(true);
173 let (state, rd, mut st, mut is_refreshing) =
174 (state.clone(), rd, st, is_refreshing);
175 let client = state.client.clone();
176 smol::spawn(async move {
177 st.set("Refreshing...".to_string());
178 refresh_task(&client, &state, rd, st).await;
179 is_refreshing.set(false);
180 })
181 .detach();
182 }
183 KeyCode::Char('d') | KeyCode::Char('D') => {
184 let recs = state.records.lock().unwrap();
185 let idx = lsi.get();
186 if idx < recs.len() {
187 view.set(AppView::Delete);
188 st.set("Enter: confirm | Esc: cancel".to_string());
189 }
190 }
191 KeyCode::Char('e') | KeyCode::Char('E') => {
192 let recs = state.records.lock().unwrap();
193 let idx = lsi.get();
194 if idx < recs.len() {
195 let rec = &recs[idx];
196 let edit_id = rec.id.clone().unwrap_or_default();
197 eid.set(edit_id.clone());
198 ff.set(0);
199 let domain_suffix =
200 format!(".{}", state.zone_name.lock().unwrap().clone());
201 fill_form_from_record(
202 rec,
203 &mut ft,
204 &mut form_name,
205 &mut form_content,
206 &mut ftl,
207 &mut fp,
208 &mut eid,
209 &domain_suffix,
210 );
211 view.set(AppView::Edit);
212 st.set(format!(
213 "Editing {} ({}) — Esc to cancel",
214 rec.name, rec.record_type
215 ));
216 }
217 }
218 _ => {}
219 }
220 }
221 }
222 });
223
224 hooks.use_terminal_events({
226 let state = ctx.state.clone();
227 let mut view = ctx.view;
228 let lsi = ctx.list_sel_idx;
229 let mut st = ctx.status;
230 let rd = ctx.records_display;
231 let is_del = ctx.is_deleting;
232 move |event| {
233 if view.get() != AppView::Delete {
234 return;
235 }
236 if let TerminalEvent::Key(KeyEvent { code, kind, .. }) = event {
237 if kind == KeyEventKind::Release {
238 return;
239 }
240 match code {
241 KeyCode::Esc => {
242 view.set(AppView::List);
243 st.set("Cancelled".to_string());
244 }
245 KeyCode::Enter if !is_del.get() => {
246 let recs = state.records.lock().unwrap();
247 let idx = lsi.get();
248 if idx >= recs.len() {
249 st.set("Record no longer exists".to_string());
250 view.set(AppView::List);
251 return;
252 }
253 let rec = &recs[idx];
254 let record_id = rec.id.clone();
255 let record_name = rec.name.clone();
256 let record_type = rec.record_type.clone();
257 drop(recs);
258
259 if record_id.is_none() {
260 st.set("No record ID".to_string());
261 view.set(AppView::List);
262 return;
263 }
264
265 let (state, view, mut is_del, st, rd) =
266 (state.clone(), view, is_del, st, rd);
267 is_del.set(true);
268 let params = DeleteParams {
269 client: state.client.clone(),
270 state: state.clone(),
271 record_id: record_id.unwrap(),
272 record_name,
273 record_type,
274 view,
275 is_deleting: is_del,
276 status: st,
277 records_display: rd,
278 };
279 smol::spawn(delete_task(params)).detach();
280 }
281 _ => {}
282 }
283 }
284 }
285 });
286
287 hooks.use_terminal_events({
289 let state = ctx.state.clone();
290 let mut view = ctx.view;
291 let mut isi = ctx.ip_sel_idx;
292 let mut ff = ctx.form_focus;
293 let mut fc = ctx.form_content;
294 let mut st = ctx.status;
295 let eid = ctx.editing_record_id;
296 move |event| {
297 if view.get() != AppView::IpSelect {
298 return;
299 }
300 if let TerminalEvent::Key(KeyEvent { code, kind, .. }) = event {
301 if kind == KeyEventKind::Release {
302 return;
303 }
304 match code {
305 KeyCode::Esc => {
306 let eid_str = eid.to_string();
307 view.set(if eid_str.is_empty() {
308 AppView::Create
309 } else {
310 AppView::Edit
311 });
312 }
313 KeyCode::Up => {
314 let ips = state.existing_ips.lock().unwrap().clone();
315 let len = ips.len() + 1;
316 let idx = isi.get();
317 isi.set(if idx > 0 { idx - 1 } else { len - 1 });
318 }
319 KeyCode::Down => {
320 let ips = state.existing_ips.lock().unwrap().clone();
321 let len = ips.len() + 1;
322 let idx = isi.get();
323 isi.set(if idx < len - 1 { idx + 1 } else { 0 });
324 }
325 KeyCode::Enter => {
326 let ips = state.existing_ips.lock().unwrap().clone();
327 let idx = isi.get();
328 if idx < ips.len() {
329 fc.set(ips[idx].clone());
330 st.set(format!("Selected: {}", ips[idx]));
331 } else {
332 fc.set("".to_string());
333 st.set("Type a new IP".to_string());
334 }
335 let eid_str = eid.to_string();
336 view.set(if eid_str.is_empty() {
337 AppView::Create
338 } else {
339 AppView::Edit
340 });
341 ff.set(2);
342 }
343 _ => {}
344 }
345 }
346 }
347 });
348
349 hooks.use_terminal_events({
351 let state = ctx.state.clone();
352 let mut view = ctx.view;
353 let mut ff = ctx.form_focus;
354 let mut ft = ctx.form_type;
355 let form_name = ctx.form_name;
356 let fc = ctx.form_content;
357 let ftl = ctx.form_ttl;
358 let mut fp = ctx.form_proxied;
359 let mut is = ctx.is_submitting;
360 let mut isi = ctx.ip_sel_idx;
361 let mut ip_sel_open = ctx.ip_sel_open;
362 let eid = ctx.editing_record_id;
363 let mut st = ctx.status;
364 let rd = ctx.records_display;
365 move |event| {
366 if !matches!(view.get(), AppView::Create | AppView::Edit) {
367 return;
368 }
369 if let TerminalEvent::Key(KeyEvent { code, kind, .. }) = event {
370 if kind == KeyEventKind::Release {
371 return;
372 }
373 match code {
374 KeyCode::Esc => {
375 if ip_sel_open.get() {
377 ip_sel_open.set(false);
378 return;
379 }
380 view.set(AppView::List);
381 st.set("Cancelled".to_string());
382 }
383 KeyCode::Up => {
384 ff.set((ff.get() + FORM_FIELD_COUNT as i32 - 1) % FORM_FIELD_COUNT as i32)
385 }
386 KeyCode::Down => ff.set((ff.get() + 1) % FORM_FIELD_COUNT as i32),
387 KeyCode::Tab => ff.set((ff.get() + 1) % FORM_FIELD_COUNT as i32),
388 KeyCode::BackTab => {
389 ff.set((ff.get() + FORM_FIELD_COUNT as i32 - 1) % FORM_FIELD_COUNT as i32)
390 }
391 KeyCode::Enter if ff.get() == 5 && !is.get() => {
392 let nm = form_name.to_string();
394 let ct = fc.to_string();
395 let ttl_str = ftl.to_string();
396 if nm.is_empty() {
397 st.set("Name cannot be empty".to_string());
398 return;
399 }
400 if ct.is_empty() {
401 st.set("Content cannot be empty".to_string());
402 return;
403 }
404 let ttl: i64 = match ttl_str.parse() {
405 Ok(v) => v,
406 Err(_) => {
407 st.set(format!("Invalid TTL '{}': must be a number", ttl_str));
408 return;
409 }
410 };
411 let px = fp.to_string().to_lowercase() == "true";
412 is.set(true);
413 let eid_str = eid.to_string();
414 let (state, rt, rd, st, view, form_name, fc, is) = (
415 state.clone(),
416 ft.to_string(),
417 rd,
418 st,
419 view,
420 form_name,
421 fc,
422 is,
423 );
424 let params = SubmitParams {
425 client: state.client.clone(),
426 state: state.clone(),
427 record_id: eid_str,
428 record_type: rt,
429 name: nm,
430 content: ct,
431 ttl,
432 proxied: px,
433 records_display: rd,
434 status: st,
435 view,
436 form_name,
437 form_content: fc,
438 is_submitting: is,
439 };
440 smol::spawn(submit_task(params)).detach();
441 }
442 KeyCode::Char(' ') if ff.get() == 0 => {
443 let c = ft.to_string();
444 let i = RECORD_TYPES.iter().position(|&t| t == c).unwrap_or(0);
445 ft.set(RECORD_TYPES[(i + 1) % RECORD_TYPES.len()].to_string());
446 }
447 KeyCode::Char(' ') if ff.get() == 4 => {
448 let c = fp.to_string().to_lowercase();
449 fp.set(if c == "true" {
450 "false".to_string()
451 } else {
452 "true".to_string()
453 });
454 }
455 KeyCode::Char(' ') if ff.get() == 2 => {
456 view.set(AppView::IpSelect);
457 ip_sel_open.set(true);
458 isi.set(0);
459 }
460 _ => {}
461 }
462 }
463 }
464 });
465}
466
467#[allow(clippy::too_many_arguments)]
469pub fn fill_form_from_record(
470 rec: &crate::api::DnsRecord,
471 form_type: &mut State<String>,
472 form_name: &mut State<String>,
473 form_content: &mut State<String>,
474 form_ttl: &mut State<String>,
475 form_proxied: &mut State<String>,
476 editing_id: &mut State<String>,
477 domain_suffix: &str,
478) {
479 form_type.set(rec.record_type.clone());
480 let short_name = crate::utils::strip_domain_suffix(&rec.name, domain_suffix);
482 form_name.set(short_name);
483 form_content.set(rec.content.clone());
484 form_ttl.set(rec.ttl.unwrap_or(1).to_string());
485 form_proxied.set(
486 if rec.proxied.unwrap_or(false) {
487 "true"
488 } else {
489 "false"
490 }
491 .to_string(),
492 );
493 editing_id.set(rec.id.clone().unwrap_or_default());
494}