Skip to main content

pitchfork_cli/tui/
app.rs

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