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