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