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