Skip to main content

cloudflare_dns/ui/
app.rs

1/// Main application component and entry point.
2///
3/// This module defines the root App component and the run_app function
4/// that initializes the TUI.
5use anyhow::Result;
6use iocraft::prelude::*;
7use std::sync::Arc;
8
9use crate::ui::components::create_form::CreateForm;
10use crate::ui::components::delete_confirm::DeleteConfirm;
11use crate::ui::components::ip_selector::IpSelector;
12use crate::ui::components::record_list::RecordList;
13use crate::ui::hooks::*;
14use crate::ui::state::{AppProps, AppState, AppView};
15use crate::ui::status::{StatusMessage, generate_contextual_status};
16use crate::utils::format_selector;
17
18// ─── App ────────────────────────────────────────────────────────────────────
19
20#[component]
21pub fn App(props: &AppProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
22    let (_width, _height) = hooks.use_terminal_size();
23    let records_display = hooks.use_state(|| "Loading DNS records...".to_string());
24    let status = hooks.use_state(|| "Initializing...".to_string());
25    let should_exit = hooks.use_state(|| false);
26    let mut system = hooks.use_context_mut::<SystemContext>();
27
28    // View state
29    let view = hooks.use_state(|| AppView::List);
30
31    // Form fields
32    let form_focus = hooks.use_state(|| 1);
33    let form_type = hooks.use_state(|| "A".to_string());
34    let form_name = hooks.use_state(|| "".to_string());
35    let form_content = hooks.use_state(|| "".to_string());
36    let form_ttl = hooks.use_state(|| "1".to_string());
37    let form_proxied = hooks.use_state(|| "false".to_string());
38    let is_submitting = hooks.use_state(|| false);
39    // iocraft's use_state requires FnOnce, not a direct value
40    #[allow(clippy::redundant_closure)]
41    let editing_record_id = hooks.use_state(|| String::new()); // empty = creating
42
43    // IP selector
44    let ip_sel_idx = hooks.use_state(|| 0);
45    let ip_sel_open = hooks.use_state(|| false);
46
47    // List selection
48    let list_sel_idx = hooks.use_state(|| 0);
49    let is_deleting = hooks.use_state(|| false);
50
51    // Refresh guard
52    let is_refreshing = hooks.use_state(|| false);
53
54    // ── Context Encapsulation ───────────────────────────────────────────
55    let ctx = AppCtx {
56        view,
57        should_exit,
58        form_focus,
59        form_type,
60        form_name,
61        form_content,
62        form_ttl,
63        form_proxied,
64        is_submitting,
65        editing_record_id,
66        ip_sel_idx,
67        ip_sel_open,
68        list_sel_idx,
69        is_deleting,
70        is_refreshing,
71        records_display,
72        status,
73        state: props.state.clone(),
74    };
75
76    // ── Application Event Listeners ─────────────────────────────────────
77    use_app_events(&mut hooks, &ctx);
78
79    // ── Exit ────────────────────────────────────────────────────────────
80    if should_exit.get() {
81        system.exit();
82    }
83
84    // ── Snapshot ────────────────────────────────────────────────────────
85    let records = props.state.records.lock().unwrap().clone();
86    let ips = props.state.existing_ips.lock().unwrap().clone();
87    let zone_name = props.state.zone_name.lock().unwrap().clone();
88    let domain_suffix = format!(".{}", &zone_name);
89    let sel_text = format_selector(&ips, ip_sel_idx.get());
90
91    let lsi = list_sel_idx.get();
92    let rec_name: String = if lsi < records.len() {
93        format!("{} ({})", records[lsi].name, records[lsi].record_type)
94    } else {
95        "Unknown".to_string()
96    };
97    let is_editing = matches!(view.get(), AppView::Edit);
98
99    // ── Contextual status text ──────────────────────────────────────────
100    let status_val = status.to_string();
101    let is_transient = StatusMessage::is_transient(&status_val);
102
103    let status_text = if is_transient {
104        status_val
105    } else {
106        let rec_name_opt = if lsi < records.len() {
107            Some(records[lsi].name.as_str())
108        } else {
109            None
110        };
111
112        let status_msg = generate_contextual_status(
113            &view.get(),
114            form_focus.get() as usize,
115            &form_type.to_string(),
116            &form_proxied.to_string(),
117            is_editing,
118            records.len(),
119            lsi,
120            rec_name_opt,
121        );
122        status_msg.render()
123    };
124
125    // ── Render ──────────────────────────────────────────────────────────
126    match view.get() {
127        AppView::Delete => element! {
128            DeleteConfirm(rec_name: rec_name, deleting: is_deleting.get(), status: status_text, zone_name: zone_name.clone())
129        }
130        .into_any(),
131        AppView::IpSelect => element! {
132            IpSelector(sel_text: sel_text, status: status_text, zone_name: zone_name.clone())
133        }
134        .into_any(),
135        AppView::Create | AppView::Edit => {
136            let title = if is_editing {
137                " Edit DNS Record "
138            } else {
139                " Create DNS Record "
140            };
141            let hint = "↑↓: navigate | esc: cancel";
142            element! {
143                CreateForm(
144                    form_type: form_type,
145                    form_name: form_name,
146                    form_content: form_content,
147                    form_ttl: form_ttl,
148                    form_proxied: form_proxied,
149                    form_focus: form_focus.get(),
150                    status: status_text,
151                    title: title.to_string(),
152                    hint: hint.to_string(),
153                    submit_label: if is_editing { "Save" } else { "Create" },
154                    zone_name: zone_name.clone(),
155                    domain_suffix: domain_suffix.clone(),
156                    is_editing: is_editing,
157                )
158            }
159            .into_any()
160        }
161        AppView::List => element! {
162            RecordList(
163                records: records,
164                selected_idx: lsi as i32,
165                status: status_text,
166                zone_name: zone_name,
167            )
168        }
169        .into_any(),
170    }
171}
172
173/// Run the TUI application.
174pub fn run_app(api_token: String, zone_id: String) -> Result<()> {
175    let state = Arc::new(AppState::new(api_token, zone_id));
176    smol::block_on(element!(App(state: state.clone())).fullscreen())?;
177    Ok(())
178}