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 ReadyHttp, 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<u16>),
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 match &mut self.value {
266 FormFieldValue::Text(s) => *s = text,
267 FormFieldValue::OptionalText(opt) => {
268 *opt = if text.is_empty() { None } else { Some(text) };
269 }
270 FormFieldValue::Number(n) => {
271 let trimmed = text.trim();
272 if trimmed.is_empty() {
273 *n = 0;
274 self.error = None;
275 } else {
276 match trimmed.parse() {
277 Ok(value) => {
278 *n = value;
279 self.error = None;
280 }
281 Err(_) => {
282 *n = 0;
283 self.error = Some("Invalid number".to_string());
284 }
285 }
286 }
287 }
288 FormFieldValue::OptionalNumber(opt) => {
289 *opt = text.parse().ok();
290 }
291 FormFieldValue::OptionalPort(opt) => {
292 *opt = text.parse().ok();
293 }
294 FormFieldValue::StringList(v) => {
295 *v = text
296 .split(',')
297 .map(|s| s.trim().to_string())
298 .filter(|s| !s.is_empty())
299 .collect();
300 }
301 _ => {}
302 }
303 }
304
305 pub fn is_text_editable(&self) -> bool {
306 matches!(
307 self.value,
308 FormFieldValue::Text(_)
309 | FormFieldValue::OptionalText(_)
310 | FormFieldValue::Number(_)
311 | FormFieldValue::OptionalNumber(_)
312 | FormFieldValue::OptionalPort(_)
313 | FormFieldValue::StringList(_)
314 )
315 }
316}
317
318#[derive(Debug, Clone)]
320pub struct EditorState {
321 pub mode: EditMode,
322 pub daemon_id: String,
323 pub daemon_id_editing: bool,
324 pub daemon_id_cursor: usize,
325 pub daemon_id_error: Option<String>,
326 pub fields: Vec<FormField>,
327 pub focused_field: usize,
328 pub config_path: PathBuf,
329 pub unsaved_changes: bool,
330 #[allow(dead_code)]
331 pub scroll_offset: usize,
332 preserved_ready_cmd: Option<String>,
334 preserved_ready_http_status: Option<Vec<u16>>,
336}
337
338impl EditorState {
339 pub fn new_create(config_path: PathBuf) -> Self {
340 Self {
341 mode: EditMode::Create,
342 daemon_id: String::new(),
343 daemon_id_editing: true,
344 daemon_id_cursor: 0,
345 daemon_id_error: None,
346 fields: Self::default_fields(),
347 focused_field: 0,
348 config_path,
349 unsaved_changes: false,
350 scroll_offset: 0,
351 preserved_ready_cmd: None,
352 preserved_ready_http_status: None,
353 }
354 }
355
356 pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
357 Self {
358 mode: EditMode::Edit {
359 original_id: daemon_id.clone(),
360 },
361 daemon_id,
362 daemon_id_editing: false,
363 daemon_id_cursor: 0,
364 daemon_id_error: None,
365 fields: Self::fields_from_config(config),
366 focused_field: 0,
367 config_path,
368 unsaved_changes: false,
369 scroll_offset: 0,
370 preserved_ready_cmd: config.ready_cmd.clone(),
371 preserved_ready_http_status: config
372 .ready_http
373 .as_ref()
374 .and_then(|h| (!h.status.is_empty()).then(|| h.status.clone())),
375 }
376 }
377
378 fn default_fields() -> Vec<FormField> {
379 vec![
380 FormField::text(
381 "run",
382 "Run Command",
383 "Command to execute. Prepend 'exec' to avoid shell overhead.",
384 true,
385 ),
386 FormField::optional_text(
387 "dir",
388 "Working Directory",
389 "Working directory for the daemon. Relative to pitchfork.toml location.",
390 ),
391 FormField::string_list(
392 "env",
393 "Environment Variables",
394 "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
395 ),
396 FormField::auto_behavior(
397 "auto",
398 "Auto Behavior",
399 "Auto start/stop based on directory hooks.",
400 ),
401 FormField::number(
402 "retry",
403 "Retry Count",
404 "Number of retry attempts on failure (0 = no retries).",
405 0,
406 ),
407 FormField::optional_number(
408 "ready_delay",
409 "Ready Delay (ms)",
410 "Milliseconds to wait before considering daemon ready.",
411 ),
412 FormField::optional_text(
413 "ready_output",
414 "Ready Output Pattern",
415 "Regex pattern in ANSI-stripped stdout/stderr indicating readiness.",
416 ),
417 FormField::optional_text(
418 "ready_http",
419 "Ready HTTP URL",
420 "HTTP URL to poll for readiness (expects 2xx).",
421 ),
422 FormField::optional_port(
423 "ready_port",
424 "Ready Port",
425 "TCP port to check for readiness (1-65535).",
426 ),
427 FormField::optional_bool(
428 "boot_start",
429 "Start on Boot",
430 "Automatically start this daemon on system boot.",
431 ),
432 FormField::string_list(
433 "depends",
434 "Dependencies",
435 "Comma-separated daemon names that must start first.",
436 ),
437 FormField::string_list(
438 "watch",
439 "Watch Files",
440 "Comma-separated glob patterns to watch for auto-restart.",
441 ),
442 FormField::optional_text(
443 "cron_schedule",
444 "Cron Schedule",
445 "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
446 ),
447 FormField::retrigger(
448 "cron_retrigger",
449 "Cron Retrigger",
450 "Behavior when cron triggers while previous run is active.",
451 ),
452 FormField::optional_bool(
453 "cron_immediate",
454 "Cron Immediate",
455 "Trigger immediately on first check (default: false).",
456 ),
457 ]
458 }
459
460 fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
461 let mut fields = Self::default_fields();
462
463 for field in &mut fields {
464 match field.name {
465 "run" => field.value = FormFieldValue::Text(config.run.clone()),
466 "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
467 "env" => {
468 field.value = FormFieldValue::StringList(
469 config
470 .env
471 .as_ref()
472 .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
473 .unwrap_or_default(),
474 );
475 }
476 "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
477 "retry" => field.value = FormFieldValue::Number(config.retry.count()),
478 "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
479 "ready_output" => {
480 field.value = FormFieldValue::OptionalText(config.ready_output.clone())
481 }
482 "ready_http" => {
483 field.value = FormFieldValue::OptionalText(
484 config.ready_http.as_ref().map(|h| h.url.clone()),
485 )
486 }
487 "ready_port" => field.value = FormFieldValue::OptionalPort(config.ready_port),
488 "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
489 "depends" => {
490 field.value = FormFieldValue::StringList(
491 config
492 .depends
493 .iter()
494 .map(|d: &DaemonId| d.qualified())
495 .collect(),
496 )
497 }
498 "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
499 "cron_schedule" => {
500 field.value = FormFieldValue::OptionalText(
501 config.cron.as_ref().map(|c| c.schedule.clone()),
502 );
503 }
504 "cron_retrigger" => {
505 field.value = FormFieldValue::Retrigger(
506 config
507 .cron
508 .as_ref()
509 .map(|c| c.retrigger)
510 .unwrap_or(CronRetrigger::Finish),
511 );
512 }
513 "cron_immediate" => {
514 field.value =
515 FormFieldValue::OptionalBoolean(config.cron.as_ref().map(|c| c.immediate));
516 }
517 _ => {}
518 }
519 }
520
521 fields
522 }
523
524 pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
525 let mut config = PitchforkTomlDaemon {
526 ready_cmd: self.preserved_ready_cmd.clone(),
527 path: Some(self.config_path.clone()),
528 ..PitchforkTomlDaemon::default()
529 };
530
531 let mut cron_schedule: Option<String> = None;
532 let mut cron_retrigger = CronRetrigger::Finish;
533 let mut cron_immediate = false;
534
535 for field in &self.fields {
536 match (field.name, &field.value) {
537 ("run", FormFieldValue::Text(s)) => config.run = s.clone(),
538 ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
539 ("env", FormFieldValue::StringList(v)) => {
540 if v.is_empty() {
541 config.env = None;
542 } else {
543 let mut map = indexmap::IndexMap::new();
544 for entry in v {
545 if let Some((k, val)) = entry.split_once('=') {
546 map.insert(k.trim().to_string(), val.trim().to_string());
547 }
548 }
549 config.env = if map.is_empty() { None } else { Some(map) };
550 }
551 }
552 ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
553 ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
554 ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
555 ("ready_output", FormFieldValue::OptionalText(s)) => {
556 config.ready_output = s.clone()
557 }
558 ("ready_http", FormFieldValue::OptionalText(s)) => {
559 config.ready_http = s.clone().map(|url| ReadyHttp {
560 url,
561 status: self.preserved_ready_http_status.clone().unwrap_or_default(),
562 })
563 }
564 ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = *p,
565 ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
566 ("depends", FormFieldValue::StringList(v)) => {
567 config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
568 }
569 ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
570 ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
571 ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
572 ("cron_immediate", FormFieldValue::OptionalBoolean(b)) => {
573 cron_immediate = b.unwrap_or(false);
574 }
575 _ => {}
576 }
577 }
578
579 if let Some(schedule) = cron_schedule {
580 config.cron = Some(PitchforkTomlCron {
581 schedule,
582 retrigger: cron_retrigger,
583 immediate: cron_immediate,
584 });
585 }
586
587 config
588 }
589
590 pub fn next_field(&mut self) {
591 if let Some(field) = self.fields.get_mut(self.focused_field) {
593 field.editing = false;
594 }
595
596 if self.daemon_id_editing {
598 self.daemon_id_editing = false;
599 return;
600 }
601
602 if self.focused_field < self.fields.len() - 1 {
603 self.focused_field += 1;
604 }
605 }
606
607 pub fn prev_field(&mut self) {
608 if let Some(field) = self.fields.get_mut(self.focused_field) {
610 field.editing = false;
611 }
612 self.daemon_id_editing = false;
613
614 if self.focused_field > 0 {
615 self.focused_field -= 1;
616 }
617 }
618
619 pub fn toggle_current_field(&mut self) {
620 if let Some(field) = self.fields.get_mut(self.focused_field) {
621 let toggled = match &mut field.value {
622 FormFieldValue::Boolean(b) => {
623 *b = !*b;
624 true
625 }
626 FormFieldValue::OptionalBoolean(opt) => {
627 *opt = match opt {
628 None => Some(true),
629 Some(true) => Some(false),
630 Some(false) => None,
631 };
632 true
633 }
634 FormFieldValue::AutoBehavior(v) => {
635 let has_start = v.contains(&PitchforkTomlAuto::Start);
637 let has_stop = v.contains(&PitchforkTomlAuto::Stop);
638 *v = match (has_start, has_stop) {
639 (false, false) => vec![PitchforkTomlAuto::Start],
640 (true, false) => vec![PitchforkTomlAuto::Stop],
641 (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
642 (true, true) => vec![],
643 };
644 true
645 }
646 FormFieldValue::Retrigger(r) => {
647 *r = match r {
648 CronRetrigger::Finish => CronRetrigger::Always,
649 CronRetrigger::Always => CronRetrigger::Success,
650 CronRetrigger::Success => CronRetrigger::Fail,
651 CronRetrigger::Fail => CronRetrigger::Finish,
652 };
653 true
654 }
655 _ => false,
656 };
657 if toggled {
658 self.unsaved_changes = true;
659 }
660 }
661 }
662
663 pub fn start_editing(&mut self) {
664 if let Some(field) = self.fields.get_mut(self.focused_field) {
665 if field.is_text_editable() {
666 field.editing = true;
667 field.cursor = field.get_text().chars().count();
668 } else {
669 self.toggle_current_field();
671 }
672 }
673 }
674
675 pub fn stop_editing(&mut self) {
676 if let Some(field) = self.fields.get_mut(self.focused_field) {
677 field.editing = false;
678 }
679 self.daemon_id_editing = false;
680 }
681
682 pub fn is_editing(&self) -> bool {
683 self.daemon_id_editing
684 || self
685 .fields
686 .get(self.focused_field)
687 .map(|f| f.editing)
688 .unwrap_or(false)
689 }
690
691 pub fn text_push(&mut self, c: char) {
692 if self.daemon_id_editing {
693 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
694 self.daemon_id.insert(byte_idx, c);
695 self.daemon_id_cursor += 1;
696 self.unsaved_changes = true;
697 } else if let Some(field) = self.fields.get_mut(self.focused_field)
698 && field.editing
699 {
700 let mut text = field.get_text();
701 let byte_idx = char_to_byte_index(&text, field.cursor);
702 text.insert(byte_idx, c);
703 field.cursor += 1;
704 field.set_text(text);
705 self.unsaved_changes = true;
706 }
707 }
708
709 pub fn text_pop(&mut self) {
710 if self.daemon_id_editing && self.daemon_id_cursor > 0 {
711 self.daemon_id_cursor -= 1;
712 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
713 self.daemon_id.remove(byte_idx);
714 self.unsaved_changes = true;
715 } else if let Some(field) = self.fields.get_mut(self.focused_field)
716 && field.editing
717 && field.cursor > 0
718 {
719 let mut text = field.get_text();
720 field.cursor -= 1;
721 let byte_idx = char_to_byte_index(&text, field.cursor);
722 text.remove(byte_idx);
723 field.set_text(text);
724 if matches!(field.value, FormFieldValue::Number(_)) {
727 field.cursor = field.get_text().chars().count();
728 }
729 self.unsaved_changes = true;
730 }
731 }
732
733 pub fn validate(&mut self) -> bool {
734 let mut valid = true;
735
736 self.daemon_id_error = None;
738 if self.daemon_id.is_empty() {
739 self.daemon_id_error = Some("Name is required".to_string());
740 valid = false;
741 } else if !self
742 .daemon_id
743 .chars()
744 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
745 {
746 self.daemon_id_error =
747 Some("Only letters, digits, hyphens, and underscores allowed".to_string());
748 valid = false;
749 }
750
751 for field in &mut self.fields {
753 field.error = None;
754
755 match (field.name, &field.value) {
756 ("run", FormFieldValue::Text(s)) if s.is_empty() => {
757 field.error = Some("Required".to_string());
758 valid = false;
759 }
760 ("ready_port", FormFieldValue::OptionalPort(Some(p))) if *p == 0 => {
761 field.error = Some("Port must be 1-65535".to_string());
762 valid = false;
763 }
764 ("ready_http", FormFieldValue::OptionalText(Some(url)))
765 if !(url.starts_with("http://") || url.starts_with("https://")) =>
766 {
767 field.error = Some("Must start with http:// or https://".to_string());
768 valid = false;
769 }
770 _ => {}
771 }
772 }
773
774 valid
775 }
776}
777
778#[derive(Debug, Clone)]
780pub struct ConfigFileSelector {
781 pub files: Vec<PathBuf>,
782 pub selected: usize,
783}
784
785#[derive(Debug, Clone)]
786pub enum PendingAction {
787 Stop(DaemonId),
788 Restart(DaemonId),
789 Disable(DaemonId),
790 BatchStop(Vec<DaemonId>),
792 BatchRestart(Vec<DaemonId>),
793 BatchDisable(Vec<DaemonId>),
794 DeleteDaemon { id: String, config_path: PathBuf },
796 DiscardEditorChanges,
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
800pub enum SortColumn {
801 #[default]
802 Name,
803 Status,
804 Cpu,
805 Memory,
806 Uptime,
807}
808
809impl SortColumn {
810 pub fn next(self) -> Self {
811 match self {
812 Self::Name => Self::Status,
813 Self::Status => Self::Cpu,
814 Self::Cpu => Self::Memory,
815 Self::Memory => Self::Uptime,
816 Self::Uptime => Self::Name,
817 }
818 }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
822pub enum SortOrder {
823 #[default]
824 Ascending,
825 Descending,
826}
827
828impl SortOrder {
829 pub fn toggle(self) -> Self {
830 match self {
831 Self::Ascending => Self::Descending,
832 Self::Descending => Self::Ascending,
833 }
834 }
835
836 pub fn indicator(self) -> &'static str {
837 match self {
838 Self::Ascending => "↑",
839 Self::Descending => "↓",
840 }
841 }
842}
843
844pub struct App {
845 pub daemons: Vec<Daemon>,
846 pub disabled: Vec<DaemonId>,
847 pub selected: usize,
848 pub view: View,
849 pub prev_view: View,
850 pub log_content: Vec<String>,
851 pub log_daemon_id: Option<DaemonId>,
852 pub log_scroll: usize,
853 pub log_follow: bool, pub message: Option<String>,
855 pub message_time: Option<Instant>,
856 pub process_stats: HashMap<u32, ProcessStats>, pub stats_history: HashMap<DaemonId, StatsHistory>, pub pending_action: Option<PendingAction>,
859 pub loading_text: Option<String>,
860 pub search_query: String,
861 pub search_active: bool,
862 pub sort_column: SortColumn,
864 pub sort_order: SortOrder,
865 pub log_search_query: String,
867 pub log_search_active: bool,
868 pub log_search_matches: Vec<usize>, pub log_search_current: usize, pub details_daemon_id: Option<DaemonId>,
872 pub logs_expanded: bool,
874 pub multi_select: HashSet<DaemonId>,
876 pub config_daemon_ids: HashSet<DaemonId>,
878 pub show_available: bool,
880 pub editor_state: Option<EditorState>,
882 pub file_selector: Option<ConfigFileSelector>,
884 pub network_listeners: Vec<Listener>,
886 pub network_search_query: String,
887 pub network_search_active: bool,
888 pub network_selected: usize,
889 pub network_scroll_offset: usize,
890 pub network_selected_pid: Option<u32>, pub network_visible_rows: usize, }
893
894impl App {
895 pub fn new() -> Self {
896 Self {
897 daemons: Vec::new(),
898 disabled: Vec::new(),
899 selected: 0,
900 view: View::Dashboard,
901 prev_view: View::Dashboard,
902 log_content: Vec::new(),
903 log_daemon_id: None,
904 log_scroll: 1,
905 log_follow: true,
906 message: None,
907 message_time: None,
908 process_stats: HashMap::new(),
909 stats_history: HashMap::new(),
910 pending_action: None,
911 loading_text: None,
912 search_query: String::new(),
913 search_active: false,
914 sort_column: SortColumn::default(),
915 sort_order: SortOrder::default(),
916 log_search_query: String::new(),
917 log_search_active: false,
918 log_search_matches: Vec::new(),
919 log_search_current: 0,
920 details_daemon_id: None,
921 logs_expanded: false,
922 multi_select: HashSet::new(),
923 config_daemon_ids: HashSet::new(),
924 show_available: true, editor_state: None,
926 file_selector: None,
927 network_listeners: Vec::new(),
928 network_search_query: String::new(),
929 network_search_active: false,
930 network_selected: 0,
931 network_scroll_offset: 0,
932 network_selected_pid: None,
933 network_visible_rows: 20, }
935 }
936
937 pub fn confirm_action(&mut self, action: PendingAction) {
938 self.pending_action = Some(action);
939 self.prev_view = self.view;
940 self.view = View::Confirm;
941 }
942
943 pub fn cancel_confirm(&mut self) {
944 self.pending_action = None;
945 self.view = self.prev_view;
946 }
947
948 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
949 self.view = self.prev_view;
950 self.pending_action.take()
951 }
952
953 pub fn start_loading(&mut self, text: impl Into<String>) {
954 self.loading_text = Some(text.into());
955 }
956
957 pub fn stop_loading(&mut self) {
958 self.loading_text = None;
959 }
960
961 pub fn start_search(&mut self) {
963 self.search_active = true;
964 }
965
966 pub fn end_search(&mut self) {
967 self.search_active = false;
968 }
969
970 pub fn clear_search(&mut self) {
971 self.search_query.clear();
972 self.search_active = false;
973 self.selected = 0;
974 }
975
976 pub fn search_push(&mut self, c: char) {
977 self.search_query.push(c);
978 self.selected = 0;
980 }
981
982 pub fn search_pop(&mut self) {
983 self.search_query.pop();
984 self.selected = 0;
985 }
986
987 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
988 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
989 self.daemons.iter().collect()
990 } else {
991 let matcher = SkimMatcherV2::default();
993 let mut scored: Vec<_> = self
994 .daemons
995 .iter()
996 .filter_map(|d| {
997 matcher
998 .fuzzy_match(&d.id.qualified(), &self.search_query)
999 .map(|score| (d, score))
1000 })
1001 .collect();
1002 scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1004 scored.into_iter().map(|(d, _)| d).collect()
1005 };
1006
1007 filtered.sort_by(|a, b| {
1009 let cmp = match self.sort_column {
1010 SortColumn::Name => {
1011 a.id.to_string()
1012 .to_lowercase()
1013 .cmp(&b.id.to_string().to_lowercase())
1014 }
1015 SortColumn::Status => {
1016 let status_order = |d: &Daemon| match &d.status {
1017 crate::daemon_status::DaemonStatus::Running => 0,
1018 crate::daemon_status::DaemonStatus::Waiting => 1,
1019 crate::daemon_status::DaemonStatus::Stopping => 2,
1020 crate::daemon_status::DaemonStatus::Stopped => 3,
1021 crate::daemon_status::DaemonStatus::Errored(_) => 4,
1022 crate::daemon_status::DaemonStatus::Failed(_) => 5,
1023 };
1024 status_order(a).cmp(&status_order(b))
1025 }
1026 SortColumn::Cpu => {
1027 let cpu_a = a
1028 .pid
1029 .and_then(|p| self.get_stats(p))
1030 .map(|s| s.cpu_percent)
1031 .unwrap_or(0.0);
1032 let cpu_b = b
1033 .pid
1034 .and_then(|p| self.get_stats(p))
1035 .map(|s| s.cpu_percent)
1036 .unwrap_or(0.0);
1037 cpu_a
1038 .partial_cmp(&cpu_b)
1039 .unwrap_or(std::cmp::Ordering::Equal)
1040 }
1041 SortColumn::Memory => {
1042 let mem_a = a
1043 .pid
1044 .and_then(|p| self.get_stats(p))
1045 .map(|s| s.memory_bytes)
1046 .unwrap_or(0);
1047 let mem_b = b
1048 .pid
1049 .and_then(|p| self.get_stats(p))
1050 .map(|s| s.memory_bytes)
1051 .unwrap_or(0);
1052 mem_a.cmp(&mem_b)
1053 }
1054 SortColumn::Uptime => {
1055 let up_a = a
1056 .pid
1057 .and_then(|p| self.get_stats(p))
1058 .map(|s| s.uptime_secs)
1059 .unwrap_or(0);
1060 let up_b = b
1061 .pid
1062 .and_then(|p| self.get_stats(p))
1063 .map(|s| s.uptime_secs)
1064 .unwrap_or(0);
1065 up_a.cmp(&up_b)
1066 }
1067 };
1068 match self.sort_order {
1069 SortOrder::Ascending => cmp,
1070 SortOrder::Descending => cmp.reverse(),
1071 }
1072 });
1073
1074 filtered
1075 }
1076
1077 pub fn cycle_sort(&mut self) {
1079 self.sort_column = self.sort_column.next();
1081 self.selected = 0;
1082 }
1083
1084 pub fn toggle_sort_order(&mut self) {
1085 self.sort_order = self.sort_order.toggle();
1086 self.selected = 0;
1087 }
1088
1089 pub fn selected_daemon(&self) -> Option<&Daemon> {
1090 let filtered = self.filtered_daemons();
1091 filtered.get(self.selected).copied()
1092 }
1093
1094 pub fn select_next(&mut self) {
1095 let count = self.filtered_daemons().len();
1096 if count > 0 {
1097 self.selected = (self.selected + 1) % count;
1098 }
1099 }
1100
1101 pub fn select_prev(&mut self) {
1102 let count = self.filtered_daemons().len();
1103 if count > 0 {
1104 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1105 }
1106 }
1107
1108 pub fn toggle_log_follow(&mut self) {
1110 self.log_follow = !self.log_follow;
1111 if self.log_follow && !self.log_content.is_empty() {
1112 self.log_scroll = self.log_content.len();
1114 }
1115 }
1116
1117 pub fn toggle_logs_expanded(&mut self) {
1119 self.logs_expanded = !self.logs_expanded;
1120 }
1121
1122 pub fn toggle_select(&mut self) {
1124 if let Some(daemon) = self.selected_daemon() {
1125 let id = daemon.id.clone();
1126 if self.multi_select.contains(&id) {
1127 self.multi_select.remove(&id);
1128 } else {
1129 self.multi_select.insert(id);
1130 }
1131 }
1132 }
1133
1134 pub fn select_all_visible(&mut self) {
1135 let ids: Vec<DaemonId> = self
1137 .filtered_daemons()
1138 .iter()
1139 .map(|d| d.id.clone())
1140 .collect();
1141 for id in ids {
1142 self.multi_select.insert(id);
1143 }
1144 }
1145
1146 pub fn clear_selection(&mut self) {
1147 self.multi_select.clear();
1148 }
1149
1150 pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1151 self.multi_select.contains(daemon_id)
1152 }
1153
1154 pub fn has_selection(&self) -> bool {
1155 !self.multi_select.is_empty()
1156 }
1157
1158 pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1159 self.multi_select.iter().cloned().collect()
1160 }
1161
1162 pub fn set_message(&mut self, msg: impl Into<String>) {
1163 self.message = Some(msg.into());
1164 self.message_time = Some(Instant::now());
1165 }
1166
1167 pub fn clear_stale_message(&mut self) {
1168 let duration = settings().tui_message_duration();
1169 if let Some(time) = self.message_time
1170 && time.elapsed() >= duration
1171 {
1172 self.message = None;
1173 self.message_time = None;
1174 }
1175 }
1176
1177 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1178 self.process_stats.get(&pid)
1179 }
1180
1181 fn refresh_process_stats(&mut self) {
1182 let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1183 if !pids.is_empty() {
1184 PROCS.refresh_processes();
1185 }
1186 self.process_stats.clear();
1187
1188 let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1189
1190 for daemon in &self.daemons {
1191 if let Some(pid) = daemon.pid
1192 && let Some(stats) = stats_by_pid.get(&pid).copied()
1193 {
1194 self.process_stats.insert(pid, stats);
1195 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1197 history.push(StatsSnapshot::from(&stats));
1198 }
1199 }
1200 }
1201
1202 pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1204 self.stats_history.get(daemon_id)
1205 }
1206
1207 pub async fn fetch_daemon_data(client: &Arc<IpcClient>) -> Result<Vec<DaemonListEntry>> {
1209 use crate::daemon_list::get_all_daemons;
1210 get_all_daemons(client).await
1211 }
1212
1213 pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1215 self.daemons.clear();
1217 self.disabled.clear();
1218 self.config_daemon_ids.clear();
1219
1220 for entry in all_entries {
1221 let daemon_id = entry.daemon.id.clone();
1222
1223 if entry.is_disabled {
1224 self.disabled.push(daemon_id.clone());
1225 }
1226
1227 if entry.is_available {
1228 self.config_daemon_ids.insert(daemon_id.clone());
1229 }
1230
1231 if !entry.is_available || self.show_available {
1232 self.daemons.push(entry.daemon);
1233 }
1234 }
1235
1236 self.refresh_process_stats();
1237 self.clear_stale_message();
1238
1239 let total_count = self.total_daemon_count();
1240 if total_count > 0 && self.selected >= total_count {
1241 self.selected = total_count - 1;
1242 }
1243
1244 if self.view == View::Logs
1245 && let Some(id) = self.log_daemon_id.clone()
1246 {
1247 self.load_logs(&id);
1248 }
1249 }
1250
1251 pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1252 let entries = Self::fetch_daemon_data(client).await?;
1253 self.apply_refresh(entries);
1254 Ok(())
1255 }
1256
1257 pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1259 self.network_listeners = listeners;
1260
1261 let filtered_count = self.filtered_network_listeners().len();
1262
1263 if filtered_count > 0 && self.network_selected >= filtered_count {
1264 self.network_selected = filtered_count - 1;
1265 } else if filtered_count == 0 {
1266 self.network_selected = 0;
1267 }
1268
1269 let selected_pid = self
1270 .filtered_network_listeners()
1271 .get(self.network_selected)
1272 .map(|l| l.process.pid);
1273 self.network_selected_pid = selected_pid;
1274 }
1275
1276 pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1278 if self.network_search_query.is_empty() {
1279 return self.network_listeners.iter().collect();
1280 }
1281
1282 let matcher = SkimMatcherV2::default();
1283 let query = &self.network_search_query;
1284
1285 self.network_listeners
1286 .iter()
1287 .filter(|listener| {
1288 let search_text = format!(
1290 "{} {} {}",
1291 listener.process.name,
1292 listener.process.pid,
1293 listener.socket.port()
1294 );
1295 matcher.fuzzy_match(&search_text, query).is_some()
1296 })
1297 .collect()
1298 }
1299
1300 pub fn toggle_network_search(&mut self) {
1302 self.network_search_active = !self.network_search_active;
1303 if !self.network_search_active {
1304 self.network_search_query.clear();
1305 }
1306 self.network_selected = 0;
1308 self.network_scroll_offset = 0;
1309 let filtered = self.filtered_network_listeners();
1311 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1312 }
1313
1314 pub fn clear_network_search(&mut self) {
1316 self.network_search_query.clear();
1317 self.network_search_active = false;
1318 self.network_selected = 0;
1320 self.network_scroll_offset = 0;
1321 let filtered = self.filtered_network_listeners();
1323 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1324 }
1325
1326 pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1328 self.config_daemon_ids.contains(daemon_id)
1329 }
1330
1331 pub fn toggle_show_available(&mut self) {
1333 self.show_available = !self.show_available;
1334 }
1335
1336 fn total_daemon_count(&self) -> usize {
1338 self.filtered_daemons().len()
1339 }
1340
1341 pub fn scroll_logs_down(&mut self) {
1342 let max_scroll = self.log_content.len();
1343 self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1344 }
1345
1346 pub fn scroll_logs_up(&mut self) {
1347 self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1348 }
1349
1350 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1352 let half_page = visible_lines / 2;
1353 let max_scroll = self.log_content.len();
1354 self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1355 }
1356
1357 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1359 let half_page = visible_lines / 2;
1360 self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1361 }
1362
1363 pub fn start_log_search(&mut self) {
1365 self.log_search_active = true;
1366 self.log_search_query.clear();
1367 self.log_search_matches.clear();
1368 self.log_search_current = 0;
1369 }
1370
1371 pub fn end_log_search(&mut self) {
1372 self.log_search_active = false;
1373 }
1374
1375 pub fn clear_log_search(&mut self) {
1376 self.log_search_query.clear();
1377 self.log_search_active = false;
1378 self.log_search_matches.clear();
1379 self.log_search_current = 0;
1380 }
1381
1382 pub fn log_search_push(&mut self, c: char) {
1383 self.log_search_query.push(c);
1384 self.update_log_search_matches();
1385 }
1386
1387 pub fn log_search_pop(&mut self) {
1388 self.log_search_query.pop();
1389 self.update_log_search_matches();
1390 }
1391
1392 fn update_log_search_matches(&mut self) {
1393 self.log_search_matches.clear();
1394 if !self.log_search_query.is_empty() {
1395 let query = self.log_search_query.to_lowercase();
1396 for (i, line) in self.log_content.iter().enumerate() {
1397 if line.to_lowercase().contains(&query) {
1398 self.log_search_matches.push(i);
1399 }
1400 }
1401 if !self.log_search_matches.is_empty() {
1403 self.log_search_current = 0;
1404 self.jump_to_log_match();
1405 }
1406 }
1407 }
1408
1409 pub fn log_search_next(&mut self) {
1410 if !self.log_search_matches.is_empty() {
1411 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1412 self.jump_to_log_match();
1413 }
1414 }
1415
1416 pub fn log_search_prev(&mut self) {
1417 if !self.log_search_matches.is_empty() {
1418 self.log_search_current = self
1419 .log_search_current
1420 .checked_sub(1)
1421 .unwrap_or(self.log_search_matches.len() - 1);
1422 self.jump_to_log_match();
1423 }
1424 }
1425
1426 fn jump_to_log_match(&mut self) {
1427 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1428 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1431 self.log_follow = false;
1432 }
1433 }
1434
1435 pub fn show_details(&mut self, daemon_id: &DaemonId) {
1437 self.details_daemon_id = Some(daemon_id.clone());
1438 self.prev_view = self.view;
1439 self.view = View::Details;
1440 }
1441
1442 pub fn hide_details(&mut self) {
1443 self.details_daemon_id = None;
1444 self.view = View::Dashboard;
1445 }
1446
1447 pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1449 self.log_daemon_id = Some(daemon_id.clone());
1450 self.logs_expanded = false; self.load_logs(daemon_id);
1452 self.view = View::Logs; }
1454
1455 fn load_logs(&mut self, daemon_id: &DaemonId) {
1456 const TUI_LOG_LIMIT: usize = 5000;
1457 let prev_len = self.log_content.len();
1458
1459 self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1460 daemon_ids: vec![daemon_id.qualified()],
1461 from: None,
1462 to: None,
1463 limit: Some(TUI_LOG_LIMIT),
1464 order_desc: true,
1465 after_id: None,
1466 }) {
1467 Ok(entries) if !entries.is_empty() => {
1468 let mut lines: Vec<String> = entries
1469 .into_iter()
1470 .map(|e| {
1471 let ts = e.timestamp.format("%H:%M:%S").to_string();
1472 format!("{} {}", ts, e.message)
1473 })
1474 .collect();
1475 lines.reverse();
1476 lines
1477 }
1478 _ => vec!["No logs available".to_string()],
1479 };
1480
1481 if self.log_follow {
1483 self.log_scroll = self.log_content.len().max(1);
1484 } else if prev_len == 0 {
1485 self.log_scroll = self.log_content.len().max(1);
1487 }
1488 }
1490
1491 pub fn show_help(&mut self) {
1492 self.view = View::Help;
1493 }
1494
1495 pub fn back_to_dashboard(&mut self) {
1496 self.view = View::Dashboard;
1497 self.log_daemon_id = None;
1498 self.log_content.clear();
1499 self.log_scroll = 1;
1500 }
1501
1502 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1504 let available = self.config_daemon_ids.len();
1505 let total = self.daemons.len();
1506 let running = self
1507 .daemons
1508 .iter()
1509 .filter(|d| d.status.is_running())
1510 .count();
1511 let stopped = self
1513 .daemons
1514 .iter()
1515 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1516 .count();
1517 let errored = self
1518 .daemons
1519 .iter()
1520 .filter(|d| d.status.is_errored() || d.status.is_failed())
1521 .count();
1522 (total, running, stopped, errored, available)
1523 }
1524
1525 pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1526 self.disabled.contains(daemon_id)
1527 }
1528
1529 pub fn get_config_files(&self) -> Vec<PathBuf> {
1533 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1534 .into_iter()
1535 .filter(|p| p.exists())
1536 .collect();
1537
1538 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1540 if !files.contains(&cwd_config) {
1541 files.push(cwd_config);
1542 }
1543
1544 files
1545 }
1546
1547 pub fn open_file_selector(&mut self) {
1549 let files = self.get_config_files();
1550 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1551 self.view = View::ConfigFileSelect;
1552 }
1553
1554 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1556 self.editor_state = Some(EditorState::new_create(config_path));
1557 self.file_selector = None;
1558 self.view = View::ConfigEditor;
1559 }
1560
1561 pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1563 let config = match PitchforkToml::all_merged() {
1564 Ok(config) => config,
1565 Err(e) => {
1566 self.set_message(format!("Failed to load config: {e}"));
1567 return;
1568 }
1569 };
1570 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1571 let config_path = daemon_config
1572 .path
1573 .clone()
1574 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1575 self.editor_state = Some(EditorState::new_edit(
1576 daemon_id.to_string(),
1577 daemon_config,
1578 config_path,
1579 ));
1580 self.view = View::ConfigEditor;
1581 } else {
1582 self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1583 }
1584 }
1585
1586 pub fn close_editor(&mut self) {
1588 self.editor_state = None;
1589 self.file_selector = None;
1590 self.view = View::Dashboard;
1591 }
1592
1593 pub fn save_editor_config(&mut self) -> Result<bool> {
1596 let editor = self
1597 .editor_state
1598 .as_mut()
1599 .ok_or_else(|| miette::miette!("No editor state"))?;
1600
1601 if !editor.validate() {
1603 self.set_message("Please fix validation errors before saving");
1604 return Ok(false);
1605 }
1606
1607 let daemon_config = editor.to_daemon_config();
1609
1610 let daemon_id = DaemonId::parse(&editor.daemon_id)
1612 .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1613
1614 let mut config = PitchforkToml::read(&editor.config_path)?;
1616
1617 let is_duplicate = match &editor.mode {
1619 EditMode::Create => config.daemons.contains_key(&daemon_id),
1620 EditMode::Edit { original_id } => {
1621 let original_daemon_id = DaemonId::parse(original_id)
1623 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1624 original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1625 }
1626 };
1627
1628 if is_duplicate {
1629 self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1630 return Ok(false);
1631 }
1632
1633 if let EditMode::Edit { original_id } = &editor.mode {
1635 let original_daemon_id = DaemonId::parse(original_id)
1636 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1637 if original_daemon_id != daemon_id {
1638 config.daemons.shift_remove(&original_daemon_id);
1639 }
1640 }
1641
1642 config.daemons.insert(daemon_id, daemon_config);
1644
1645 config.write()?;
1647
1648 editor.unsaved_changes = false;
1649 let daemon_id = editor.daemon_id.clone();
1650 self.set_message(format!("Saved daemon '{daemon_id}'"));
1651
1652 Ok(true)
1653 }
1654
1655 pub fn delete_daemon_from_config(
1657 &mut self,
1658 id: &str,
1659 config_path: &std::path::Path,
1660 ) -> Result<bool> {
1661 let mut config = PitchforkToml::read(config_path)?;
1662
1663 let daemon_id = if id.contains('/') {
1666 DaemonId::parse(id)?
1667 } else {
1668 let ns = namespace_from_path(config_path)?;
1669 DaemonId::try_new(&ns, id)?
1670 };
1671
1672 if config.daemons.shift_remove(&daemon_id).is_some() {
1673 config.write()?;
1674 Ok(true)
1675 } else {
1676 Ok(false)
1677 }
1678 }
1679}
1680
1681impl Default for App {
1682 fn default() -> Self {
1683 Self::new()
1684 }
1685}