Skip to main content

cloudflare_dns/ui/
hooks.rs

1/// Application event handling.
2///
3/// This module manages all terminal event handling for the application,
4/// including keyboard input for different views and state transitions.
5use 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
18/// Number of focusable items in the create/edit form.
19const FORM_FIELD_COUNT: usize = 6;
20
21/// Application context containing all state handles.
22#[derive(Clone)]
23pub struct AppCtx {
24    pub view: State<AppView>,
25    pub should_exit: State<bool>,
26
27    // Form fields
28    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    // IP selector
38    pub ip_sel_idx: State<usize>,
39    pub ip_sel_open: State<bool>,
40
41    // List selection
42    pub list_sel_idx: State<usize>,
43    pub is_deleting: State<bool>,
44
45    // Refresh guard
46    pub is_refreshing: State<bool>,
47
48    // Global
49    pub records_display: State<String>,
50    pub status: State<String>,
51    pub state: Arc<AppState>,
52}
53
54/// Set up all application event listeners.
55pub fn use_app_events(hooks: &mut Hooks<'_, '_>, ctx: &AppCtx) {
56    // ── Status auto-clear via one-shot timers ────────────────────────────
57    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                // Transient status — wait 3s then clear if unchanged
69                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    // ── Fetch on mount ──────────────────────────────────────────────────
79    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    // ── Global keys (Q / C) ─────────────────────────────────────────────
90    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    // ── List keys (↑↓ R D E) ────────────────────────────────────────────
127    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    // ── Delete-confirm keys ─────────────────────────────────────────────
225    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    // ── IP-selector keys ────────────────────────────────────────────────
288    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    // ── Create-form keys ────────────────────────────────────────────────
350    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 IpSelect just handled Esc, don't double-process it.
376                        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                        // Client-side input validation before submit
393                        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/// Fill form fields from an existing DNS record (for editing).
468#[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    // Strip the domain suffix from the name (e.g., "pihole.robrett.com" -> "pihole")
481    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}