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;
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}
935
936impl App {
937    pub fn new() -> Self {
938        Self {
939            daemons: Vec::new(),
940            disabled: Vec::new(),
941            selected: 0,
942            view: View::Dashboard,
943            prev_view: View::Dashboard,
944            log_content: Vec::new(),
945            log_daemon_id: None,
946            log_scroll: 1,
947            log_follow: true,
948            message: None,
949            message_time: None,
950            process_stats: HashMap::new(),
951            stats_history: HashMap::new(),
952            pending_action: None,
953            loading_text: None,
954            search_query: String::new(),
955            search_active: false,
956            sort_column: SortColumn::default(),
957            sort_order: SortOrder::default(),
958            log_search_query: String::new(),
959            log_search_active: false,
960            log_search_matches: Vec::new(),
961            log_search_current: 0,
962            details_daemon_id: None,
963            logs_expanded: false,
964            multi_select: HashSet::new(),
965            config_daemon_ids: HashSet::new(),
966            show_available: true, // Show available daemons by default
967            editor_state: None,
968            file_selector: None,
969            network_listeners: Vec::new(),
970            network_search_query: String::new(),
971            network_search_active: false,
972            network_selected: 0,
973            network_scroll_offset: 0,
974            network_selected_pid: None,
975            network_visible_rows: 20, // Default value, updated during draw
976        }
977    }
978
979    pub fn confirm_action(&mut self, action: PendingAction) {
980        self.pending_action = Some(action);
981        self.prev_view = self.view;
982        self.view = View::Confirm;
983    }
984
985    pub fn cancel_confirm(&mut self) {
986        self.pending_action = None;
987        self.view = self.prev_view;
988    }
989
990    pub fn take_pending_action(&mut self) -> Option<PendingAction> {
991        self.view = self.prev_view;
992        self.pending_action.take()
993    }
994
995    pub fn start_loading(&mut self, text: impl Into<String>) {
996        self.loading_text = Some(text.into());
997    }
998
999    pub fn stop_loading(&mut self) {
1000        self.loading_text = None;
1001    }
1002
1003    // Search functionality
1004    pub fn start_search(&mut self) {
1005        self.search_active = true;
1006    }
1007
1008    pub fn end_search(&mut self) {
1009        self.search_active = false;
1010    }
1011
1012    pub fn clear_search(&mut self) {
1013        self.search_query.clear();
1014        self.search_active = false;
1015        self.selected = 0;
1016    }
1017
1018    pub fn search_push(&mut self, c: char) {
1019        self.search_query.push(c);
1020        // Reset selection when search changes
1021        self.selected = 0;
1022    }
1023
1024    pub fn search_pop(&mut self) {
1025        self.search_query.pop();
1026        self.selected = 0;
1027    }
1028
1029    pub fn filtered_daemons(&self) -> Vec<&Daemon> {
1030        let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
1031            self.daemons.iter().collect()
1032        } else {
1033            // Use fuzzy matching with SkimMatcherV2
1034            let matcher = SkimMatcherV2::default();
1035            let mut scored: Vec<_> = self
1036                .daemons
1037                .iter()
1038                .filter_map(|d| {
1039                    matcher
1040                        .fuzzy_match(&d.id.qualified(), &self.search_query)
1041                        .map(|score| (d, score))
1042                })
1043                .collect();
1044            // Sort by score descending (best matches first)
1045            scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1046            scored.into_iter().map(|(d, _)| d).collect()
1047        };
1048
1049        // Sort the filtered list
1050        filtered.sort_by(|a, b| {
1051            let cmp = match self.sort_column {
1052                SortColumn::Name => {
1053                    a.id.to_string()
1054                        .to_lowercase()
1055                        .cmp(&b.id.to_string().to_lowercase())
1056                }
1057                SortColumn::Status => {
1058                    let status_order = |d: &Daemon| match &d.status {
1059                        crate::daemon_status::DaemonStatus::Running => 0,
1060                        crate::daemon_status::DaemonStatus::Waiting => 1,
1061                        crate::daemon_status::DaemonStatus::Stopping => 2,
1062                        crate::daemon_status::DaemonStatus::Stopped => 3,
1063                        crate::daemon_status::DaemonStatus::Errored(_) => 4,
1064                        crate::daemon_status::DaemonStatus::Failed(_) => 5,
1065                    };
1066                    status_order(a).cmp(&status_order(b))
1067                }
1068                SortColumn::Cpu => {
1069                    let cpu_a = a
1070                        .pid
1071                        .and_then(|p| self.get_stats(p))
1072                        .map(|s| s.cpu_percent)
1073                        .unwrap_or(0.0);
1074                    let cpu_b = b
1075                        .pid
1076                        .and_then(|p| self.get_stats(p))
1077                        .map(|s| s.cpu_percent)
1078                        .unwrap_or(0.0);
1079                    cpu_a
1080                        .partial_cmp(&cpu_b)
1081                        .unwrap_or(std::cmp::Ordering::Equal)
1082                }
1083                SortColumn::Memory => {
1084                    let mem_a = a
1085                        .pid
1086                        .and_then(|p| self.get_stats(p))
1087                        .map(|s| s.memory_bytes)
1088                        .unwrap_or(0);
1089                    let mem_b = b
1090                        .pid
1091                        .and_then(|p| self.get_stats(p))
1092                        .map(|s| s.memory_bytes)
1093                        .unwrap_or(0);
1094                    mem_a.cmp(&mem_b)
1095                }
1096                SortColumn::Uptime => {
1097                    let up_a = a
1098                        .pid
1099                        .and_then(|p| self.get_stats(p))
1100                        .map(|s| s.uptime_secs)
1101                        .unwrap_or(0);
1102                    let up_b = b
1103                        .pid
1104                        .and_then(|p| self.get_stats(p))
1105                        .map(|s| s.uptime_secs)
1106                        .unwrap_or(0);
1107                    up_a.cmp(&up_b)
1108                }
1109            };
1110            match self.sort_order {
1111                SortOrder::Ascending => cmp,
1112                SortOrder::Descending => cmp.reverse(),
1113            }
1114        });
1115
1116        filtered
1117    }
1118
1119    // Sorting
1120    pub fn cycle_sort(&mut self) {
1121        // If clicking the same column, toggle order; otherwise switch column
1122        self.sort_column = self.sort_column.next();
1123        self.selected = 0;
1124    }
1125
1126    pub fn toggle_sort_order(&mut self) {
1127        self.sort_order = self.sort_order.toggle();
1128        self.selected = 0;
1129    }
1130
1131    pub fn selected_daemon(&self) -> Option<&Daemon> {
1132        let filtered = self.filtered_daemons();
1133        filtered.get(self.selected).copied()
1134    }
1135
1136    pub fn select_next(&mut self) {
1137        let count = self.filtered_daemons().len();
1138        if count > 0 {
1139            self.selected = (self.selected + 1) % count;
1140        }
1141    }
1142
1143    pub fn select_prev(&mut self) {
1144        let count = self.filtered_daemons().len();
1145        if count > 0 {
1146            self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1147        }
1148    }
1149
1150    // Log follow mode
1151    pub fn toggle_log_follow(&mut self) {
1152        self.log_follow = !self.log_follow;
1153        if self.log_follow && !self.log_content.is_empty() {
1154            // Jump to bottom when enabling follow
1155            self.log_scroll = self.log_content.len();
1156        }
1157    }
1158
1159    // Toggle logs expanded (hide/show charts)
1160    pub fn toggle_logs_expanded(&mut self) {
1161        self.logs_expanded = !self.logs_expanded;
1162    }
1163
1164    // Multi-select methods
1165    pub fn toggle_select(&mut self) {
1166        if let Some(daemon) = self.selected_daemon() {
1167            let id = daemon.id.clone();
1168            if self.multi_select.contains(&id) {
1169                self.multi_select.remove(&id);
1170            } else {
1171                self.multi_select.insert(id);
1172            }
1173        }
1174    }
1175
1176    pub fn select_all_visible(&mut self) {
1177        // Collect IDs first to avoid borrow conflict
1178        let ids: Vec<DaemonId> = self
1179            .filtered_daemons()
1180            .iter()
1181            .map(|d| d.id.clone())
1182            .collect();
1183        for id in ids {
1184            self.multi_select.insert(id);
1185        }
1186    }
1187
1188    pub fn clear_selection(&mut self) {
1189        self.multi_select.clear();
1190    }
1191
1192    pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1193        self.multi_select.contains(daemon_id)
1194    }
1195
1196    pub fn has_selection(&self) -> bool {
1197        !self.multi_select.is_empty()
1198    }
1199
1200    pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1201        self.multi_select.iter().cloned().collect()
1202    }
1203
1204    pub fn set_message(&mut self, msg: impl Into<String>) {
1205        self.message = Some(msg.into());
1206        self.message_time = Some(Instant::now());
1207    }
1208
1209    pub fn clear_stale_message(&mut self) {
1210        let duration = settings().tui_message_duration();
1211        if let Some(time) = self.message_time
1212            && time.elapsed() >= duration
1213        {
1214            self.message = None;
1215            self.message_time = None;
1216        }
1217    }
1218
1219    pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1220        self.process_stats.get(&pid)
1221    }
1222
1223    fn refresh_process_stats(&mut self) {
1224        let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1225        if !pids.is_empty() {
1226            PROCS.refresh_processes();
1227        }
1228        self.process_stats.clear();
1229
1230        let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1231
1232        for daemon in &self.daemons {
1233            if let Some(pid) = daemon.pid
1234                && let Some(stats) = stats_by_pid.get(&pid).copied()
1235            {
1236                self.process_stats.insert(pid, stats);
1237                // Record history for this daemon
1238                let history = self.stats_history.entry(daemon.id.clone()).or_default();
1239                history.push(StatsSnapshot::from(&stats));
1240            }
1241        }
1242    }
1243
1244    /// Get stats history for a daemon
1245    pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1246        self.stats_history.get(daemon_id)
1247    }
1248
1249    /// Fetch fresh daemon data from IPC. Called from a background task.
1250    pub async fn fetch_daemon_data(client: &Arc<IpcClient>) -> Result<Vec<DaemonListEntry>> {
1251        use crate::daemon_list::get_all_daemons;
1252        get_all_daemons(client).await
1253    }
1254
1255    /// Apply previously fetched daemon data to update app state.
1256    pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1257        // Clear current lists
1258        self.daemons.clear();
1259        self.disabled.clear();
1260        self.config_daemon_ids.clear();
1261
1262        for entry in all_entries {
1263            let daemon_id = entry.daemon.id.clone();
1264
1265            if entry.is_disabled {
1266                self.disabled.push(daemon_id.clone());
1267            }
1268
1269            if entry.is_available {
1270                self.config_daemon_ids.insert(daemon_id.clone());
1271            }
1272
1273            if !entry.is_available || self.show_available {
1274                self.daemons.push(entry.daemon);
1275            }
1276        }
1277
1278        self.refresh_process_stats();
1279        self.clear_stale_message();
1280
1281        let total_count = self.total_daemon_count();
1282        if total_count > 0 && self.selected >= total_count {
1283            self.selected = total_count - 1;
1284        }
1285
1286        if self.view == View::Logs
1287            && let Some(id) = self.log_daemon_id.clone()
1288        {
1289            self.load_logs(&id);
1290        }
1291    }
1292
1293    pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1294        let entries = Self::fetch_daemon_data(client).await?;
1295        self.apply_refresh(entries);
1296        Ok(())
1297    }
1298
1299    /// Apply network listener data fetched by a background task.
1300    pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1301        self.network_listeners = listeners;
1302
1303        let filtered_count = self.filtered_network_listeners().len();
1304
1305        if filtered_count > 0 && self.network_selected >= filtered_count {
1306            self.network_selected = filtered_count - 1;
1307        } else if filtered_count == 0 {
1308            self.network_selected = 0;
1309        }
1310
1311        let selected_pid = self
1312            .filtered_network_listeners()
1313            .get(self.network_selected)
1314            .map(|l| l.process.pid);
1315        self.network_selected_pid = selected_pid;
1316    }
1317
1318    /// Get filtered network listeners based on search query
1319    pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1320        if self.network_search_query.is_empty() {
1321            return self.network_listeners.iter().collect();
1322        }
1323
1324        let matcher = SkimMatcherV2::default();
1325        let query = &self.network_search_query;
1326
1327        self.network_listeners
1328            .iter()
1329            .filter(|listener| {
1330                // Search by process name, PID, or port
1331                let search_text = format!(
1332                    "{} {} {}",
1333                    listener.process.name,
1334                    listener.process.pid,
1335                    listener.socket.port()
1336                );
1337                matcher.fuzzy_match(&search_text, query).is_some()
1338            })
1339            .collect()
1340    }
1341
1342    /// Toggle network search active state
1343    pub fn toggle_network_search(&mut self) {
1344        self.network_search_active = !self.network_search_active;
1345        if !self.network_search_active {
1346            self.network_search_query.clear();
1347        }
1348        // Reset scroll and selection when toggling search
1349        self.network_selected = 0;
1350        self.network_scroll_offset = 0;
1351        // Update PID for new selection
1352        let filtered = self.filtered_network_listeners();
1353        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1354    }
1355
1356    /// Clear network search
1357    pub fn clear_network_search(&mut self) {
1358        self.network_search_query.clear();
1359        self.network_search_active = false;
1360        // Reset scroll and selection
1361        self.network_selected = 0;
1362        self.network_scroll_offset = 0;
1363        // Update PID for new selection
1364        let filtered = self.filtered_network_listeners();
1365        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1366    }
1367
1368    /// Check if a daemon is from config only (not currently active)
1369    pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1370        self.config_daemon_ids.contains(daemon_id)
1371    }
1372
1373    /// Toggle showing available daemons from config
1374    pub fn toggle_show_available(&mut self) {
1375        self.show_available = !self.show_available;
1376    }
1377
1378    /// Get total daemon count (for selection bounds)
1379    fn total_daemon_count(&self) -> usize {
1380        self.filtered_daemons().len()
1381    }
1382
1383    pub fn scroll_logs_down(&mut self) {
1384        let max_scroll = self.log_content.len();
1385        self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1386    }
1387
1388    pub fn scroll_logs_up(&mut self) {
1389        self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1390    }
1391
1392    /// Scroll down by half page (Ctrl+D)
1393    pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1394        let half_page = visible_lines / 2;
1395        let max_scroll = self.log_content.len();
1396        self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1397    }
1398
1399    /// Scroll up by half page (Ctrl+U)
1400    pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1401        let half_page = visible_lines / 2;
1402        self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1403    }
1404
1405    // Log search
1406    pub fn start_log_search(&mut self) {
1407        self.log_search_active = true;
1408        self.log_search_query.clear();
1409        self.log_search_matches.clear();
1410        self.log_search_current = 0;
1411    }
1412
1413    pub fn end_log_search(&mut self) {
1414        self.log_search_active = false;
1415    }
1416
1417    pub fn clear_log_search(&mut self) {
1418        self.log_search_query.clear();
1419        self.log_search_active = false;
1420        self.log_search_matches.clear();
1421        self.log_search_current = 0;
1422    }
1423
1424    pub fn log_search_push(&mut self, c: char) {
1425        self.log_search_query.push(c);
1426        self.update_log_search_matches();
1427    }
1428
1429    pub fn log_search_pop(&mut self) {
1430        self.log_search_query.pop();
1431        self.update_log_search_matches();
1432    }
1433
1434    fn update_log_search_matches(&mut self) {
1435        self.log_search_matches.clear();
1436        if !self.log_search_query.is_empty() {
1437            let query = self.log_search_query.to_lowercase();
1438            for (i, line) in self.log_content.iter().enumerate() {
1439                if line.to_lowercase().contains(&query) {
1440                    self.log_search_matches.push(i);
1441                }
1442            }
1443            // Jump to first match if any
1444            if !self.log_search_matches.is_empty() {
1445                self.log_search_current = 0;
1446                self.jump_to_log_match();
1447            }
1448        }
1449    }
1450
1451    pub fn log_search_next(&mut self) {
1452        if !self.log_search_matches.is_empty() {
1453            self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1454            self.jump_to_log_match();
1455        }
1456    }
1457
1458    pub fn log_search_prev(&mut self) {
1459        if !self.log_search_matches.is_empty() {
1460            self.log_search_current = self
1461                .log_search_current
1462                .checked_sub(1)
1463                .unwrap_or(self.log_search_matches.len() - 1);
1464            self.jump_to_log_match();
1465        }
1466    }
1467
1468    fn jump_to_log_match(&mut self) {
1469        if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1470            // Scroll so the match is visible (center it if possible)
1471            let half_page = 10; // Assume ~20 visible lines
1472            self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1473            self.log_follow = false;
1474        }
1475    }
1476
1477    // Details view
1478    pub fn show_details(&mut self, daemon_id: &DaemonId) {
1479        self.details_daemon_id = Some(daemon_id.clone());
1480        self.prev_view = self.view;
1481        self.view = View::Details;
1482    }
1483
1484    pub fn hide_details(&mut self) {
1485        self.details_daemon_id = None;
1486        self.view = View::Dashboard;
1487    }
1488
1489    /// View daemon details (charts + logs)
1490    pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1491        self.log_daemon_id = Some(daemon_id.clone());
1492        self.logs_expanded = false; // Start with charts visible
1493        self.load_logs(daemon_id);
1494        self.view = View::Logs; // Logs view is now the full daemon details view
1495    }
1496
1497    fn load_logs(&mut self, daemon_id: &DaemonId) {
1498        const TUI_LOG_LIMIT: usize = 5000;
1499        let prev_len = self.log_content.len();
1500
1501        self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1502            daemon_ids: vec![daemon_id.qualified()],
1503            from: None,
1504            to: None,
1505            limit: Some(TUI_LOG_LIMIT),
1506            order_desc: true,
1507            after_id: None,
1508            message_filters: Vec::new(),
1509            field_filters: Vec::new(),
1510            include_structured: false,
1511        }) {
1512            Ok(entries) if !entries.is_empty() => {
1513                let mut lines: Vec<String> = entries
1514                    .into_iter()
1515                    .map(|e| {
1516                        let ts = e.timestamp.format("%H:%M:%S").to_string();
1517                        format!("{} {}", ts, e.message)
1518                    })
1519                    .collect();
1520                lines.reverse();
1521                lines
1522            }
1523            _ => vec!["No logs available".to_string()],
1524        };
1525
1526        // Auto-scroll to bottom when in follow mode
1527        if self.log_follow {
1528            self.log_scroll = self.log_content.len().max(1);
1529        } else if prev_len == 0 {
1530            // First load - start at bottom
1531            self.log_scroll = self.log_content.len().max(1);
1532        }
1533        // If not following and not first load, keep scroll position
1534    }
1535
1536    pub fn show_help(&mut self) {
1537        self.view = View::Help;
1538    }
1539
1540    pub fn back_to_dashboard(&mut self) {
1541        self.view = View::Dashboard;
1542        self.log_daemon_id = None;
1543        self.log_content.clear();
1544        self.log_scroll = 1;
1545    }
1546
1547    /// Returns (total, running, stopped, errored, available)
1548    pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1549        let available = self.config_daemon_ids.len();
1550        let total = self.daemons.len();
1551        let running = self
1552            .daemons
1553            .iter()
1554            .filter(|d| d.status.is_running())
1555            .count();
1556        // Don't count config-only daemons as stopped
1557        let stopped = self
1558            .daemons
1559            .iter()
1560            .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1561            .count();
1562        let errored = self
1563            .daemons
1564            .iter()
1565            .filter(|d| d.status.is_errored() || d.status.is_failed())
1566            .count();
1567        (total, running, stopped, errored, available)
1568    }
1569
1570    pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1571        self.disabled.contains(daemon_id)
1572    }
1573
1574    // Config editor methods
1575
1576    /// Get list of available config file paths
1577    pub fn get_config_files(&self) -> Vec<PathBuf> {
1578        let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1579            .into_iter()
1580            .filter(|p| p.exists())
1581            .collect();
1582
1583        // Add option to create in current directory if not present
1584        let cwd_config = crate::env::CWD.join("pitchfork.toml");
1585        if !files.contains(&cwd_config) {
1586            files.push(cwd_config);
1587        }
1588
1589        files
1590    }
1591
1592    /// Open file selector for creating a new daemon
1593    pub fn open_file_selector(&mut self) {
1594        let files = self.get_config_files();
1595        self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1596        self.view = View::ConfigFileSelect;
1597    }
1598
1599    /// Open editor for a new daemon with the selected config file
1600    pub fn open_editor_create(&mut self, config_path: PathBuf) {
1601        self.editor_state = Some(EditorState::new_create(config_path));
1602        self.file_selector = None;
1603        self.view = View::ConfigEditor;
1604    }
1605
1606    /// Open editor for an existing daemon
1607    pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1608        let config = match PitchforkToml::all_merged() {
1609            Ok(config) => config,
1610            Err(e) => {
1611                self.set_message(format!("Failed to load config: {e}"));
1612                return;
1613            }
1614        };
1615        if let Some(daemon_config) = config.daemons.get(daemon_id) {
1616            let config_path = daemon_config
1617                .path
1618                .clone()
1619                .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1620            self.editor_state = Some(EditorState::new_edit(
1621                daemon_id.to_string(),
1622                daemon_config,
1623                config_path,
1624            ));
1625            self.view = View::ConfigEditor;
1626        } else {
1627            self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1628        }
1629    }
1630
1631    /// Close the editor and return to dashboard
1632    pub fn close_editor(&mut self) {
1633        self.editor_state = None;
1634        self.file_selector = None;
1635        self.view = View::Dashboard;
1636    }
1637
1638    /// Save the current editor state to config file.
1639    /// Returns Ok(true) if saved successfully, Ok(false) if validation/duplicate error (don't close editor).
1640    pub fn save_editor_config(&mut self) -> Result<bool> {
1641        let editor = self
1642            .editor_state
1643            .as_mut()
1644            .ok_or_else(|| miette::miette!("No editor state"))?;
1645
1646        // Validate
1647        if !editor.validate() {
1648            self.set_message("Please fix validation errors before saving");
1649            return Ok(false);
1650        }
1651
1652        // Build daemon config
1653        let daemon_config = editor.to_daemon_config();
1654
1655        // Parse daemon ID from string
1656        let daemon_id = DaemonId::parse(&editor.daemon_id)
1657            .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1658
1659        // Read existing config (or create new)
1660        let mut config = PitchforkToml::read(&editor.config_path)?;
1661
1662        // Check for duplicate daemon ID
1663        let is_duplicate = match &editor.mode {
1664            EditMode::Create => config.daemons.contains_key(&daemon_id),
1665            EditMode::Edit { original_id } => {
1666                // Only a duplicate if ID changed AND new ID already exists
1667                let original_daemon_id = DaemonId::parse(original_id)
1668                    .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1669                original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1670            }
1671        };
1672
1673        if is_duplicate {
1674            self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1675            return Ok(false);
1676        }
1677
1678        // Handle rename case
1679        if let EditMode::Edit { original_id } = &editor.mode {
1680            let original_daemon_id = DaemonId::parse(original_id)
1681                .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1682            if original_daemon_id != daemon_id {
1683                config.daemons.shift_remove(&original_daemon_id);
1684            }
1685        }
1686
1687        // Insert/update daemon
1688        config.daemons.insert(daemon_id, daemon_config);
1689
1690        // Write back
1691        config.write()?;
1692
1693        editor.unsaved_changes = false;
1694        let daemon_id = editor.daemon_id.clone();
1695        self.set_message(format!("Saved daemon '{daemon_id}'"));
1696
1697        Ok(true)
1698    }
1699
1700    /// Delete a daemon from the config file. Returns Ok(true) if deleted, Ok(false) if not found.
1701    pub fn delete_daemon_from_config(
1702        &mut self,
1703        id: &str,
1704        config_path: &std::path::Path,
1705    ) -> Result<bool> {
1706        let mut config = PitchforkToml::read(config_path)?;
1707
1708        // For qualified IDs, parse directly; for bare names derive the namespace
1709        // from the config file path so project-level daemons are found correctly.
1710        let daemon_id = if id.contains('/') {
1711            DaemonId::parse(id)?
1712        } else {
1713            let ns = namespace_from_path(config_path)?;
1714            DaemonId::try_new(&ns, id)?
1715        };
1716
1717        if config.daemons.shift_remove(&daemon_id).is_some() {
1718            config.write()?;
1719            Ok(true)
1720        } else {
1721            Ok(false)
1722        }
1723    }
1724}
1725
1726impl Default for App {
1727    fn default() -> Self {
1728        Self::new()
1729    }
1730}