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