Skip to main content

pitchfork_cli/tui/
app.rs

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