Skip to main content

pitchfork_cli/tui/
app.rs

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