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