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            run: String::new(),
508            auto: vec![],
509            cron: None,
510            retry: Retry(0),
511            ready_delay: None,
512            ready_output: None,
513            ready_http: None,
514            ready_port: None,
515            ready_cmd: self.preserved_ready_cmd.clone(),
516            expected_port: Vec::new(),
517            auto_bump_port: false,
518            port_bump_attempts: 10,
519            boot_start: None,
520            depends: vec![],
521            watch: vec![],
522            dir: None,
523            env: None,
524            hooks: None,
525            mise: None,
526            path: Some(self.config_path.clone()),
527        };
528
529        let mut cron_schedule: Option<String> = None;
530        let mut cron_retrigger = CronRetrigger::Finish;
531
532        for field in &self.fields {
533            match (field.name, &field.value) {
534                ("run", FormFieldValue::Text(s)) => config.run = s.clone(),
535                ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
536                ("env", FormFieldValue::StringList(v)) => {
537                    if v.is_empty() {
538                        config.env = None;
539                    } else {
540                        let mut map = indexmap::IndexMap::new();
541                        for entry in v {
542                            if let Some((k, val)) = entry.split_once('=') {
543                                map.insert(k.trim().to_string(), val.trim().to_string());
544                            }
545                        }
546                        config.env = if map.is_empty() { None } else { Some(map) };
547                    }
548                }
549                ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
550                ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
551                ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
552                ("ready_output", FormFieldValue::OptionalText(s)) => {
553                    config.ready_output = s.clone()
554                }
555                ("ready_http", FormFieldValue::OptionalText(s)) => config.ready_http = s.clone(),
556                ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = *p,
557                ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
558                ("depends", FormFieldValue::StringList(v)) => {
559                    config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
560                }
561                ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
562                ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
563                ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
564                _ => {}
565            }
566        }
567
568        if let Some(schedule) = cron_schedule {
569            config.cron = Some(PitchforkTomlCron {
570                schedule,
571                retrigger: cron_retrigger,
572            });
573        }
574
575        config
576    }
577
578    pub fn next_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
584        // When leaving daemon_id editing, don't increment - just move to first form field
585        if self.daemon_id_editing {
586            self.daemon_id_editing = false;
587            return;
588        }
589
590        if self.focused_field < self.fields.len() - 1 {
591            self.focused_field += 1;
592        }
593    }
594
595    pub fn prev_field(&mut self) {
596        // Stop editing current field if text editing
597        if let Some(field) = self.fields.get_mut(self.focused_field) {
598            field.editing = false;
599        }
600        self.daemon_id_editing = false;
601
602        if self.focused_field > 0 {
603            self.focused_field -= 1;
604        }
605    }
606
607    pub fn toggle_current_field(&mut self) {
608        if let Some(field) = self.fields.get_mut(self.focused_field) {
609            let toggled = match &mut field.value {
610                FormFieldValue::Boolean(b) => {
611                    *b = !*b;
612                    true
613                }
614                FormFieldValue::OptionalBoolean(opt) => {
615                    *opt = match opt {
616                        None => Some(true),
617                        Some(true) => Some(false),
618                        Some(false) => None,
619                    };
620                    true
621                }
622                FormFieldValue::AutoBehavior(v) => {
623                    // Cycle through: [] -> [Start] -> [Stop] -> [Start, Stop] -> []
624                    let has_start = v.contains(&PitchforkTomlAuto::Start);
625                    let has_stop = v.contains(&PitchforkTomlAuto::Stop);
626                    *v = match (has_start, has_stop) {
627                        (false, false) => vec![PitchforkTomlAuto::Start],
628                        (true, false) => vec![PitchforkTomlAuto::Stop],
629                        (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
630                        (true, true) => vec![],
631                    };
632                    true
633                }
634                FormFieldValue::Retrigger(r) => {
635                    *r = match r {
636                        CronRetrigger::Finish => CronRetrigger::Always,
637                        CronRetrigger::Always => CronRetrigger::Success,
638                        CronRetrigger::Success => CronRetrigger::Fail,
639                        CronRetrigger::Fail => CronRetrigger::Finish,
640                    };
641                    true
642                }
643                _ => false,
644            };
645            if toggled {
646                self.unsaved_changes = true;
647            }
648        }
649    }
650
651    pub fn start_editing(&mut self) {
652        if let Some(field) = self.fields.get_mut(self.focused_field) {
653            if field.is_text_editable() {
654                field.editing = true;
655                field.cursor = field.get_text().chars().count();
656            } else {
657                // For non-text fields, toggle them
658                self.toggle_current_field();
659            }
660        }
661    }
662
663    pub fn stop_editing(&mut self) {
664        if let Some(field) = self.fields.get_mut(self.focused_field) {
665            field.editing = false;
666        }
667        self.daemon_id_editing = false;
668    }
669
670    pub fn is_editing(&self) -> bool {
671        self.daemon_id_editing
672            || self
673                .fields
674                .get(self.focused_field)
675                .map(|f| f.editing)
676                .unwrap_or(false)
677    }
678
679    pub fn text_push(&mut self, c: char) {
680        if self.daemon_id_editing {
681            let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
682            self.daemon_id.insert(byte_idx, c);
683            self.daemon_id_cursor += 1;
684            self.unsaved_changes = true;
685        } else if let Some(field) = self.fields.get_mut(self.focused_field)
686            && field.editing
687        {
688            let mut text = field.get_text();
689            let byte_idx = char_to_byte_index(&text, field.cursor);
690            text.insert(byte_idx, c);
691            field.cursor += 1;
692            field.set_text(text);
693            self.unsaved_changes = true;
694        }
695    }
696
697    pub fn text_pop(&mut self) {
698        if self.daemon_id_editing && self.daemon_id_cursor > 0 {
699            self.daemon_id_cursor -= 1;
700            let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
701            self.daemon_id.remove(byte_idx);
702            self.unsaved_changes = true;
703        } else if let Some(field) = self.fields.get_mut(self.focused_field)
704            && field.editing
705            && field.cursor > 0
706        {
707            let mut text = field.get_text();
708            field.cursor -= 1;
709            let byte_idx = char_to_byte_index(&text, field.cursor);
710            text.remove(byte_idx);
711            field.set_text(text);
712            // For Number fields, sync cursor to end if value defaulted to "0"
713            // This handles the case where backspacing to empty makes value 0
714            if matches!(field.value, FormFieldValue::Number(_)) {
715                field.cursor = field.get_text().chars().count();
716            }
717            self.unsaved_changes = true;
718        }
719    }
720
721    pub fn validate(&mut self) -> bool {
722        let mut valid = true;
723
724        // Validate daemon ID
725        self.daemon_id_error = None;
726        if self.daemon_id.is_empty() {
727            self.daemon_id_error = Some("Name is required".to_string());
728            valid = false;
729        } else if !self
730            .daemon_id
731            .chars()
732            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
733        {
734            self.daemon_id_error =
735                Some("Only letters, digits, hyphens, and underscores allowed".to_string());
736            valid = false;
737        }
738
739        // Validate fields
740        for field in &mut self.fields {
741            field.error = None;
742
743            match (field.name, &field.value) {
744                ("run", FormFieldValue::Text(s)) if s.is_empty() => {
745                    field.error = Some("Required".to_string());
746                    valid = false;
747                }
748                ("ready_port", FormFieldValue::OptionalPort(Some(p))) if *p == 0 => {
749                    field.error = Some("Port must be 1-65535".to_string());
750                    valid = false;
751                }
752                ("ready_http", FormFieldValue::OptionalText(Some(url)))
753                    if !(url.starts_with("http://") || url.starts_with("https://")) =>
754                {
755                    field.error = Some("Must start with http:// or https://".to_string());
756                    valid = false;
757                }
758                _ => {}
759            }
760        }
761
762        valid
763    }
764}
765
766/// State for config file selection
767#[derive(Debug, Clone)]
768pub struct ConfigFileSelector {
769    pub files: Vec<PathBuf>,
770    pub selected: usize,
771}
772
773#[derive(Debug, Clone)]
774pub enum PendingAction {
775    Stop(DaemonId),
776    Restart(DaemonId),
777    Disable(DaemonId),
778    // Batch operations
779    BatchStop(Vec<DaemonId>),
780    BatchRestart(Vec<DaemonId>),
781    BatchDisable(Vec<DaemonId>),
782    // Config editor actions
783    DeleteDaemon { id: String, config_path: PathBuf },
784    DiscardEditorChanges,
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
788pub enum SortColumn {
789    #[default]
790    Name,
791    Status,
792    Cpu,
793    Memory,
794    Uptime,
795}
796
797impl SortColumn {
798    pub fn next(self) -> Self {
799        match self {
800            Self::Name => Self::Status,
801            Self::Status => Self::Cpu,
802            Self::Cpu => Self::Memory,
803            Self::Memory => Self::Uptime,
804            Self::Uptime => Self::Name,
805        }
806    }
807}
808
809#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
810pub enum SortOrder {
811    #[default]
812    Ascending,
813    Descending,
814}
815
816impl SortOrder {
817    pub fn toggle(self) -> Self {
818        match self {
819            Self::Ascending => Self::Descending,
820            Self::Descending => Self::Ascending,
821        }
822    }
823
824    pub fn indicator(self) -> &'static str {
825        match self {
826            Self::Ascending => "↑",
827            Self::Descending => "↓",
828        }
829    }
830}
831
832pub struct App {
833    pub daemons: Vec<Daemon>,
834    pub disabled: Vec<DaemonId>,
835    pub selected: usize,
836    pub view: View,
837    pub prev_view: View,
838    pub log_content: Vec<String>,
839    pub log_daemon_id: Option<DaemonId>,
840    pub log_scroll: usize,
841    pub log_follow: bool, // Auto-scroll to bottom as new lines appear
842    pub message: Option<String>,
843    pub message_time: Option<Instant>,
844    pub process_stats: HashMap<u32, ProcessStats>, // PID -> stats
845    pub stats_history: HashMap<DaemonId, StatsHistory>, // daemon_id -> history
846    pub pending_action: Option<PendingAction>,
847    pub loading_text: Option<String>,
848    pub search_query: String,
849    pub search_active: bool,
850    // Sorting
851    pub sort_column: SortColumn,
852    pub sort_order: SortOrder,
853    // Log search
854    pub log_search_query: String,
855    pub log_search_active: bool,
856    pub log_search_matches: Vec<usize>, // Line indices that match
857    pub log_search_current: usize,      // Current match index
858    // Details view daemon (now used for full-page details view from 'l' key)
859    pub details_daemon_id: Option<DaemonId>,
860    // Whether logs are expanded to fill the screen (hides charts)
861    pub logs_expanded: bool,
862    // Multi-select state
863    pub multi_select: HashSet<DaemonId>,
864    // Config-only daemons (defined in pitchfork.toml but not currently active)
865    pub config_daemon_ids: HashSet<DaemonId>,
866    // Whether to show config-only daemons in the list
867    pub show_available: bool,
868    // Config editor state
869    pub editor_state: Option<EditorState>,
870    // Config file selector state
871    pub file_selector: Option<ConfigFileSelector>,
872    // Network view state
873    pub network_listeners: Vec<Listener>,
874    pub network_search_query: String,
875    pub network_search_active: bool,
876    pub network_selected: usize,
877    pub network_scroll_offset: usize,
878    pub network_selected_pid: Option<u32>, // Store PID to ensure correct targeting
879    pub network_visible_rows: usize,       // Cached visible row count for scroll calculations
880}
881
882impl App {
883    pub fn new() -> Self {
884        Self {
885            daemons: Vec::new(),
886            disabled: Vec::new(),
887            selected: 0,
888            view: View::Dashboard,
889            prev_view: View::Dashboard,
890            log_content: Vec::new(),
891            log_daemon_id: None,
892            log_scroll: 1,
893            log_follow: true,
894            message: None,
895            message_time: None,
896            process_stats: HashMap::new(),
897            stats_history: HashMap::new(),
898            pending_action: None,
899            loading_text: None,
900            search_query: String::new(),
901            search_active: false,
902            sort_column: SortColumn::default(),
903            sort_order: SortOrder::default(),
904            log_search_query: String::new(),
905            log_search_active: false,
906            log_search_matches: Vec::new(),
907            log_search_current: 0,
908            details_daemon_id: None,
909            logs_expanded: false,
910            multi_select: HashSet::new(),
911            config_daemon_ids: HashSet::new(),
912            show_available: true, // Show available daemons by default
913            editor_state: None,
914            file_selector: None,
915            network_listeners: Vec::new(),
916            network_search_query: String::new(),
917            network_search_active: false,
918            network_selected: 0,
919            network_scroll_offset: 0,
920            network_selected_pid: None,
921            network_visible_rows: 20, // Default value, updated during draw
922        }
923    }
924
925    pub fn confirm_action(&mut self, action: PendingAction) {
926        self.pending_action = Some(action);
927        self.prev_view = self.view;
928        self.view = View::Confirm;
929    }
930
931    pub fn cancel_confirm(&mut self) {
932        self.pending_action = None;
933        self.view = self.prev_view;
934    }
935
936    pub fn take_pending_action(&mut self) -> Option<PendingAction> {
937        self.view = self.prev_view;
938        self.pending_action.take()
939    }
940
941    pub fn start_loading(&mut self, text: impl Into<String>) {
942        self.prev_view = self.view;
943        self.loading_text = Some(text.into());
944        self.view = View::Loading;
945    }
946
947    pub fn stop_loading(&mut self) {
948        self.loading_text = None;
949        self.view = self.prev_view;
950    }
951
952    // Search functionality
953    pub fn start_search(&mut self) {
954        self.search_active = true;
955    }
956
957    pub fn end_search(&mut self) {
958        self.search_active = false;
959    }
960
961    pub fn clear_search(&mut self) {
962        self.search_query.clear();
963        self.search_active = false;
964        self.selected = 0;
965    }
966
967    pub fn search_push(&mut self, c: char) {
968        self.search_query.push(c);
969        // Reset selection when search changes
970        self.selected = 0;
971    }
972
973    pub fn search_pop(&mut self) {
974        self.search_query.pop();
975        self.selected = 0;
976    }
977
978    pub fn filtered_daemons(&self) -> Vec<&Daemon> {
979        let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
980            self.daemons.iter().collect()
981        } else {
982            // Use fuzzy matching with SkimMatcherV2
983            let matcher = SkimMatcherV2::default();
984            let mut scored: Vec<_> = self
985                .daemons
986                .iter()
987                .filter_map(|d| {
988                    matcher
989                        .fuzzy_match(&d.id.qualified(), &self.search_query)
990                        .map(|score| (d, score))
991                })
992                .collect();
993            // Sort by score descending (best matches first)
994            scored.sort_by_key(|s| std::cmp::Reverse(s.1));
995            scored.into_iter().map(|(d, _)| d).collect()
996        };
997
998        // Sort the filtered list
999        filtered.sort_by(|a, b| {
1000            let cmp = match self.sort_column {
1001                SortColumn::Name => {
1002                    a.id.to_string()
1003                        .to_lowercase()
1004                        .cmp(&b.id.to_string().to_lowercase())
1005                }
1006                SortColumn::Status => {
1007                    let status_order = |d: &Daemon| match &d.status {
1008                        crate::daemon_status::DaemonStatus::Running => 0,
1009                        crate::daemon_status::DaemonStatus::Waiting => 1,
1010                        crate::daemon_status::DaemonStatus::Stopping => 2,
1011                        crate::daemon_status::DaemonStatus::Stopped => 3,
1012                        crate::daemon_status::DaemonStatus::Errored(_) => 4,
1013                        crate::daemon_status::DaemonStatus::Failed(_) => 5,
1014                    };
1015                    status_order(a).cmp(&status_order(b))
1016                }
1017                SortColumn::Cpu => {
1018                    let cpu_a = a
1019                        .pid
1020                        .and_then(|p| self.get_stats(p))
1021                        .map(|s| s.cpu_percent)
1022                        .unwrap_or(0.0);
1023                    let cpu_b = b
1024                        .pid
1025                        .and_then(|p| self.get_stats(p))
1026                        .map(|s| s.cpu_percent)
1027                        .unwrap_or(0.0);
1028                    cpu_a
1029                        .partial_cmp(&cpu_b)
1030                        .unwrap_or(std::cmp::Ordering::Equal)
1031                }
1032                SortColumn::Memory => {
1033                    let mem_a = a
1034                        .pid
1035                        .and_then(|p| self.get_stats(p))
1036                        .map(|s| s.memory_bytes)
1037                        .unwrap_or(0);
1038                    let mem_b = b
1039                        .pid
1040                        .and_then(|p| self.get_stats(p))
1041                        .map(|s| s.memory_bytes)
1042                        .unwrap_or(0);
1043                    mem_a.cmp(&mem_b)
1044                }
1045                SortColumn::Uptime => {
1046                    let up_a = a
1047                        .pid
1048                        .and_then(|p| self.get_stats(p))
1049                        .map(|s| s.uptime_secs)
1050                        .unwrap_or(0);
1051                    let up_b = b
1052                        .pid
1053                        .and_then(|p| self.get_stats(p))
1054                        .map(|s| s.uptime_secs)
1055                        .unwrap_or(0);
1056                    up_a.cmp(&up_b)
1057                }
1058            };
1059            match self.sort_order {
1060                SortOrder::Ascending => cmp,
1061                SortOrder::Descending => cmp.reverse(),
1062            }
1063        });
1064
1065        filtered
1066    }
1067
1068    // Sorting
1069    pub fn cycle_sort(&mut self) {
1070        // If clicking the same column, toggle order; otherwise switch column
1071        self.sort_column = self.sort_column.next();
1072        self.selected = 0;
1073    }
1074
1075    pub fn toggle_sort_order(&mut self) {
1076        self.sort_order = self.sort_order.toggle();
1077        self.selected = 0;
1078    }
1079
1080    pub fn selected_daemon(&self) -> Option<&Daemon> {
1081        let filtered = self.filtered_daemons();
1082        filtered.get(self.selected).copied()
1083    }
1084
1085    pub fn select_next(&mut self) {
1086        let count = self.filtered_daemons().len();
1087        if count > 0 {
1088            self.selected = (self.selected + 1) % count;
1089        }
1090    }
1091
1092    pub fn select_prev(&mut self) {
1093        let count = self.filtered_daemons().len();
1094        if count > 0 {
1095            self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1096        }
1097    }
1098
1099    // Log follow mode
1100    pub fn toggle_log_follow(&mut self) {
1101        self.log_follow = !self.log_follow;
1102        if self.log_follow && !self.log_content.is_empty() {
1103            // Jump to bottom when enabling follow
1104            self.log_scroll = self.log_content.len();
1105        }
1106    }
1107
1108    // Toggle logs expanded (hide/show charts)
1109    pub fn toggle_logs_expanded(&mut self) {
1110        self.logs_expanded = !self.logs_expanded;
1111    }
1112
1113    // Multi-select methods
1114    pub fn toggle_select(&mut self) {
1115        if let Some(daemon) = self.selected_daemon() {
1116            let id = daemon.id.clone();
1117            if self.multi_select.contains(&id) {
1118                self.multi_select.remove(&id);
1119            } else {
1120                self.multi_select.insert(id);
1121            }
1122        }
1123    }
1124
1125    pub fn select_all_visible(&mut self) {
1126        // Collect IDs first to avoid borrow conflict
1127        let ids: Vec<DaemonId> = self
1128            .filtered_daemons()
1129            .iter()
1130            .map(|d| d.id.clone())
1131            .collect();
1132        for id in ids {
1133            self.multi_select.insert(id);
1134        }
1135    }
1136
1137    pub fn clear_selection(&mut self) {
1138        self.multi_select.clear();
1139    }
1140
1141    pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1142        self.multi_select.contains(daemon_id)
1143    }
1144
1145    pub fn has_selection(&self) -> bool {
1146        !self.multi_select.is_empty()
1147    }
1148
1149    pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1150        self.multi_select.iter().cloned().collect()
1151    }
1152
1153    pub fn set_message(&mut self, msg: impl Into<String>) {
1154        self.message = Some(msg.into());
1155        self.message_time = Some(Instant::now());
1156    }
1157
1158    pub fn clear_stale_message(&mut self) {
1159        let duration = settings().tui_message_duration();
1160        if let Some(time) = self.message_time
1161            && time.elapsed() >= duration
1162        {
1163            self.message = None;
1164            self.message_time = None;
1165        }
1166    }
1167
1168    pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1169        self.process_stats.get(&pid)
1170    }
1171
1172    fn refresh_process_stats(&mut self) {
1173        PROCS.refresh_processes();
1174        self.process_stats.clear();
1175        for daemon in &self.daemons {
1176            if let Some(pid) = daemon.pid
1177                && let Some(stats) = PROCS.get_stats(pid)
1178            {
1179                self.process_stats.insert(pid, stats);
1180                // Record history for this daemon
1181                let history = self.stats_history.entry(daemon.id.clone()).or_default();
1182                history.push(StatsSnapshot::from(&stats));
1183            }
1184        }
1185    }
1186
1187    /// Get stats history for a daemon
1188    pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1189        self.stats_history.get(daemon_id)
1190    }
1191
1192    pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1193        use crate::daemon_list::get_all_daemons;
1194
1195        // Get all daemons using shared logic (includes both active and available)
1196        let all_entries = get_all_daemons(client).await?;
1197
1198        // Clear current lists
1199        self.daemons.clear();
1200        self.disabled.clear();
1201        self.config_daemon_ids.clear();
1202
1203        // Populate daemons list based on show_available setting
1204        for entry in all_entries {
1205            // entry.id is already a DaemonId (entry.daemon.id)
1206            let daemon_id = entry.daemon.id.clone();
1207
1208            // Track disabled daemons
1209            if entry.is_disabled {
1210                self.disabled.push(daemon_id.clone());
1211            }
1212
1213            // Track config-only daemons
1214            if entry.is_available {
1215                self.config_daemon_ids.insert(daemon_id.clone());
1216            }
1217
1218            // Add to daemons list if:
1219            // - It's an active daemon (not available), OR
1220            // - It's available and show_available is enabled
1221            if !entry.is_available || self.show_available {
1222                self.daemons.push(entry.daemon);
1223            }
1224        }
1225
1226        // Refresh process stats (CPU, memory, uptime)
1227        self.refresh_process_stats();
1228
1229        // Clear stale messages
1230        self.clear_stale_message();
1231
1232        // Keep selection in bounds
1233        let total_count = self.total_daemon_count();
1234        if total_count > 0 && self.selected >= total_count {
1235            self.selected = total_count - 1;
1236        }
1237
1238        // Refresh logs if viewing
1239        if self.view == View::Logs
1240            && let Some(id) = self.log_daemon_id.clone()
1241        {
1242            self.load_logs(&id);
1243        }
1244
1245        Ok(())
1246    }
1247
1248    /// Refresh network listeners data (for Network view)
1249    pub async fn refresh_network(&mut self) {
1250        // Use spawn_blocking since listeners::get_all() is a blocking operation
1251        let listeners: Vec<Listener> = tokio::task::spawn_blocking(|| {
1252            listeners::get_all()
1253                .map(|set| set.into_iter().collect::<Vec<_>>())
1254                .unwrap_or_default()
1255        })
1256        .await
1257        .unwrap_or_default();
1258
1259        self.network_listeners = listeners;
1260
1261        // Keep selection in bounds - first calculate the filtered count
1262        let filtered_count = self.filtered_network_listeners().len();
1263
1264        // Update selection index
1265        if filtered_count > 0 && self.network_selected >= filtered_count {
1266            self.network_selected = filtered_count - 1;
1267        } else if filtered_count == 0 {
1268            self.network_selected = 0;
1269        }
1270
1271        // Get a fresh filtered list and update the stored PID
1272        let selected_pid = self
1273            .filtered_network_listeners()
1274            .get(self.network_selected)
1275            .map(|l| l.process.pid);
1276        self.network_selected_pid = selected_pid;
1277    }
1278
1279    /// Get filtered network listeners based on search query
1280    pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1281        if self.network_search_query.is_empty() {
1282            return self.network_listeners.iter().collect();
1283        }
1284
1285        let matcher = SkimMatcherV2::default();
1286        let query = &self.network_search_query;
1287
1288        self.network_listeners
1289            .iter()
1290            .filter(|listener| {
1291                // Search by process name, PID, or port
1292                let search_text = format!(
1293                    "{} {} {}",
1294                    listener.process.name,
1295                    listener.process.pid,
1296                    listener.socket.port()
1297                );
1298                matcher.fuzzy_match(&search_text, query).is_some()
1299            })
1300            .collect()
1301    }
1302
1303    /// Toggle network search active state
1304    pub fn toggle_network_search(&mut self) {
1305        self.network_search_active = !self.network_search_active;
1306        if !self.network_search_active {
1307            self.network_search_query.clear();
1308        }
1309        // Reset scroll and selection when toggling search
1310        self.network_selected = 0;
1311        self.network_scroll_offset = 0;
1312        // Update PID for new selection
1313        let filtered = self.filtered_network_listeners();
1314        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1315    }
1316
1317    /// Clear network search
1318    pub fn clear_network_search(&mut self) {
1319        self.network_search_query.clear();
1320        self.network_search_active = false;
1321        // Reset scroll and selection
1322        self.network_selected = 0;
1323        self.network_scroll_offset = 0;
1324        // Update PID for new selection
1325        let filtered = self.filtered_network_listeners();
1326        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1327    }
1328
1329    /// Check if a daemon is from config only (not currently active)
1330    pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1331        self.config_daemon_ids.contains(daemon_id)
1332    }
1333
1334    /// Toggle showing available daemons from config
1335    pub fn toggle_show_available(&mut self) {
1336        self.show_available = !self.show_available;
1337    }
1338
1339    /// Get total daemon count (for selection bounds)
1340    fn total_daemon_count(&self) -> usize {
1341        self.filtered_daemons().len()
1342    }
1343
1344    pub fn scroll_logs_down(&mut self) {
1345        let max_scroll = self.log_content.len();
1346        self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1347    }
1348
1349    pub fn scroll_logs_up(&mut self) {
1350        self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1351    }
1352
1353    /// Scroll down by half page (Ctrl+D)
1354    pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1355        let half_page = visible_lines / 2;
1356        let max_scroll = self.log_content.len();
1357        self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1358    }
1359
1360    /// Scroll up by half page (Ctrl+U)
1361    pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1362        let half_page = visible_lines / 2;
1363        self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1364    }
1365
1366    // Log search
1367    pub fn start_log_search(&mut self) {
1368        self.log_search_active = true;
1369        self.log_search_query.clear();
1370        self.log_search_matches.clear();
1371        self.log_search_current = 0;
1372    }
1373
1374    pub fn end_log_search(&mut self) {
1375        self.log_search_active = false;
1376    }
1377
1378    pub fn clear_log_search(&mut self) {
1379        self.log_search_query.clear();
1380        self.log_search_active = false;
1381        self.log_search_matches.clear();
1382        self.log_search_current = 0;
1383    }
1384
1385    pub fn log_search_push(&mut self, c: char) {
1386        self.log_search_query.push(c);
1387        self.update_log_search_matches();
1388    }
1389
1390    pub fn log_search_pop(&mut self) {
1391        self.log_search_query.pop();
1392        self.update_log_search_matches();
1393    }
1394
1395    fn update_log_search_matches(&mut self) {
1396        self.log_search_matches.clear();
1397        if !self.log_search_query.is_empty() {
1398            let query = self.log_search_query.to_lowercase();
1399            for (i, line) in self.log_content.iter().enumerate() {
1400                if line.to_lowercase().contains(&query) {
1401                    self.log_search_matches.push(i);
1402                }
1403            }
1404            // Jump to first match if any
1405            if !self.log_search_matches.is_empty() {
1406                self.log_search_current = 0;
1407                self.jump_to_log_match();
1408            }
1409        }
1410    }
1411
1412    pub fn log_search_next(&mut self) {
1413        if !self.log_search_matches.is_empty() {
1414            self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1415            self.jump_to_log_match();
1416        }
1417    }
1418
1419    pub fn log_search_prev(&mut self) {
1420        if !self.log_search_matches.is_empty() {
1421            self.log_search_current = self
1422                .log_search_current
1423                .checked_sub(1)
1424                .unwrap_or(self.log_search_matches.len() - 1);
1425            self.jump_to_log_match();
1426        }
1427    }
1428
1429    fn jump_to_log_match(&mut self) {
1430        if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1431            // Scroll so the match is visible (center it if possible)
1432            let half_page = 10; // Assume ~20 visible lines
1433            self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1434            self.log_follow = false;
1435        }
1436    }
1437
1438    // Details view
1439    pub fn show_details(&mut self, daemon_id: &DaemonId) {
1440        self.details_daemon_id = Some(daemon_id.clone());
1441        self.prev_view = self.view;
1442        self.view = View::Details;
1443    }
1444
1445    pub fn hide_details(&mut self) {
1446        self.details_daemon_id = None;
1447        self.view = View::Dashboard;
1448    }
1449
1450    /// View daemon details (charts + logs)
1451    pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1452        self.log_daemon_id = Some(daemon_id.clone());
1453        self.logs_expanded = false; // Start with charts visible
1454        self.load_logs(daemon_id);
1455        self.view = View::Logs; // Logs view is now the full daemon details view
1456    }
1457
1458    fn load_logs(&mut self, daemon_id: &DaemonId) {
1459        let log_path = daemon_id.log_path();
1460        let prev_len = self.log_content.len();
1461
1462        self.log_content = if log_path.exists() {
1463            fs::read_to_string(&log_path)
1464                .unwrap_or_default()
1465                .lines()
1466                .map(String::from)
1467                .collect()
1468        } else {
1469            vec!["No logs available".to_string()]
1470        };
1471
1472        // Auto-scroll to bottom when in follow mode
1473        if self.log_follow {
1474            self.log_scroll = self.log_content.len().max(1);
1475        } else if prev_len == 0 {
1476            // First load - start at bottom
1477            self.log_scroll = self.log_content.len().max(1);
1478        }
1479        // If not following and not first load, keep scroll position
1480    }
1481
1482    pub fn show_help(&mut self) {
1483        self.view = View::Help;
1484    }
1485
1486    pub fn back_to_dashboard(&mut self) {
1487        self.view = View::Dashboard;
1488        self.log_daemon_id = None;
1489        self.log_content.clear();
1490        self.log_scroll = 1;
1491    }
1492
1493    /// Returns (total, running, stopped, errored, available)
1494    pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1495        let available = self.config_daemon_ids.len();
1496        let total = self.daemons.len();
1497        let running = self
1498            .daemons
1499            .iter()
1500            .filter(|d| d.status.is_running())
1501            .count();
1502        // Don't count config-only daemons as stopped
1503        let stopped = self
1504            .daemons
1505            .iter()
1506            .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1507            .count();
1508        let errored = self
1509            .daemons
1510            .iter()
1511            .filter(|d| d.status.is_errored() || d.status.is_failed())
1512            .count();
1513        (total, running, stopped, errored, available)
1514    }
1515
1516    pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1517        self.disabled.contains(daemon_id)
1518    }
1519
1520    // Config editor methods
1521
1522    /// Get list of available config file paths
1523    pub fn get_config_files(&self) -> Vec<PathBuf> {
1524        let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1525            .into_iter()
1526            .filter(|p| p.exists())
1527            .collect();
1528
1529        // Add option to create in current directory if not present
1530        let cwd_config = crate::env::CWD.join("pitchfork.toml");
1531        if !files.contains(&cwd_config) {
1532            files.push(cwd_config);
1533        }
1534
1535        files
1536    }
1537
1538    /// Open file selector for creating a new daemon
1539    pub fn open_file_selector(&mut self) {
1540        let files = self.get_config_files();
1541        self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1542        self.view = View::ConfigFileSelect;
1543    }
1544
1545    /// Open editor for a new daemon with the selected config file
1546    pub fn open_editor_create(&mut self, config_path: PathBuf) {
1547        self.editor_state = Some(EditorState::new_create(config_path));
1548        self.file_selector = None;
1549        self.view = View::ConfigEditor;
1550    }
1551
1552    /// Open editor for an existing daemon
1553    pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1554        let config = match PitchforkToml::all_merged() {
1555            Ok(config) => config,
1556            Err(e) => {
1557                self.set_message(format!("Failed to load config: {e}"));
1558                return;
1559            }
1560        };
1561        if let Some(daemon_config) = config.daemons.get(daemon_id) {
1562            let config_path = daemon_config
1563                .path
1564                .clone()
1565                .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1566            self.editor_state = Some(EditorState::new_edit(
1567                daemon_id.to_string(),
1568                daemon_config,
1569                config_path,
1570            ));
1571            self.view = View::ConfigEditor;
1572        } else {
1573            self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1574        }
1575    }
1576
1577    /// Close the editor and return to dashboard
1578    pub fn close_editor(&mut self) {
1579        self.editor_state = None;
1580        self.file_selector = None;
1581        self.view = View::Dashboard;
1582    }
1583
1584    /// Save the current editor state to config file.
1585    /// Returns Ok(true) if saved successfully, Ok(false) if validation/duplicate error (don't close editor).
1586    pub fn save_editor_config(&mut self) -> Result<bool> {
1587        let editor = self
1588            .editor_state
1589            .as_mut()
1590            .ok_or_else(|| miette::miette!("No editor state"))?;
1591
1592        // Validate
1593        if !editor.validate() {
1594            self.set_message("Please fix validation errors before saving");
1595            return Ok(false);
1596        }
1597
1598        // Build daemon config
1599        let daemon_config = editor.to_daemon_config();
1600
1601        // Parse daemon ID from string
1602        let daemon_id = DaemonId::parse(&editor.daemon_id)
1603            .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1604
1605        // Read existing config (or create new)
1606        let mut config = PitchforkToml::read(&editor.config_path)?;
1607
1608        // Check for duplicate daemon ID
1609        let is_duplicate = match &editor.mode {
1610            EditMode::Create => config.daemons.contains_key(&daemon_id),
1611            EditMode::Edit { original_id } => {
1612                // Only a duplicate if ID changed AND new ID already exists
1613                let original_daemon_id = DaemonId::parse(original_id)
1614                    .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1615                original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1616            }
1617        };
1618
1619        if is_duplicate {
1620            self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1621            return Ok(false);
1622        }
1623
1624        // Handle rename case
1625        if let EditMode::Edit { original_id } = &editor.mode {
1626            let original_daemon_id = DaemonId::parse(original_id)
1627                .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1628            if original_daemon_id != daemon_id {
1629                config.daemons.shift_remove(&original_daemon_id);
1630            }
1631        }
1632
1633        // Insert/update daemon
1634        config.daemons.insert(daemon_id, daemon_config);
1635
1636        // Write back
1637        config.write()?;
1638
1639        editor.unsaved_changes = false;
1640        let daemon_id = editor.daemon_id.clone();
1641        self.set_message(format!("Saved daemon '{daemon_id}'"));
1642
1643        Ok(true)
1644    }
1645
1646    /// Delete a daemon from the config file. Returns Ok(true) if deleted, Ok(false) if not found.
1647    pub fn delete_daemon_from_config(
1648        &mut self,
1649        id: &str,
1650        config_path: &std::path::Path,
1651    ) -> Result<bool> {
1652        let mut config = PitchforkToml::read(config_path)?;
1653
1654        // For qualified IDs, parse directly; for bare names derive the namespace
1655        // from the config file path so project-level daemons are found correctly.
1656        let daemon_id = if id.contains('/') {
1657            DaemonId::parse(id)?
1658        } else {
1659            let ns = namespace_from_path(config_path)?;
1660            DaemonId::try_new(&ns, id)?
1661        };
1662
1663        if config.daemons.shift_remove(&daemon_id).is_some() {
1664            config.write()?;
1665            Ok(true)
1666        } else {
1667            Ok(false)
1668        }
1669    }
1670}
1671
1672impl Default for App {
1673    fn default() -> Self {
1674        Self::new()
1675    }
1676}