Skip to main content

pitchfork_cli/tui/
app.rs

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