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