pitchfork_cli/tui/
app.rs

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