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