Skip to main content

pitchfork_cli/tui/
app.rs

1use crate::Result;
2use crate::daemon::Daemon;
3use crate::daemon_id::DaemonId;
4use crate::daemon_list::DaemonListEntry;
5use crate::ipc::client::IpcClient;
6use crate::log_store::LogStore;
7use crate::log_store::sqlite::LOG_STORE;
8use crate::pitchfork_toml::{
9    CronRetrigger, PitchforkToml, PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlDaemon,
10    ReadyHttp, Retry, namespace_from_path,
11};
12use crate::procs::{PROCS, ProcessStats};
13use crate::settings::settings;
14use fuzzy_matcher::FuzzyMatcher;
15use fuzzy_matcher::skim::SkimMatcherV2;
16use listeners::Listener;
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::time::Instant;
21
22/// Convert character index to byte index for UTF-8 strings
23fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
24    s.char_indices()
25        .nth(char_idx)
26        .map(|(i, _)| i)
27        .unwrap_or(s.len())
28}
29
30/// A snapshot of stats at a point in time
31#[derive(Debug, Clone, Copy)]
32pub struct StatsSnapshot {
33    pub cpu_percent: f32,
34    pub memory_bytes: u64,
35    pub disk_read_bytes: u64,
36    pub disk_write_bytes: u64,
37}
38
39impl From<&ProcessStats> for StatsSnapshot {
40    fn from(stats: &ProcessStats) -> Self {
41        Self {
42            cpu_percent: stats.cpu_percent,
43            memory_bytes: stats.memory_bytes,
44            disk_read_bytes: stats.disk_read_bytes,
45            disk_write_bytes: stats.disk_write_bytes,
46        }
47    }
48}
49
50/// Historical stats for a daemon
51#[derive(Debug, Clone, Default)]
52pub struct StatsHistory {
53    pub samples: VecDeque<StatsSnapshot>,
54}
55
56impl StatsHistory {
57    pub fn push(&mut self, snapshot: StatsSnapshot) {
58        self.samples.push_back(snapshot);
59        let max_history = settings().tui.stat_history.max(1) as usize;
60        while self.samples.len() > max_history {
61            self.samples.pop_front();
62        }
63    }
64
65    pub fn cpu_values(&self) -> Vec<f32> {
66        self.samples.iter().map(|s| s.cpu_percent).collect()
67    }
68
69    pub fn memory_values(&self) -> Vec<u64> {
70        self.samples.iter().map(|s| s.memory_bytes).collect()
71    }
72
73    pub fn disk_read_values(&self) -> Vec<u64> {
74        self.samples.iter().map(|s| s.disk_read_bytes).collect()
75    }
76
77    pub fn disk_write_values(&self) -> Vec<u64> {
78        self.samples.iter().map(|s| s.disk_write_bytes).collect()
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum View {
84    Dashboard,
85    Logs,
86    Network,
87    Help,
88    Confirm,
89    Details,
90    ConfigEditor,
91    ConfigFileSelect,
92}
93
94/// Edit mode for the config editor
95#[derive(Debug, Clone, PartialEq)]
96pub enum EditMode {
97    Create,
98    Edit { original_id: String },
99}
100
101/// Form field value types
102#[derive(Debug, Clone)]
103pub enum FormFieldValue {
104    Text(String),
105    OptionalText(Option<String>),
106    Number(u32),
107    OptionalNumber(Option<u64>),
108    OptionalPort(Option<u16>),
109    #[allow(dead_code)]
110    Boolean(bool),
111    OptionalBoolean(Option<bool>),
112    AutoBehavior(Vec<PitchforkTomlAuto>),
113    Retrigger(CronRetrigger),
114    StringList(Vec<String>),
115}
116
117/// A form field with metadata
118#[derive(Debug, Clone)]
119pub struct FormField {
120    pub name: &'static str,
121    pub label: &'static str,
122    pub value: FormFieldValue,
123    pub required: bool,
124    #[allow(dead_code)]
125    pub help_text: &'static str,
126    pub error: Option<String>,
127    pub editing: bool,
128    pub cursor: usize,
129}
130
131impl FormField {
132    fn text(name: &'static str, label: &'static str, help: &'static str, required: bool) -> Self {
133        Self {
134            name,
135            label,
136            value: FormFieldValue::Text(String::new()),
137            required,
138            help_text: help,
139            error: None,
140            editing: false,
141            cursor: 0,
142        }
143    }
144
145    fn optional_text(name: &'static str, label: &'static str, help: &'static str) -> Self {
146        Self {
147            name,
148            label,
149            value: FormFieldValue::OptionalText(None),
150            required: false,
151            help_text: help,
152            error: None,
153            editing: false,
154            cursor: 0,
155        }
156    }
157
158    fn number(name: &'static str, label: &'static str, help: &'static str, default: u32) -> Self {
159        Self {
160            name,
161            label,
162            value: FormFieldValue::Number(default),
163            required: false,
164            help_text: help,
165            error: None,
166            editing: false,
167            cursor: 0,
168        }
169    }
170
171    fn optional_number(name: &'static str, label: &'static str, help: &'static str) -> Self {
172        Self {
173            name,
174            label,
175            value: FormFieldValue::OptionalNumber(None),
176            required: false,
177            help_text: help,
178            error: None,
179            editing: false,
180            cursor: 0,
181        }
182    }
183
184    fn optional_port(name: &'static str, label: &'static str, help: &'static str) -> Self {
185        Self {
186            name,
187            label,
188            value: FormFieldValue::OptionalPort(None),
189            required: false,
190            help_text: help,
191            error: None,
192            editing: false,
193            cursor: 0,
194        }
195    }
196
197    fn optional_bool(name: &'static str, label: &'static str, help: &'static str) -> Self {
198        Self {
199            name,
200            label,
201            value: FormFieldValue::OptionalBoolean(None),
202            required: false,
203            help_text: help,
204            error: None,
205            editing: false,
206            cursor: 0,
207        }
208    }
209
210    fn auto_behavior(name: &'static str, label: &'static str, help: &'static str) -> Self {
211        Self {
212            name,
213            label,
214            value: FormFieldValue::AutoBehavior(vec![]),
215            required: false,
216            help_text: help,
217            error: None,
218            editing: false,
219            cursor: 0,
220        }
221    }
222
223    fn retrigger(name: &'static str, label: &'static str, help: &'static str) -> Self {
224        Self {
225            name,
226            label,
227            value: FormFieldValue::Retrigger(CronRetrigger::Finish),
228            required: false,
229            help_text: help,
230            error: None,
231            editing: false,
232            cursor: 0,
233        }
234    }
235
236    fn string_list(name: &'static str, label: &'static str, help: &'static str) -> Self {
237        Self {
238            name,
239            label,
240            value: FormFieldValue::StringList(vec![]),
241            required: false,
242            help_text: help,
243            error: None,
244            editing: false,
245            cursor: 0,
246        }
247    }
248
249    pub fn get_text(&self) -> String {
250        match &self.value {
251            FormFieldValue::Text(s) => s.clone(),
252            FormFieldValue::OptionalText(Some(s)) => s.clone(),
253            FormFieldValue::OptionalText(None) => String::new(),
254            FormFieldValue::Number(n) => n.to_string(),
255            FormFieldValue::OptionalNumber(Some(n)) => n.to_string(),
256            FormFieldValue::OptionalNumber(None) => String::new(),
257            FormFieldValue::OptionalPort(Some(p)) => p.to_string(),
258            FormFieldValue::OptionalPort(None) => String::new(),
259            FormFieldValue::StringList(v) => v.join(", "),
260            _ => String::new(),
261        }
262    }
263
264    pub fn set_text(&mut self, text: String) {
265        match &mut self.value {
266            FormFieldValue::Text(s) => *s = text,
267            FormFieldValue::OptionalText(opt) => {
268                *opt = if text.is_empty() { None } else { Some(text) };
269            }
270            FormFieldValue::Number(n) => {
271                let trimmed = text.trim();
272                if trimmed.is_empty() {
273                    *n = 0;
274                    self.error = None;
275                } else {
276                    match trimmed.parse() {
277                        Ok(value) => {
278                            *n = value;
279                            self.error = None;
280                        }
281                        Err(_) => {
282                            *n = 0;
283                            self.error = Some("Invalid number".to_string());
284                        }
285                    }
286                }
287            }
288            FormFieldValue::OptionalNumber(opt) => {
289                *opt = text.parse().ok();
290            }
291            FormFieldValue::OptionalPort(opt) => {
292                *opt = text.parse().ok();
293            }
294            FormFieldValue::StringList(v) => {
295                *v = text
296                    .split(',')
297                    .map(|s| s.trim().to_string())
298                    .filter(|s| !s.is_empty())
299                    .collect();
300            }
301            _ => {}
302        }
303    }
304
305    pub fn is_text_editable(&self) -> bool {
306        matches!(
307            self.value,
308            FormFieldValue::Text(_)
309                | FormFieldValue::OptionalText(_)
310                | FormFieldValue::Number(_)
311                | FormFieldValue::OptionalNumber(_)
312                | FormFieldValue::OptionalPort(_)
313                | FormFieldValue::StringList(_)
314        )
315    }
316}
317
318/// State for the daemon config editor
319#[derive(Debug, Clone)]
320pub struct EditorState {
321    pub mode: EditMode,
322    pub daemon_id: String,
323    pub daemon_id_editing: bool,
324    pub daemon_id_cursor: usize,
325    pub daemon_id_error: Option<String>,
326    pub fields: Vec<FormField>,
327    pub focused_field: usize,
328    pub config_path: PathBuf,
329    pub unsaved_changes: bool,
330    #[allow(dead_code)]
331    pub scroll_offset: usize,
332    /// Preserved config field for ready_cmd (no form UI yet)
333    preserved_ready_cmd: Option<String>,
334    /// Preserved ready_http statuses (no form UI yet)
335    preserved_ready_http_status: Option<Vec<u16>>,
336}
337
338impl EditorState {
339    pub fn new_create(config_path: PathBuf) -> Self {
340        Self {
341            mode: EditMode::Create,
342            daemon_id: String::new(),
343            daemon_id_editing: true,
344            daemon_id_cursor: 0,
345            daemon_id_error: None,
346            fields: Self::default_fields(),
347            focused_field: 0,
348            config_path,
349            unsaved_changes: false,
350            scroll_offset: 0,
351            preserved_ready_cmd: None,
352            preserved_ready_http_status: None,
353        }
354    }
355
356    pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
357        Self {
358            mode: EditMode::Edit {
359                original_id: daemon_id.clone(),
360            },
361            daemon_id,
362            daemon_id_editing: false,
363            daemon_id_cursor: 0,
364            daemon_id_error: None,
365            fields: Self::fields_from_config(config),
366            focused_field: 0,
367            config_path,
368            unsaved_changes: false,
369            scroll_offset: 0,
370            preserved_ready_cmd: config.ready_cmd.clone(),
371            preserved_ready_http_status: config
372                .ready_http
373                .as_ref()
374                .and_then(|h| (!h.status.is_empty()).then(|| h.status.clone())),
375        }
376    }
377
378    fn default_fields() -> Vec<FormField> {
379        vec![
380            FormField::text(
381                "run",
382                "Run Command",
383                "Command to execute. Prepend 'exec' to avoid shell overhead.",
384                true,
385            ),
386            FormField::optional_text(
387                "dir",
388                "Working Directory",
389                "Working directory for the daemon. Relative to pitchfork.toml location.",
390            ),
391            FormField::string_list(
392                "env",
393                "Environment Variables",
394                "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
395            ),
396            FormField::auto_behavior(
397                "auto",
398                "Auto Behavior",
399                "Auto start/stop based on directory hooks.",
400            ),
401            FormField::number(
402                "retry",
403                "Retry Count",
404                "Number of retry attempts on failure (0 = no retries).",
405                0,
406            ),
407            FormField::optional_number(
408                "ready_delay",
409                "Ready Delay (ms)",
410                "Milliseconds to wait before considering daemon ready.",
411            ),
412            FormField::optional_text(
413                "ready_output",
414                "Ready Output Pattern",
415                "Regex pattern in ANSI-stripped stdout/stderr indicating readiness.",
416            ),
417            FormField::optional_text(
418                "ready_http",
419                "Ready HTTP URL",
420                "HTTP URL to poll for readiness (expects 2xx).",
421            ),
422            FormField::optional_port(
423                "ready_port",
424                "Ready Port",
425                "TCP port to check for readiness (1-65535).",
426            ),
427            FormField::optional_bool(
428                "boot_start",
429                "Start on Boot",
430                "Automatically start this daemon on system boot.",
431            ),
432            FormField::string_list(
433                "depends",
434                "Dependencies",
435                "Comma-separated daemon names that must start first.",
436            ),
437            FormField::string_list(
438                "watch",
439                "Watch Files",
440                "Comma-separated glob patterns to watch for auto-restart.",
441            ),
442            FormField::optional_text(
443                "cron_schedule",
444                "Cron Schedule",
445                "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
446            ),
447            FormField::retrigger(
448                "cron_retrigger",
449                "Cron Retrigger",
450                "Behavior when cron triggers while previous run is active.",
451            ),
452            FormField::optional_bool(
453                "cron_immediate",
454                "Cron Immediate",
455                "Trigger immediately on first check (default: false).",
456            ),
457        ]
458    }
459
460    fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
461        let mut fields = Self::default_fields();
462
463        for field in &mut fields {
464            match field.name {
465                "run" => field.value = FormFieldValue::Text(config.run.clone()),
466                "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
467                "env" => {
468                    field.value = FormFieldValue::StringList(
469                        config
470                            .env
471                            .as_ref()
472                            .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
473                            .unwrap_or_default(),
474                    );
475                }
476                "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
477                "retry" => field.value = FormFieldValue::Number(config.retry.count()),
478                "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
479                "ready_output" => {
480                    field.value = FormFieldValue::OptionalText(config.ready_output.clone())
481                }
482                "ready_http" => {
483                    field.value = FormFieldValue::OptionalText(
484                        config.ready_http.as_ref().map(|h| h.url.clone()),
485                    )
486                }
487                "ready_port" => field.value = FormFieldValue::OptionalPort(config.ready_port),
488                "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
489                "depends" => {
490                    field.value = FormFieldValue::StringList(
491                        config
492                            .depends
493                            .iter()
494                            .map(|d: &DaemonId| d.qualified())
495                            .collect(),
496                    )
497                }
498                "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
499                "cron_schedule" => {
500                    field.value = FormFieldValue::OptionalText(
501                        config.cron.as_ref().map(|c| c.schedule.clone()),
502                    );
503                }
504                "cron_retrigger" => {
505                    field.value = FormFieldValue::Retrigger(
506                        config
507                            .cron
508                            .as_ref()
509                            .map(|c| c.retrigger)
510                            .unwrap_or(CronRetrigger::Finish),
511                    );
512                }
513                "cron_immediate" => {
514                    field.value =
515                        FormFieldValue::OptionalBoolean(config.cron.as_ref().map(|c| c.immediate));
516                }
517                _ => {}
518            }
519        }
520
521        fields
522    }
523
524    pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
525        let mut config = PitchforkTomlDaemon {
526            ready_cmd: self.preserved_ready_cmd.clone(),
527            path: Some(self.config_path.clone()),
528            ..PitchforkTomlDaemon::default()
529        };
530
531        let mut cron_schedule: Option<String> = None;
532        let mut cron_retrigger = CronRetrigger::Finish;
533        let mut cron_immediate = false;
534
535        for field in &self.fields {
536            match (field.name, &field.value) {
537                ("run", FormFieldValue::Text(s)) => config.run = s.clone(),
538                ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
539                ("env", FormFieldValue::StringList(v)) => {
540                    if v.is_empty() {
541                        config.env = None;
542                    } else {
543                        let mut map = indexmap::IndexMap::new();
544                        for entry in v {
545                            if let Some((k, val)) = entry.split_once('=') {
546                                map.insert(k.trim().to_string(), val.trim().to_string());
547                            }
548                        }
549                        config.env = if map.is_empty() { None } else { Some(map) };
550                    }
551                }
552                ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
553                ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
554                ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
555                ("ready_output", FormFieldValue::OptionalText(s)) => {
556                    config.ready_output = s.clone()
557                }
558                ("ready_http", FormFieldValue::OptionalText(s)) => {
559                    config.ready_http = s.clone().map(|url| ReadyHttp {
560                        url,
561                        status: self.preserved_ready_http_status.clone().unwrap_or_default(),
562                    })
563                }
564                ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = *p,
565                ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
566                ("depends", FormFieldValue::StringList(v)) => {
567                    config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
568                }
569                ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
570                ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
571                ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
572                ("cron_immediate", FormFieldValue::OptionalBoolean(b)) => {
573                    cron_immediate = b.unwrap_or(false);
574                }
575                _ => {}
576            }
577        }
578
579        if let Some(schedule) = cron_schedule {
580            config.cron = Some(PitchforkTomlCron {
581                schedule,
582                retrigger: cron_retrigger,
583                immediate: cron_immediate,
584            });
585        }
586
587        config
588    }
589
590    pub fn next_field(&mut self) {
591        // Stop editing current field if text editing
592        if let Some(field) = self.fields.get_mut(self.focused_field) {
593            field.editing = false;
594        }
595
596        // When leaving daemon_id editing, don't increment - just move to first form field
597        if self.daemon_id_editing {
598            self.daemon_id_editing = false;
599            return;
600        }
601
602        if self.focused_field < self.fields.len() - 1 {
603            self.focused_field += 1;
604        }
605    }
606
607    pub fn prev_field(&mut self) {
608        // Stop editing current field if text editing
609        if let Some(field) = self.fields.get_mut(self.focused_field) {
610            field.editing = false;
611        }
612        self.daemon_id_editing = false;
613
614        if self.focused_field > 0 {
615            self.focused_field -= 1;
616        }
617    }
618
619    pub fn toggle_current_field(&mut self) {
620        if let Some(field) = self.fields.get_mut(self.focused_field) {
621            let toggled = match &mut field.value {
622                FormFieldValue::Boolean(b) => {
623                    *b = !*b;
624                    true
625                }
626                FormFieldValue::OptionalBoolean(opt) => {
627                    *opt = match opt {
628                        None => Some(true),
629                        Some(true) => Some(false),
630                        Some(false) => None,
631                    };
632                    true
633                }
634                FormFieldValue::AutoBehavior(v) => {
635                    // Cycle through: [] -> [Start] -> [Stop] -> [Start, Stop] -> []
636                    let has_start = v.contains(&PitchforkTomlAuto::Start);
637                    let has_stop = v.contains(&PitchforkTomlAuto::Stop);
638                    *v = match (has_start, has_stop) {
639                        (false, false) => vec![PitchforkTomlAuto::Start],
640                        (true, false) => vec![PitchforkTomlAuto::Stop],
641                        (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
642                        (true, true) => vec![],
643                    };
644                    true
645                }
646                FormFieldValue::Retrigger(r) => {
647                    *r = match r {
648                        CronRetrigger::Finish => CronRetrigger::Always,
649                        CronRetrigger::Always => CronRetrigger::Success,
650                        CronRetrigger::Success => CronRetrigger::Fail,
651                        CronRetrigger::Fail => CronRetrigger::Finish,
652                    };
653                    true
654                }
655                _ => false,
656            };
657            if toggled {
658                self.unsaved_changes = true;
659            }
660        }
661    }
662
663    pub fn start_editing(&mut self) {
664        if let Some(field) = self.fields.get_mut(self.focused_field) {
665            if field.is_text_editable() {
666                field.editing = true;
667                field.cursor = field.get_text().chars().count();
668            } else {
669                // For non-text fields, toggle them
670                self.toggle_current_field();
671            }
672        }
673    }
674
675    pub fn stop_editing(&mut self) {
676        if let Some(field) = self.fields.get_mut(self.focused_field) {
677            field.editing = false;
678        }
679        self.daemon_id_editing = false;
680    }
681
682    pub fn is_editing(&self) -> bool {
683        self.daemon_id_editing
684            || self
685                .fields
686                .get(self.focused_field)
687                .map(|f| f.editing)
688                .unwrap_or(false)
689    }
690
691    pub fn text_push(&mut self, c: char) {
692        if self.daemon_id_editing {
693            let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
694            self.daemon_id.insert(byte_idx, c);
695            self.daemon_id_cursor += 1;
696            self.unsaved_changes = true;
697        } else if let Some(field) = self.fields.get_mut(self.focused_field)
698            && field.editing
699        {
700            let mut text = field.get_text();
701            let byte_idx = char_to_byte_index(&text, field.cursor);
702            text.insert(byte_idx, c);
703            field.cursor += 1;
704            field.set_text(text);
705            self.unsaved_changes = true;
706        }
707    }
708
709    pub fn text_pop(&mut self) {
710        if self.daemon_id_editing && self.daemon_id_cursor > 0 {
711            self.daemon_id_cursor -= 1;
712            let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
713            self.daemon_id.remove(byte_idx);
714            self.unsaved_changes = true;
715        } else if let Some(field) = self.fields.get_mut(self.focused_field)
716            && field.editing
717            && field.cursor > 0
718        {
719            let mut text = field.get_text();
720            field.cursor -= 1;
721            let byte_idx = char_to_byte_index(&text, field.cursor);
722            text.remove(byte_idx);
723            field.set_text(text);
724            // For Number fields, sync cursor to end if value defaulted to "0"
725            // This handles the case where backspacing to empty makes value 0
726            if matches!(field.value, FormFieldValue::Number(_)) {
727                field.cursor = field.get_text().chars().count();
728            }
729            self.unsaved_changes = true;
730        }
731    }
732
733    pub fn validate(&mut self) -> bool {
734        let mut valid = true;
735
736        // Validate daemon ID
737        self.daemon_id_error = None;
738        if self.daemon_id.is_empty() {
739            self.daemon_id_error = Some("Name is required".to_string());
740            valid = false;
741        } else if !self
742            .daemon_id
743            .chars()
744            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
745        {
746            self.daemon_id_error =
747                Some("Only letters, digits, hyphens, and underscores allowed".to_string());
748            valid = false;
749        }
750
751        // Validate fields
752        for field in &mut self.fields {
753            field.error = None;
754
755            match (field.name, &field.value) {
756                ("run", FormFieldValue::Text(s)) if s.is_empty() => {
757                    field.error = Some("Required".to_string());
758                    valid = false;
759                }
760                ("ready_port", FormFieldValue::OptionalPort(Some(p))) if *p == 0 => {
761                    field.error = Some("Port must be 1-65535".to_string());
762                    valid = false;
763                }
764                ("ready_http", FormFieldValue::OptionalText(Some(url)))
765                    if !(url.starts_with("http://") || url.starts_with("https://")) =>
766                {
767                    field.error = Some("Must start with http:// or https://".to_string());
768                    valid = false;
769                }
770                _ => {}
771            }
772        }
773
774        valid
775    }
776}
777
778/// State for config file selection
779#[derive(Debug, Clone)]
780pub struct ConfigFileSelector {
781    pub files: Vec<PathBuf>,
782    pub selected: usize,
783}
784
785#[derive(Debug, Clone)]
786pub enum PendingAction {
787    Stop(DaemonId),
788    Restart(DaemonId),
789    Disable(DaemonId),
790    // Batch operations
791    BatchStop(Vec<DaemonId>),
792    BatchRestart(Vec<DaemonId>),
793    BatchDisable(Vec<DaemonId>),
794    // Config editor actions
795    DeleteDaemon { id: String, config_path: PathBuf },
796    DiscardEditorChanges,
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
800pub enum SortColumn {
801    #[default]
802    Name,
803    Status,
804    Cpu,
805    Memory,
806    Uptime,
807}
808
809impl SortColumn {
810    pub fn next(self) -> Self {
811        match self {
812            Self::Name => Self::Status,
813            Self::Status => Self::Cpu,
814            Self::Cpu => Self::Memory,
815            Self::Memory => Self::Uptime,
816            Self::Uptime => Self::Name,
817        }
818    }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
822pub enum SortOrder {
823    #[default]
824    Ascending,
825    Descending,
826}
827
828impl SortOrder {
829    pub fn toggle(self) -> Self {
830        match self {
831            Self::Ascending => Self::Descending,
832            Self::Descending => Self::Ascending,
833        }
834    }
835
836    pub fn indicator(self) -> &'static str {
837        match self {
838            Self::Ascending => "↑",
839            Self::Descending => "↓",
840        }
841    }
842}
843
844pub struct App {
845    pub daemons: Vec<Daemon>,
846    pub disabled: Vec<DaemonId>,
847    pub selected: usize,
848    pub view: View,
849    pub prev_view: View,
850    pub log_content: Vec<String>,
851    pub log_daemon_id: Option<DaemonId>,
852    pub log_scroll: usize,
853    pub log_follow: bool, // Auto-scroll to bottom as new lines appear
854    pub message: Option<String>,
855    pub message_time: Option<Instant>,
856    pub process_stats: HashMap<u32, ProcessStats>, // PID -> stats
857    pub stats_history: HashMap<DaemonId, StatsHistory>, // daemon_id -> history
858    pub pending_action: Option<PendingAction>,
859    pub loading_text: Option<String>,
860    pub search_query: String,
861    pub search_active: bool,
862    // Sorting
863    pub sort_column: SortColumn,
864    pub sort_order: SortOrder,
865    // Log search
866    pub log_search_query: String,
867    pub log_search_active: bool,
868    pub log_search_matches: Vec<usize>, // Line indices that match
869    pub log_search_current: usize,      // Current match index
870    // Details view daemon (now used for full-page details view from 'l' key)
871    pub details_daemon_id: Option<DaemonId>,
872    // Whether logs are expanded to fill the screen (hides charts)
873    pub logs_expanded: bool,
874    // Multi-select state
875    pub multi_select: HashSet<DaemonId>,
876    // Config-only daemons (defined in pitchfork.toml but not currently active)
877    pub config_daemon_ids: HashSet<DaemonId>,
878    // Whether to show config-only daemons in the list
879    pub show_available: bool,
880    // Config editor state
881    pub editor_state: Option<EditorState>,
882    // Config file selector state
883    pub file_selector: Option<ConfigFileSelector>,
884    // Network view state
885    pub network_listeners: Vec<Listener>,
886    pub network_search_query: String,
887    pub network_search_active: bool,
888    pub network_selected: usize,
889    pub network_scroll_offset: usize,
890    pub network_selected_pid: Option<u32>, // Store PID to ensure correct targeting
891    pub network_visible_rows: usize,       // Cached visible row count for scroll calculations
892}
893
894impl App {
895    pub fn new() -> Self {
896        Self {
897            daemons: Vec::new(),
898            disabled: Vec::new(),
899            selected: 0,
900            view: View::Dashboard,
901            prev_view: View::Dashboard,
902            log_content: Vec::new(),
903            log_daemon_id: None,
904            log_scroll: 1,
905            log_follow: true,
906            message: None,
907            message_time: None,
908            process_stats: HashMap::new(),
909            stats_history: HashMap::new(),
910            pending_action: None,
911            loading_text: None,
912            search_query: String::new(),
913            search_active: false,
914            sort_column: SortColumn::default(),
915            sort_order: SortOrder::default(),
916            log_search_query: String::new(),
917            log_search_active: false,
918            log_search_matches: Vec::new(),
919            log_search_current: 0,
920            details_daemon_id: None,
921            logs_expanded: false,
922            multi_select: HashSet::new(),
923            config_daemon_ids: HashSet::new(),
924            show_available: true, // Show available daemons by default
925            editor_state: None,
926            file_selector: None,
927            network_listeners: Vec::new(),
928            network_search_query: String::new(),
929            network_search_active: false,
930            network_selected: 0,
931            network_scroll_offset: 0,
932            network_selected_pid: None,
933            network_visible_rows: 20, // Default value, updated during draw
934        }
935    }
936
937    pub fn confirm_action(&mut self, action: PendingAction) {
938        self.pending_action = Some(action);
939        self.prev_view = self.view;
940        self.view = View::Confirm;
941    }
942
943    pub fn cancel_confirm(&mut self) {
944        self.pending_action = None;
945        self.view = self.prev_view;
946    }
947
948    pub fn take_pending_action(&mut self) -> Option<PendingAction> {
949        self.view = self.prev_view;
950        self.pending_action.take()
951    }
952
953    pub fn start_loading(&mut self, text: impl Into<String>) {
954        self.loading_text = Some(text.into());
955    }
956
957    pub fn stop_loading(&mut self) {
958        self.loading_text = None;
959    }
960
961    // Search functionality
962    pub fn start_search(&mut self) {
963        self.search_active = true;
964    }
965
966    pub fn end_search(&mut self) {
967        self.search_active = false;
968    }
969
970    pub fn clear_search(&mut self) {
971        self.search_query.clear();
972        self.search_active = false;
973        self.selected = 0;
974    }
975
976    pub fn search_push(&mut self, c: char) {
977        self.search_query.push(c);
978        // Reset selection when search changes
979        self.selected = 0;
980    }
981
982    pub fn search_pop(&mut self) {
983        self.search_query.pop();
984        self.selected = 0;
985    }
986
987    pub fn filtered_daemons(&self) -> Vec<&Daemon> {
988        let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
989            self.daemons.iter().collect()
990        } else {
991            // Use fuzzy matching with SkimMatcherV2
992            let matcher = SkimMatcherV2::default();
993            let mut scored: Vec<_> = self
994                .daemons
995                .iter()
996                .filter_map(|d| {
997                    matcher
998                        .fuzzy_match(&d.id.qualified(), &self.search_query)
999                        .map(|score| (d, score))
1000                })
1001                .collect();
1002            // Sort by score descending (best matches first)
1003            scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1004            scored.into_iter().map(|(d, _)| d).collect()
1005        };
1006
1007        // Sort the filtered list
1008        filtered.sort_by(|a, b| {
1009            let cmp = match self.sort_column {
1010                SortColumn::Name => {
1011                    a.id.to_string()
1012                        .to_lowercase()
1013                        .cmp(&b.id.to_string().to_lowercase())
1014                }
1015                SortColumn::Status => {
1016                    let status_order = |d: &Daemon| match &d.status {
1017                        crate::daemon_status::DaemonStatus::Running => 0,
1018                        crate::daemon_status::DaemonStatus::Waiting => 1,
1019                        crate::daemon_status::DaemonStatus::Stopping => 2,
1020                        crate::daemon_status::DaemonStatus::Stopped => 3,
1021                        crate::daemon_status::DaemonStatus::Errored(_) => 4,
1022                        crate::daemon_status::DaemonStatus::Failed(_) => 5,
1023                    };
1024                    status_order(a).cmp(&status_order(b))
1025                }
1026                SortColumn::Cpu => {
1027                    let cpu_a = a
1028                        .pid
1029                        .and_then(|p| self.get_stats(p))
1030                        .map(|s| s.cpu_percent)
1031                        .unwrap_or(0.0);
1032                    let cpu_b = b
1033                        .pid
1034                        .and_then(|p| self.get_stats(p))
1035                        .map(|s| s.cpu_percent)
1036                        .unwrap_or(0.0);
1037                    cpu_a
1038                        .partial_cmp(&cpu_b)
1039                        .unwrap_or(std::cmp::Ordering::Equal)
1040                }
1041                SortColumn::Memory => {
1042                    let mem_a = a
1043                        .pid
1044                        .and_then(|p| self.get_stats(p))
1045                        .map(|s| s.memory_bytes)
1046                        .unwrap_or(0);
1047                    let mem_b = b
1048                        .pid
1049                        .and_then(|p| self.get_stats(p))
1050                        .map(|s| s.memory_bytes)
1051                        .unwrap_or(0);
1052                    mem_a.cmp(&mem_b)
1053                }
1054                SortColumn::Uptime => {
1055                    let up_a = a
1056                        .pid
1057                        .and_then(|p| self.get_stats(p))
1058                        .map(|s| s.uptime_secs)
1059                        .unwrap_or(0);
1060                    let up_b = b
1061                        .pid
1062                        .and_then(|p| self.get_stats(p))
1063                        .map(|s| s.uptime_secs)
1064                        .unwrap_or(0);
1065                    up_a.cmp(&up_b)
1066                }
1067            };
1068            match self.sort_order {
1069                SortOrder::Ascending => cmp,
1070                SortOrder::Descending => cmp.reverse(),
1071            }
1072        });
1073
1074        filtered
1075    }
1076
1077    // Sorting
1078    pub fn cycle_sort(&mut self) {
1079        // If clicking the same column, toggle order; otherwise switch column
1080        self.sort_column = self.sort_column.next();
1081        self.selected = 0;
1082    }
1083
1084    pub fn toggle_sort_order(&mut self) {
1085        self.sort_order = self.sort_order.toggle();
1086        self.selected = 0;
1087    }
1088
1089    pub fn selected_daemon(&self) -> Option<&Daemon> {
1090        let filtered = self.filtered_daemons();
1091        filtered.get(self.selected).copied()
1092    }
1093
1094    pub fn select_next(&mut self) {
1095        let count = self.filtered_daemons().len();
1096        if count > 0 {
1097            self.selected = (self.selected + 1) % count;
1098        }
1099    }
1100
1101    pub fn select_prev(&mut self) {
1102        let count = self.filtered_daemons().len();
1103        if count > 0 {
1104            self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1105        }
1106    }
1107
1108    // Log follow mode
1109    pub fn toggle_log_follow(&mut self) {
1110        self.log_follow = !self.log_follow;
1111        if self.log_follow && !self.log_content.is_empty() {
1112            // Jump to bottom when enabling follow
1113            self.log_scroll = self.log_content.len();
1114        }
1115    }
1116
1117    // Toggle logs expanded (hide/show charts)
1118    pub fn toggle_logs_expanded(&mut self) {
1119        self.logs_expanded = !self.logs_expanded;
1120    }
1121
1122    // Multi-select methods
1123    pub fn toggle_select(&mut self) {
1124        if let Some(daemon) = self.selected_daemon() {
1125            let id = daemon.id.clone();
1126            if self.multi_select.contains(&id) {
1127                self.multi_select.remove(&id);
1128            } else {
1129                self.multi_select.insert(id);
1130            }
1131        }
1132    }
1133
1134    pub fn select_all_visible(&mut self) {
1135        // Collect IDs first to avoid borrow conflict
1136        let ids: Vec<DaemonId> = self
1137            .filtered_daemons()
1138            .iter()
1139            .map(|d| d.id.clone())
1140            .collect();
1141        for id in ids {
1142            self.multi_select.insert(id);
1143        }
1144    }
1145
1146    pub fn clear_selection(&mut self) {
1147        self.multi_select.clear();
1148    }
1149
1150    pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1151        self.multi_select.contains(daemon_id)
1152    }
1153
1154    pub fn has_selection(&self) -> bool {
1155        !self.multi_select.is_empty()
1156    }
1157
1158    pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1159        self.multi_select.iter().cloned().collect()
1160    }
1161
1162    pub fn set_message(&mut self, msg: impl Into<String>) {
1163        self.message = Some(msg.into());
1164        self.message_time = Some(Instant::now());
1165    }
1166
1167    pub fn clear_stale_message(&mut self) {
1168        let duration = settings().tui_message_duration();
1169        if let Some(time) = self.message_time
1170            && time.elapsed() >= duration
1171        {
1172            self.message = None;
1173            self.message_time = None;
1174        }
1175    }
1176
1177    pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1178        self.process_stats.get(&pid)
1179    }
1180
1181    fn refresh_process_stats(&mut self) {
1182        let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1183        if !pids.is_empty() {
1184            PROCS.refresh_processes();
1185        }
1186        self.process_stats.clear();
1187
1188        let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1189
1190        for daemon in &self.daemons {
1191            if let Some(pid) = daemon.pid
1192                && let Some(stats) = stats_by_pid.get(&pid).copied()
1193            {
1194                self.process_stats.insert(pid, stats);
1195                // Record history for this daemon
1196                let history = self.stats_history.entry(daemon.id.clone()).or_default();
1197                history.push(StatsSnapshot::from(&stats));
1198            }
1199        }
1200    }
1201
1202    /// Get stats history for a daemon
1203    pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1204        self.stats_history.get(daemon_id)
1205    }
1206
1207    /// Fetch fresh daemon data from IPC. Called from a background task.
1208    pub async fn fetch_daemon_data(client: &Arc<IpcClient>) -> Result<Vec<DaemonListEntry>> {
1209        use crate::daemon_list::get_all_daemons;
1210        get_all_daemons(client).await
1211    }
1212
1213    /// Apply previously fetched daemon data to update app state.
1214    pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1215        // Clear current lists
1216        self.daemons.clear();
1217        self.disabled.clear();
1218        self.config_daemon_ids.clear();
1219
1220        for entry in all_entries {
1221            let daemon_id = entry.daemon.id.clone();
1222
1223            if entry.is_disabled {
1224                self.disabled.push(daemon_id.clone());
1225            }
1226
1227            if entry.is_available {
1228                self.config_daemon_ids.insert(daemon_id.clone());
1229            }
1230
1231            if !entry.is_available || self.show_available {
1232                self.daemons.push(entry.daemon);
1233            }
1234        }
1235
1236        self.refresh_process_stats();
1237        self.clear_stale_message();
1238
1239        let total_count = self.total_daemon_count();
1240        if total_count > 0 && self.selected >= total_count {
1241            self.selected = total_count - 1;
1242        }
1243
1244        if self.view == View::Logs
1245            && let Some(id) = self.log_daemon_id.clone()
1246        {
1247            self.load_logs(&id);
1248        }
1249    }
1250
1251    pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1252        let entries = Self::fetch_daemon_data(client).await?;
1253        self.apply_refresh(entries);
1254        Ok(())
1255    }
1256
1257    /// Apply network listener data fetched by a background task.
1258    pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1259        self.network_listeners = listeners;
1260
1261        let filtered_count = self.filtered_network_listeners().len();
1262
1263        if filtered_count > 0 && self.network_selected >= filtered_count {
1264            self.network_selected = filtered_count - 1;
1265        } else if filtered_count == 0 {
1266            self.network_selected = 0;
1267        }
1268
1269        let selected_pid = self
1270            .filtered_network_listeners()
1271            .get(self.network_selected)
1272            .map(|l| l.process.pid);
1273        self.network_selected_pid = selected_pid;
1274    }
1275
1276    /// Get filtered network listeners based on search query
1277    pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1278        if self.network_search_query.is_empty() {
1279            return self.network_listeners.iter().collect();
1280        }
1281
1282        let matcher = SkimMatcherV2::default();
1283        let query = &self.network_search_query;
1284
1285        self.network_listeners
1286            .iter()
1287            .filter(|listener| {
1288                // Search by process name, PID, or port
1289                let search_text = format!(
1290                    "{} {} {}",
1291                    listener.process.name,
1292                    listener.process.pid,
1293                    listener.socket.port()
1294                );
1295                matcher.fuzzy_match(&search_text, query).is_some()
1296            })
1297            .collect()
1298    }
1299
1300    /// Toggle network search active state
1301    pub fn toggle_network_search(&mut self) {
1302        self.network_search_active = !self.network_search_active;
1303        if !self.network_search_active {
1304            self.network_search_query.clear();
1305        }
1306        // Reset scroll and selection when toggling search
1307        self.network_selected = 0;
1308        self.network_scroll_offset = 0;
1309        // Update PID for new selection
1310        let filtered = self.filtered_network_listeners();
1311        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1312    }
1313
1314    /// Clear network search
1315    pub fn clear_network_search(&mut self) {
1316        self.network_search_query.clear();
1317        self.network_search_active = false;
1318        // Reset scroll and selection
1319        self.network_selected = 0;
1320        self.network_scroll_offset = 0;
1321        // Update PID for new selection
1322        let filtered = self.filtered_network_listeners();
1323        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1324    }
1325
1326    /// Check if a daemon is from config only (not currently active)
1327    pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1328        self.config_daemon_ids.contains(daemon_id)
1329    }
1330
1331    /// Toggle showing available daemons from config
1332    pub fn toggle_show_available(&mut self) {
1333        self.show_available = !self.show_available;
1334    }
1335
1336    /// Get total daemon count (for selection bounds)
1337    fn total_daemon_count(&self) -> usize {
1338        self.filtered_daemons().len()
1339    }
1340
1341    pub fn scroll_logs_down(&mut self) {
1342        let max_scroll = self.log_content.len();
1343        self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1344    }
1345
1346    pub fn scroll_logs_up(&mut self) {
1347        self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1348    }
1349
1350    /// Scroll down by half page (Ctrl+D)
1351    pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1352        let half_page = visible_lines / 2;
1353        let max_scroll = self.log_content.len();
1354        self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1355    }
1356
1357    /// Scroll up by half page (Ctrl+U)
1358    pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1359        let half_page = visible_lines / 2;
1360        self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1361    }
1362
1363    // Log search
1364    pub fn start_log_search(&mut self) {
1365        self.log_search_active = true;
1366        self.log_search_query.clear();
1367        self.log_search_matches.clear();
1368        self.log_search_current = 0;
1369    }
1370
1371    pub fn end_log_search(&mut self) {
1372        self.log_search_active = false;
1373    }
1374
1375    pub fn clear_log_search(&mut self) {
1376        self.log_search_query.clear();
1377        self.log_search_active = false;
1378        self.log_search_matches.clear();
1379        self.log_search_current = 0;
1380    }
1381
1382    pub fn log_search_push(&mut self, c: char) {
1383        self.log_search_query.push(c);
1384        self.update_log_search_matches();
1385    }
1386
1387    pub fn log_search_pop(&mut self) {
1388        self.log_search_query.pop();
1389        self.update_log_search_matches();
1390    }
1391
1392    fn update_log_search_matches(&mut self) {
1393        self.log_search_matches.clear();
1394        if !self.log_search_query.is_empty() {
1395            let query = self.log_search_query.to_lowercase();
1396            for (i, line) in self.log_content.iter().enumerate() {
1397                if line.to_lowercase().contains(&query) {
1398                    self.log_search_matches.push(i);
1399                }
1400            }
1401            // Jump to first match if any
1402            if !self.log_search_matches.is_empty() {
1403                self.log_search_current = 0;
1404                self.jump_to_log_match();
1405            }
1406        }
1407    }
1408
1409    pub fn log_search_next(&mut self) {
1410        if !self.log_search_matches.is_empty() {
1411            self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1412            self.jump_to_log_match();
1413        }
1414    }
1415
1416    pub fn log_search_prev(&mut self) {
1417        if !self.log_search_matches.is_empty() {
1418            self.log_search_current = self
1419                .log_search_current
1420                .checked_sub(1)
1421                .unwrap_or(self.log_search_matches.len() - 1);
1422            self.jump_to_log_match();
1423        }
1424    }
1425
1426    fn jump_to_log_match(&mut self) {
1427        if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1428            // Scroll so the match is visible (center it if possible)
1429            let half_page = 10; // Assume ~20 visible lines
1430            self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1431            self.log_follow = false;
1432        }
1433    }
1434
1435    // Details view
1436    pub fn show_details(&mut self, daemon_id: &DaemonId) {
1437        self.details_daemon_id = Some(daemon_id.clone());
1438        self.prev_view = self.view;
1439        self.view = View::Details;
1440    }
1441
1442    pub fn hide_details(&mut self) {
1443        self.details_daemon_id = None;
1444        self.view = View::Dashboard;
1445    }
1446
1447    /// View daemon details (charts + logs)
1448    pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1449        self.log_daemon_id = Some(daemon_id.clone());
1450        self.logs_expanded = false; // Start with charts visible
1451        self.load_logs(daemon_id);
1452        self.view = View::Logs; // Logs view is now the full daemon details view
1453    }
1454
1455    fn load_logs(&mut self, daemon_id: &DaemonId) {
1456        const TUI_LOG_LIMIT: usize = 5000;
1457        let prev_len = self.log_content.len();
1458
1459        self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1460            daemon_ids: vec![daemon_id.qualified()],
1461            from: None,
1462            to: None,
1463            limit: Some(TUI_LOG_LIMIT),
1464            order_desc: true,
1465            after_id: None,
1466        }) {
1467            Ok(entries) if !entries.is_empty() => {
1468                let mut lines: Vec<String> = entries
1469                    .into_iter()
1470                    .map(|e| {
1471                        let ts = e.timestamp.format("%H:%M:%S").to_string();
1472                        format!("{} {}", ts, e.message)
1473                    })
1474                    .collect();
1475                lines.reverse();
1476                lines
1477            }
1478            _ => vec!["No logs available".to_string()],
1479        };
1480
1481        // Auto-scroll to bottom when in follow mode
1482        if self.log_follow {
1483            self.log_scroll = self.log_content.len().max(1);
1484        } else if prev_len == 0 {
1485            // First load - start at bottom
1486            self.log_scroll = self.log_content.len().max(1);
1487        }
1488        // If not following and not first load, keep scroll position
1489    }
1490
1491    pub fn show_help(&mut self) {
1492        self.view = View::Help;
1493    }
1494
1495    pub fn back_to_dashboard(&mut self) {
1496        self.view = View::Dashboard;
1497        self.log_daemon_id = None;
1498        self.log_content.clear();
1499        self.log_scroll = 1;
1500    }
1501
1502    /// Returns (total, running, stopped, errored, available)
1503    pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1504        let available = self.config_daemon_ids.len();
1505        let total = self.daemons.len();
1506        let running = self
1507            .daemons
1508            .iter()
1509            .filter(|d| d.status.is_running())
1510            .count();
1511        // Don't count config-only daemons as stopped
1512        let stopped = self
1513            .daemons
1514            .iter()
1515            .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1516            .count();
1517        let errored = self
1518            .daemons
1519            .iter()
1520            .filter(|d| d.status.is_errored() || d.status.is_failed())
1521            .count();
1522        (total, running, stopped, errored, available)
1523    }
1524
1525    pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1526        self.disabled.contains(daemon_id)
1527    }
1528
1529    // Config editor methods
1530
1531    /// Get list of available config file paths
1532    pub fn get_config_files(&self) -> Vec<PathBuf> {
1533        let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1534            .into_iter()
1535            .filter(|p| p.exists())
1536            .collect();
1537
1538        // Add option to create in current directory if not present
1539        let cwd_config = crate::env::CWD.join("pitchfork.toml");
1540        if !files.contains(&cwd_config) {
1541            files.push(cwd_config);
1542        }
1543
1544        files
1545    }
1546
1547    /// Open file selector for creating a new daemon
1548    pub fn open_file_selector(&mut self) {
1549        let files = self.get_config_files();
1550        self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1551        self.view = View::ConfigFileSelect;
1552    }
1553
1554    /// Open editor for a new daemon with the selected config file
1555    pub fn open_editor_create(&mut self, config_path: PathBuf) {
1556        self.editor_state = Some(EditorState::new_create(config_path));
1557        self.file_selector = None;
1558        self.view = View::ConfigEditor;
1559    }
1560
1561    /// Open editor for an existing daemon
1562    pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1563        let config = match PitchforkToml::all_merged() {
1564            Ok(config) => config,
1565            Err(e) => {
1566                self.set_message(format!("Failed to load config: {e}"));
1567                return;
1568            }
1569        };
1570        if let Some(daemon_config) = config.daemons.get(daemon_id) {
1571            let config_path = daemon_config
1572                .path
1573                .clone()
1574                .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1575            self.editor_state = Some(EditorState::new_edit(
1576                daemon_id.to_string(),
1577                daemon_config,
1578                config_path,
1579            ));
1580            self.view = View::ConfigEditor;
1581        } else {
1582            self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1583        }
1584    }
1585
1586    /// Close the editor and return to dashboard
1587    pub fn close_editor(&mut self) {
1588        self.editor_state = None;
1589        self.file_selector = None;
1590        self.view = View::Dashboard;
1591    }
1592
1593    /// Save the current editor state to config file.
1594    /// Returns Ok(true) if saved successfully, Ok(false) if validation/duplicate error (don't close editor).
1595    pub fn save_editor_config(&mut self) -> Result<bool> {
1596        let editor = self
1597            .editor_state
1598            .as_mut()
1599            .ok_or_else(|| miette::miette!("No editor state"))?;
1600
1601        // Validate
1602        if !editor.validate() {
1603            self.set_message("Please fix validation errors before saving");
1604            return Ok(false);
1605        }
1606
1607        // Build daemon config
1608        let daemon_config = editor.to_daemon_config();
1609
1610        // Parse daemon ID from string
1611        let daemon_id = DaemonId::parse(&editor.daemon_id)
1612            .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1613
1614        // Read existing config (or create new)
1615        let mut config = PitchforkToml::read(&editor.config_path)?;
1616
1617        // Check for duplicate daemon ID
1618        let is_duplicate = match &editor.mode {
1619            EditMode::Create => config.daemons.contains_key(&daemon_id),
1620            EditMode::Edit { original_id } => {
1621                // Only a duplicate if ID changed AND new ID already exists
1622                let original_daemon_id = DaemonId::parse(original_id)
1623                    .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1624                original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1625            }
1626        };
1627
1628        if is_duplicate {
1629            self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1630            return Ok(false);
1631        }
1632
1633        // Handle rename case
1634        if let EditMode::Edit { original_id } = &editor.mode {
1635            let original_daemon_id = DaemonId::parse(original_id)
1636                .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1637            if original_daemon_id != daemon_id {
1638                config.daemons.shift_remove(&original_daemon_id);
1639            }
1640        }
1641
1642        // Insert/update daemon
1643        config.daemons.insert(daemon_id, daemon_config);
1644
1645        // Write back
1646        config.write()?;
1647
1648        editor.unsaved_changes = false;
1649        let daemon_id = editor.daemon_id.clone();
1650        self.set_message(format!("Saved daemon '{daemon_id}'"));
1651
1652        Ok(true)
1653    }
1654
1655    /// Delete a daemon from the config file. Returns Ok(true) if deleted, Ok(false) if not found.
1656    pub fn delete_daemon_from_config(
1657        &mut self,
1658        id: &str,
1659        config_path: &std::path::Path,
1660    ) -> Result<bool> {
1661        let mut config = PitchforkToml::read(config_path)?;
1662
1663        // For qualified IDs, parse directly; for bare names derive the namespace
1664        // from the config file path so project-level daemons are found correctly.
1665        let daemon_id = if id.contains('/') {
1666            DaemonId::parse(id)?
1667        } else {
1668            let ns = namespace_from_path(config_path)?;
1669            DaemonId::try_new(&ns, id)?
1670        };
1671
1672        if config.daemons.shift_remove(&daemon_id).is_some() {
1673            config.write()?;
1674            Ok(true)
1675        } else {
1676            Ok(false)
1677        }
1678    }
1679}
1680
1681impl Default for App {
1682    fn default() -> Self {
1683        Self::new()
1684    }
1685}