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
22fn 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#[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#[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#[derive(Debug, Clone, PartialEq)]
96pub enum EditMode {
97 Create,
98 Edit { original_id: String },
99}
100
101#[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#[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 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#[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_ready_cmd: Option<ReadyCmd>,
352 preserved_ready_http_status: Option<Vec<u16>>,
354 preserved_ready_http_timeout: Option<std::time::Duration>,
356 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 if let Some(field) = self.fields.get_mut(self.focused_field) {
627 field.editing = false;
628 }
629
630 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 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 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 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 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 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 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 for field in &mut self.fields {
793 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#[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 BatchStop(Vec<DaemonId>),
834 BatchRestart(Vec<DaemonId>),
835 BatchDisable(Vec<DaemonId>),
836 Clean(Vec<DaemonId>),
837 DeleteDaemon { id: String, config_path: PathBuf },
839 DiscardEditorChanges,
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
843pub enum SortColumn {
844 #[default]
845 Name,
846 Status,
847 Cpu,
848 Memory,
849 Uptime,
850}
851
852impl SortColumn {
853 pub fn next(self) -> Self {
854 match self {
855 Self::Name => Self::Status,
856 Self::Status => Self::Cpu,
857 Self::Cpu => Self::Memory,
858 Self::Memory => Self::Uptime,
859 Self::Uptime => Self::Name,
860 }
861 }
862}
863
864#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
865pub enum SortOrder {
866 #[default]
867 Ascending,
868 Descending,
869}
870
871impl SortOrder {
872 pub fn toggle(self) -> Self {
873 match self {
874 Self::Ascending => Self::Descending,
875 Self::Descending => Self::Ascending,
876 }
877 }
878
879 pub fn indicator(self) -> &'static str {
880 match self {
881 Self::Ascending => "↑",
882 Self::Descending => "↓",
883 }
884 }
885}
886
887pub struct App {
888 pub daemons: Vec<Daemon>,
889 pub disabled: Vec<DaemonId>,
890 pub selected: usize,
891 pub view: View,
892 pub prev_view: View,
893 pub log_content: Vec<String>,
894 pub log_daemon_id: Option<DaemonId>,
895 pub log_scroll: usize,
896 pub log_follow: bool, pub message: Option<String>,
898 pub message_time: Option<Instant>,
899 pub process_stats: HashMap<u32, ProcessStats>, pub stats_history: HashMap<DaemonId, StatsHistory>, pub pending_action: Option<PendingAction>,
902 pub loading_text: Option<String>,
903 pub search_query: String,
904 pub search_active: bool,
905 pub sort_column: SortColumn,
907 pub sort_order: SortOrder,
908 pub log_search_query: String,
910 pub log_search_active: bool,
911 pub log_search_matches: Vec<usize>, pub log_search_current: usize, pub details_daemon_id: Option<DaemonId>,
915 pub logs_expanded: bool,
917 pub multi_select: HashSet<DaemonId>,
919 pub config_daemon_ids: HashSet<DaemonId>,
921 pub show_available: bool,
923 pub editor_state: Option<EditorState>,
925 pub file_selector: Option<ConfigFileSelector>,
927 pub network_listeners: Vec<Listener>,
929 pub network_search_query: String,
930 pub network_search_active: bool,
931 pub network_selected: usize,
932 pub network_scroll_offset: usize,
933 pub network_selected_pid: Option<u32>, pub network_visible_rows: usize, pub namespace_filter: NamespaceFilter,
938}
939
940impl App {
941 pub fn new(namespace_filter: NamespaceFilter) -> Self {
942 Self {
943 namespace_filter,
944 daemons: Vec::new(),
945 disabled: Vec::new(),
946 selected: 0,
947 view: View::Dashboard,
948 prev_view: View::Dashboard,
949 log_content: Vec::new(),
950 log_daemon_id: None,
951 log_scroll: 1,
952 log_follow: true,
953 message: None,
954 message_time: None,
955 process_stats: HashMap::new(),
956 stats_history: HashMap::new(),
957 pending_action: None,
958 loading_text: None,
959 search_query: String::new(),
960 search_active: false,
961 sort_column: SortColumn::default(),
962 sort_order: SortOrder::default(),
963 log_search_query: String::new(),
964 log_search_active: false,
965 log_search_matches: Vec::new(),
966 log_search_current: 0,
967 details_daemon_id: None,
968 logs_expanded: false,
969 multi_select: HashSet::new(),
970 config_daemon_ids: HashSet::new(),
971 show_available: true, editor_state: None,
973 file_selector: None,
974 network_listeners: Vec::new(),
975 network_search_query: String::new(),
976 network_search_active: false,
977 network_selected: 0,
978 network_scroll_offset: 0,
979 network_selected_pid: None,
980 network_visible_rows: 20, }
982 }
983
984 pub fn confirm_action(&mut self, action: PendingAction) {
985 self.pending_action = Some(action);
986 self.prev_view = self.view;
987 self.view = View::Confirm;
988 }
989
990 pub fn cancel_confirm(&mut self) {
991 self.pending_action = None;
992 self.view = self.prev_view;
993 }
994
995 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
996 self.view = self.prev_view;
997 self.pending_action.take()
998 }
999
1000 pub fn start_loading(&mut self, text: impl Into<String>) {
1001 self.loading_text = Some(text.into());
1002 }
1003
1004 pub fn stop_loading(&mut self) {
1005 self.loading_text = None;
1006 }
1007
1008 pub fn start_search(&mut self) {
1010 self.search_active = true;
1011 }
1012
1013 pub fn end_search(&mut self) {
1014 self.search_active = false;
1015 }
1016
1017 pub fn clear_search(&mut self) {
1018 self.search_query.clear();
1019 self.search_active = false;
1020 self.selected = 0;
1021 }
1022
1023 pub fn search_push(&mut self, c: char) {
1024 self.search_query.push(c);
1025 self.selected = 0;
1027 }
1028
1029 pub fn search_pop(&mut self) {
1030 self.search_query.pop();
1031 self.selected = 0;
1032 }
1033
1034 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
1035 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
1036 self.daemons.iter().collect()
1037 } else {
1038 let matcher = SkimMatcherV2::default();
1040 let mut scored: Vec<_> = self
1041 .daemons
1042 .iter()
1043 .filter_map(|d| {
1044 matcher
1045 .fuzzy_match(&d.id.qualified(), &self.search_query)
1046 .map(|score| (d, score))
1047 })
1048 .collect();
1049 scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1051 scored.into_iter().map(|(d, _)| d).collect()
1052 };
1053
1054 filtered.sort_by(|a, b| {
1056 let cmp = match self.sort_column {
1057 SortColumn::Name => {
1058 a.id.to_string()
1059 .to_lowercase()
1060 .cmp(&b.id.to_string().to_lowercase())
1061 }
1062 SortColumn::Status => {
1063 let status_order = |d: &Daemon| match &d.status {
1064 crate::daemon_status::DaemonStatus::Running => 0,
1065 crate::daemon_status::DaemonStatus::Waiting => 1,
1066 crate::daemon_status::DaemonStatus::Stopping => 2,
1067 crate::daemon_status::DaemonStatus::Stopped => 3,
1068 crate::daemon_status::DaemonStatus::Errored(_) => 4,
1069 crate::daemon_status::DaemonStatus::Failed(_) => 5,
1070 };
1071 status_order(a).cmp(&status_order(b))
1072 }
1073 SortColumn::Cpu => {
1074 let cpu_a = a
1075 .pid
1076 .and_then(|p| self.get_stats(p))
1077 .map(|s| s.cpu_percent)
1078 .unwrap_or(0.0);
1079 let cpu_b = b
1080 .pid
1081 .and_then(|p| self.get_stats(p))
1082 .map(|s| s.cpu_percent)
1083 .unwrap_or(0.0);
1084 cpu_a
1085 .partial_cmp(&cpu_b)
1086 .unwrap_or(std::cmp::Ordering::Equal)
1087 }
1088 SortColumn::Memory => {
1089 let mem_a = a
1090 .pid
1091 .and_then(|p| self.get_stats(p))
1092 .map(|s| s.memory_bytes)
1093 .unwrap_or(0);
1094 let mem_b = b
1095 .pid
1096 .and_then(|p| self.get_stats(p))
1097 .map(|s| s.memory_bytes)
1098 .unwrap_or(0);
1099 mem_a.cmp(&mem_b)
1100 }
1101 SortColumn::Uptime => {
1102 let up_a = a
1103 .pid
1104 .and_then(|p| self.get_stats(p))
1105 .map(|s| s.uptime_secs)
1106 .unwrap_or(0);
1107 let up_b = b
1108 .pid
1109 .and_then(|p| self.get_stats(p))
1110 .map(|s| s.uptime_secs)
1111 .unwrap_or(0);
1112 up_a.cmp(&up_b)
1113 }
1114 };
1115 match self.sort_order {
1116 SortOrder::Ascending => cmp,
1117 SortOrder::Descending => cmp.reverse(),
1118 }
1119 });
1120
1121 filtered
1122 }
1123
1124 pub fn cycle_sort(&mut self) {
1126 self.sort_column = self.sort_column.next();
1128 self.selected = 0;
1129 }
1130
1131 pub fn toggle_sort_order(&mut self) {
1132 self.sort_order = self.sort_order.toggle();
1133 self.selected = 0;
1134 }
1135
1136 pub fn selected_daemon(&self) -> Option<&Daemon> {
1137 let filtered = self.filtered_daemons();
1138 filtered.get(self.selected).copied()
1139 }
1140
1141 pub fn select_next(&mut self) {
1142 let count = self.filtered_daemons().len();
1143 if count > 0 {
1144 self.selected = (self.selected + 1) % count;
1145 }
1146 }
1147
1148 pub fn select_prev(&mut self) {
1149 let count = self.filtered_daemons().len();
1150 if count > 0 {
1151 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1152 }
1153 }
1154
1155 pub fn toggle_log_follow(&mut self) {
1157 self.log_follow = !self.log_follow;
1158 if self.log_follow && !self.log_content.is_empty() {
1159 self.log_scroll = self.log_content.len();
1161 }
1162 }
1163
1164 pub fn toggle_logs_expanded(&mut self) {
1166 self.logs_expanded = !self.logs_expanded;
1167 }
1168
1169 pub fn toggle_select(&mut self) {
1171 if let Some(daemon) = self.selected_daemon() {
1172 let id = daemon.id.clone();
1173 if self.multi_select.contains(&id) {
1174 self.multi_select.remove(&id);
1175 } else {
1176 self.multi_select.insert(id);
1177 }
1178 }
1179 }
1180
1181 pub fn select_all_visible(&mut self) {
1182 let ids: Vec<DaemonId> = self
1184 .filtered_daemons()
1185 .iter()
1186 .map(|d| d.id.clone())
1187 .collect();
1188 for id in ids {
1189 self.multi_select.insert(id);
1190 }
1191 }
1192
1193 pub fn clear_selection(&mut self) {
1194 self.multi_select.clear();
1195 }
1196
1197 pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1198 self.multi_select.contains(daemon_id)
1199 }
1200
1201 pub fn has_selection(&self) -> bool {
1202 !self.multi_select.is_empty()
1203 }
1204
1205 pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1206 self.multi_select.iter().cloned().collect()
1207 }
1208
1209 pub fn set_message(&mut self, msg: impl Into<String>) {
1210 self.message = Some(msg.into());
1211 self.message_time = Some(Instant::now());
1212 }
1213
1214 pub fn clear_stale_message(&mut self) {
1215 let duration = settings().tui_message_duration();
1216 if let Some(time) = self.message_time
1217 && time.elapsed() >= duration
1218 {
1219 self.message = None;
1220 self.message_time = None;
1221 }
1222 }
1223
1224 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1225 self.process_stats.get(&pid)
1226 }
1227
1228 fn refresh_process_stats(&mut self) {
1229 let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1230 if !pids.is_empty() {
1231 PROCS.refresh_if_stale();
1232 }
1233 self.process_stats.clear();
1234
1235 let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1236
1237 for daemon in &self.daemons {
1238 if let Some(pid) = daemon.pid
1239 && let Some(stats) = stats_by_pid.get(&pid).copied()
1240 {
1241 self.process_stats.insert(pid, stats);
1242 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1244 history.push(StatsSnapshot::from(&stats));
1245 }
1246 }
1247 }
1248
1249 pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1251 self.stats_history.get(daemon_id)
1252 }
1253
1254 pub async fn fetch_daemon_data(
1256 client: &Arc<IpcClient>,
1257 namespace_filter: &NamespaceFilter,
1258 ) -> Result<Vec<DaemonListEntry>> {
1259 use crate::daemon_list::get_all_daemons;
1260 get_all_daemons(client, namespace_filter).await
1261 }
1262
1263 pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1265 self.daemons.clear();
1267 self.disabled.clear();
1268 self.config_daemon_ids.clear();
1269
1270 for entry in all_entries {
1271 let daemon_id = entry.daemon.id.clone();
1272
1273 if entry.is_disabled {
1274 self.disabled.push(daemon_id.clone());
1275 }
1276
1277 if entry.is_available {
1278 self.config_daemon_ids.insert(daemon_id.clone());
1279 }
1280
1281 if !entry.is_available || self.show_available {
1282 self.daemons.push(entry.daemon);
1283 }
1284 }
1285
1286 self.refresh_process_stats();
1287 self.clear_stale_message();
1288
1289 let total_count = self.total_daemon_count();
1290 if total_count > 0 && self.selected >= total_count {
1291 self.selected = total_count - 1;
1292 }
1293
1294 if self.view == View::Logs
1295 && let Some(id) = self.log_daemon_id.clone()
1296 {
1297 self.load_logs(&id);
1298 }
1299 }
1300
1301 pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1302 let entries = Self::fetch_daemon_data(client, &self.namespace_filter).await?;
1303 self.apply_refresh(entries);
1304 Ok(())
1305 }
1306
1307 pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1309 self.network_listeners = listeners;
1310
1311 let filtered_count = self.filtered_network_listeners().len();
1312
1313 if filtered_count > 0 && self.network_selected >= filtered_count {
1314 self.network_selected = filtered_count - 1;
1315 } else if filtered_count == 0 {
1316 self.network_selected = 0;
1317 }
1318
1319 let selected_pid = self
1320 .filtered_network_listeners()
1321 .get(self.network_selected)
1322 .map(|l| l.process.pid);
1323 self.network_selected_pid = selected_pid;
1324 }
1325
1326 pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1328 if self.network_search_query.is_empty() {
1329 return self.network_listeners.iter().collect();
1330 }
1331
1332 let matcher = SkimMatcherV2::default();
1333 let query = &self.network_search_query;
1334
1335 self.network_listeners
1336 .iter()
1337 .filter(|listener| {
1338 let search_text = format!(
1340 "{} {} {}",
1341 listener.process.name,
1342 listener.process.pid,
1343 listener.socket.port()
1344 );
1345 matcher.fuzzy_match(&search_text, query).is_some()
1346 })
1347 .collect()
1348 }
1349
1350 pub fn toggle_network_search(&mut self) {
1352 self.network_search_active = !self.network_search_active;
1353 if !self.network_search_active {
1354 self.network_search_query.clear();
1355 }
1356 self.network_selected = 0;
1358 self.network_scroll_offset = 0;
1359 let filtered = self.filtered_network_listeners();
1361 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1362 }
1363
1364 pub fn clear_network_search(&mut self) {
1366 self.network_search_query.clear();
1367 self.network_search_active = false;
1368 self.network_selected = 0;
1370 self.network_scroll_offset = 0;
1371 let filtered = self.filtered_network_listeners();
1373 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1374 }
1375
1376 pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1378 self.config_daemon_ids.contains(daemon_id)
1379 }
1380
1381 pub fn toggle_show_available(&mut self) {
1383 self.show_available = !self.show_available;
1384 }
1385
1386 fn total_daemon_count(&self) -> usize {
1388 self.filtered_daemons().len()
1389 }
1390
1391 pub fn scroll_logs_down(&mut self) {
1392 let max_scroll = self.log_content.len();
1393 self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1394 }
1395
1396 pub fn scroll_logs_up(&mut self) {
1397 self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1398 }
1399
1400 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1402 let half_page = visible_lines / 2;
1403 let max_scroll = self.log_content.len();
1404 self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1405 }
1406
1407 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1409 let half_page = visible_lines / 2;
1410 self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1411 }
1412
1413 pub fn start_log_search(&mut self) {
1415 self.log_search_active = true;
1416 self.log_search_query.clear();
1417 self.log_search_matches.clear();
1418 self.log_search_current = 0;
1419 }
1420
1421 pub fn end_log_search(&mut self) {
1422 self.log_search_active = false;
1423 }
1424
1425 pub fn clear_log_search(&mut self) {
1426 self.log_search_query.clear();
1427 self.log_search_active = false;
1428 self.log_search_matches.clear();
1429 self.log_search_current = 0;
1430 }
1431
1432 pub fn log_search_push(&mut self, c: char) {
1433 self.log_search_query.push(c);
1434 self.update_log_search_matches();
1435 }
1436
1437 pub fn log_search_pop(&mut self) {
1438 self.log_search_query.pop();
1439 self.update_log_search_matches();
1440 }
1441
1442 fn update_log_search_matches(&mut self) {
1443 self.log_search_matches.clear();
1444 if !self.log_search_query.is_empty() {
1445 let query = self.log_search_query.to_lowercase();
1446 for (i, line) in self.log_content.iter().enumerate() {
1447 if line.to_lowercase().contains(&query) {
1448 self.log_search_matches.push(i);
1449 }
1450 }
1451 if !self.log_search_matches.is_empty() {
1453 self.log_search_current = 0;
1454 self.jump_to_log_match();
1455 }
1456 }
1457 }
1458
1459 pub fn log_search_next(&mut self) {
1460 if !self.log_search_matches.is_empty() {
1461 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1462 self.jump_to_log_match();
1463 }
1464 }
1465
1466 pub fn log_search_prev(&mut self) {
1467 if !self.log_search_matches.is_empty() {
1468 self.log_search_current = self
1469 .log_search_current
1470 .checked_sub(1)
1471 .unwrap_or(self.log_search_matches.len() - 1);
1472 self.jump_to_log_match();
1473 }
1474 }
1475
1476 fn jump_to_log_match(&mut self) {
1477 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1478 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1481 self.log_follow = false;
1482 }
1483 }
1484
1485 pub fn show_details(&mut self, daemon_id: &DaemonId) {
1487 self.details_daemon_id = Some(daemon_id.clone());
1488 self.prev_view = self.view;
1489 self.view = View::Details;
1490 }
1491
1492 pub fn hide_details(&mut self) {
1493 self.details_daemon_id = None;
1494 self.view = View::Dashboard;
1495 }
1496
1497 pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1499 self.log_daemon_id = Some(daemon_id.clone());
1500 self.logs_expanded = false; self.load_logs(daemon_id);
1502 self.view = View::Logs; }
1504
1505 fn load_logs(&mut self, daemon_id: &DaemonId) {
1506 const TUI_LOG_LIMIT: usize = 5000;
1507 let prev_len = self.log_content.len();
1508
1509 self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1510 daemon_ids: vec![daemon_id.qualified()],
1511 from: None,
1512 to: None,
1513 limit: Some(TUI_LOG_LIMIT),
1514 order_desc: true,
1515 after_id: None,
1516 before_id: None,
1517 message_filters: Vec::new(),
1518 field_filters: Vec::new(),
1519 include_structured: false,
1520 }) {
1521 Ok(entries) if !entries.is_empty() => {
1522 let mut lines: Vec<String> = entries
1523 .into_iter()
1524 .map(|e| {
1525 let ts = e.timestamp.format("%H:%M:%S").to_string();
1526 format!("{} {}", ts, e.message)
1527 })
1528 .collect();
1529 lines.reverse();
1530 lines
1531 }
1532 _ => vec!["No logs available".to_string()],
1533 };
1534
1535 if self.log_follow {
1537 self.log_scroll = self.log_content.len().max(1);
1538 } else if prev_len == 0 {
1539 self.log_scroll = self.log_content.len().max(1);
1541 }
1542 }
1544
1545 pub fn show_help(&mut self) {
1546 self.view = View::Help;
1547 }
1548
1549 pub fn back_to_dashboard(&mut self) {
1550 self.view = View::Dashboard;
1551 self.log_daemon_id = None;
1552 self.log_content.clear();
1553 self.log_scroll = 1;
1554 }
1555
1556 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1558 let available = self.config_daemon_ids.len();
1559 let total = self.daemons.len();
1560 let running = self
1561 .daemons
1562 .iter()
1563 .filter(|d| d.status.is_running())
1564 .count();
1565 let stopped = self
1567 .daemons
1568 .iter()
1569 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1570 .count();
1571 let errored = self
1572 .daemons
1573 .iter()
1574 .filter(|d| d.status.is_errored() || d.status.is_failed())
1575 .count();
1576 (total, running, stopped, errored, available)
1577 }
1578
1579 pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1580 self.disabled.contains(daemon_id)
1581 }
1582
1583 pub fn get_config_files(&self) -> Vec<PathBuf> {
1587 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1588 .into_iter()
1589 .filter(|p| p.exists())
1590 .collect();
1591
1592 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1594 if !files.contains(&cwd_config) {
1595 files.push(cwd_config);
1596 }
1597
1598 files
1599 }
1600
1601 pub fn open_file_selector(&mut self) {
1603 let files = self.get_config_files();
1604 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1605 self.view = View::ConfigFileSelect;
1606 }
1607
1608 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1610 self.editor_state = Some(EditorState::new_create(config_path));
1611 self.file_selector = None;
1612 self.view = View::ConfigEditor;
1613 }
1614
1615 pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1617 let config = match PitchforkToml::all_merged() {
1618 Ok(config) => config,
1619 Err(e) => {
1620 self.set_message(format!("Failed to load config: {e}"));
1621 return;
1622 }
1623 };
1624 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1625 let config_path = daemon_config
1626 .path
1627 .clone()
1628 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1629 self.editor_state = Some(EditorState::new_edit(
1630 daemon_id.to_string(),
1631 daemon_config,
1632 config_path,
1633 ));
1634 self.view = View::ConfigEditor;
1635 } else {
1636 self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1637 }
1638 }
1639
1640 pub fn close_editor(&mut self) {
1642 self.editor_state = None;
1643 self.file_selector = None;
1644 self.view = View::Dashboard;
1645 }
1646
1647 pub fn save_editor_config(&mut self) -> Result<bool> {
1650 let editor = self
1651 .editor_state
1652 .as_mut()
1653 .ok_or_else(|| miette::miette!("No editor state"))?;
1654
1655 if !editor.validate() {
1657 self.set_message("Please fix validation errors before saving");
1658 return Ok(false);
1659 }
1660
1661 let daemon_config = editor.to_daemon_config();
1663
1664 let daemon_id = DaemonId::parse(&editor.daemon_id)
1666 .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1667
1668 let mut config = PitchforkToml::read(&editor.config_path)?;
1670
1671 let is_duplicate = match &editor.mode {
1673 EditMode::Create => config.daemons.contains_key(&daemon_id),
1674 EditMode::Edit { original_id } => {
1675 let original_daemon_id = DaemonId::parse(original_id)
1677 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1678 original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1679 }
1680 };
1681
1682 if is_duplicate {
1683 self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1684 return Ok(false);
1685 }
1686
1687 if let EditMode::Edit { original_id } = &editor.mode {
1689 let original_daemon_id = DaemonId::parse(original_id)
1690 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1691 if original_daemon_id != daemon_id {
1692 config.daemons.shift_remove(&original_daemon_id);
1693 }
1694 }
1695
1696 config.daemons.insert(daemon_id, daemon_config);
1698
1699 config.write()?;
1701
1702 editor.unsaved_changes = false;
1703 let daemon_id = editor.daemon_id.clone();
1704 self.set_message(format!("Saved daemon '{daemon_id}'"));
1705
1706 Ok(true)
1707 }
1708
1709 pub fn delete_daemon_from_config(
1711 &mut self,
1712 id: &str,
1713 config_path: &std::path::Path,
1714 ) -> Result<bool> {
1715 let mut config = PitchforkToml::read(config_path)?;
1716
1717 let daemon_id = if id.contains('/') {
1720 DaemonId::parse(id)?
1721 } else {
1722 let ns = namespace_from_path(config_path)?;
1723 DaemonId::try_new(&ns, id)?
1724 };
1725
1726 if config.daemons.shift_remove(&daemon_id).is_some() {
1727 config.write()?;
1728 Ok(true)
1729 } else {
1730 Ok(false)
1731 }
1732 }
1733}
1734
1735impl Default for App {
1736 fn default() -> Self {
1737 Self::new(NamespaceFilter::default())
1738 }
1739}