1use crate::profile::{Profile, ProfileId, ProfileManager};
6use crate::shell_detection;
7
8const EMOJI_PRESETS: &[(&str, &[&str])] = &[
10 (
11 "Terminal",
12 &["๐ป", "๐ฅ๏ธ", "โจ๏ธ", "๐", "๐", "๐ฒ", "โถ๏ธ", "โฌ"],
13 ),
14 (
15 "Dev & Tools",
16 &["๐ง", "๐ ๏ธ", "โ๏ธ", "๐จ", "๐งฐ", "๐", "๐ฌ", "๐งช"],
17 ),
18 (
19 "Files & Data",
20 &["๐", "๐", "๐", "๐", "๐พ", "๐๏ธ", "๐ฆ", "๐๏ธ"],
21 ),
22 (
23 "Network & Cloud",
24 &["๐", "โ๏ธ", "๐ก", "๐", "๐", "๐ถ", "๐ฐ๏ธ", "๐"],
25 ),
26 (
27 "Security",
28 &["๐", "๐", "๐ก๏ธ", "๐", "๐จ", "โ ๏ธ", "๐", "๐งฑ"],
29 ),
30 (
31 "Status & Alerts",
32 &["โ
", "โ", "โก", "๐", "๐ก", "๐", "๐ฏ", "๐ฅ"],
33 ),
34 (
35 "Containers & Infra",
36 &["๐ณ", "๐ง", "๐๏ธ", "๐", "๐ง", "๐", "๐", "๐ "],
37 ),
38 (
39 "People & Roles",
40 &["๐ค", "๐จโ๐ป", "๐ฉโ๐ป", "๐ค", "๐ฅ", "๐งโ๐ง", "๐งโ๐ซ", "๐ท"],
41 ),
42 ("Misc", &["๐จ", "๐", "๐ท๏ธ", "โญ", "๐", "๐", "๐ฎ", "๐ต"]),
43];
44
45#[derive(Debug, Clone, PartialEq)]
47pub enum ProfileModalAction {
48 None,
50 Save,
52 Cancel,
54 #[allow(dead_code)]
56 OpenProfile(ProfileId),
57}
58
59#[derive(Debug, Clone, PartialEq)]
61enum ModalMode {
62 List,
64 Edit(ProfileId),
66 Create,
68}
69
70pub struct ProfileModalUI {
72 pub visible: bool,
74 mode: ModalMode,
76 working_profiles: Vec<Profile>,
78 editing_id: Option<ProfileId>,
80
81 temp_name: String,
83 temp_working_dir: String,
84 temp_shell: Option<String>,
85 temp_login_shell: Option<bool>,
86 temp_command: String,
87 temp_args: String,
88 temp_tab_name: String,
89 temp_icon: String,
90 temp_tags: String,
92 temp_parent_id: Option<ProfileId>,
93 temp_keyboard_shortcut: String,
94 temp_hostname_patterns: String,
95 temp_tmux_session_patterns: String,
96 temp_directory_patterns: String,
97 temp_badge_text: String,
98 temp_badge_color: Option<[u8; 3]>,
100 temp_badge_color_alpha: Option<f32>,
101 temp_badge_font: String,
102 temp_badge_font_bold: Option<bool>,
103 temp_badge_top_margin: Option<f32>,
104 temp_badge_right_margin: Option<f32>,
105 temp_badge_max_width: Option<f32>,
106 temp_badge_max_height: Option<f32>,
107 badge_section_expanded: bool,
109 ssh_section_expanded: bool,
111 temp_ssh_host: String,
113 temp_ssh_user: String,
114 temp_ssh_port: String,
115 temp_ssh_identity_file: String,
116 temp_ssh_extra_args: String,
117
118 selected_id: Option<ProfileId>,
120 has_changes: bool,
122 validation_error: Option<String>,
124 pending_delete: Option<(ProfileId, String)>,
126}
127
128impl ProfileModalUI {
129 pub fn new() -> Self {
131 Self {
132 visible: false,
133 mode: ModalMode::List,
134 working_profiles: Vec::new(),
135 editing_id: None,
136 temp_name: String::new(),
137 temp_working_dir: String::new(),
138 temp_shell: None,
139 temp_login_shell: None,
140 temp_command: String::new(),
141 temp_args: String::new(),
142 temp_tab_name: String::new(),
143 temp_icon: String::new(),
144 temp_tags: String::new(),
145 temp_parent_id: None,
146 temp_keyboard_shortcut: String::new(),
147 temp_hostname_patterns: String::new(),
148 temp_tmux_session_patterns: String::new(),
149 temp_directory_patterns: String::new(),
150 temp_badge_text: String::new(),
151 temp_badge_color: None,
152 temp_badge_color_alpha: None,
153 temp_badge_font: String::new(),
154 temp_badge_font_bold: None,
155 temp_badge_top_margin: None,
156 temp_badge_right_margin: None,
157 temp_badge_max_width: None,
158 temp_badge_max_height: None,
159 badge_section_expanded: false,
160 ssh_section_expanded: false,
161 temp_ssh_host: String::new(),
162 temp_ssh_user: String::new(),
163 temp_ssh_port: String::new(),
164 temp_ssh_identity_file: String::new(),
165 temp_ssh_extra_args: String::new(),
166 selected_id: None,
167 has_changes: false,
168 validation_error: None,
169 pending_delete: None,
170 }
171 }
172
173 pub fn open(&mut self, manager: &ProfileManager) {
175 self.visible = true;
176 self.mode = ModalMode::List;
177 self.working_profiles = manager.to_vec();
178 self.editing_id = None;
179 self.selected_id = None;
180 self.has_changes = false;
181 self.validation_error = None;
182 self.pending_delete = None;
183 self.clear_form();
184 log::info!(
185 "Profile modal opened with {} profiles",
186 self.working_profiles.len()
187 );
188 }
189
190 pub fn close(&mut self) {
192 self.visible = false;
193 self.mode = ModalMode::List;
194 self.working_profiles.clear();
195 self.editing_id = None;
196 self.pending_delete = None;
197 self.clear_form();
198 }
199
200 pub fn load_profiles(&mut self, profiles: Vec<Profile>) {
205 self.working_profiles = profiles;
206 self.mode = ModalMode::List;
207 self.editing_id = None;
208 self.selected_id = None;
209 self.has_changes = false;
210 self.validation_error = None;
211 self.pending_delete = None;
212 self.clear_form();
213 }
214
215 pub fn get_working_profiles(&self) -> &[Profile] {
217 &self.working_profiles
218 }
219
220 fn clear_form(&mut self) {
222 self.temp_name.clear();
223 self.temp_working_dir.clear();
224 self.temp_shell = None;
225 self.temp_login_shell = None;
226 self.temp_command.clear();
227 self.temp_args.clear();
228 self.temp_tab_name.clear();
229 self.temp_icon.clear();
230 self.temp_tags.clear();
231 self.temp_parent_id = None;
232 self.temp_keyboard_shortcut.clear();
233 self.temp_hostname_patterns.clear();
234 self.temp_tmux_session_patterns.clear();
235 self.temp_directory_patterns.clear();
236 self.temp_badge_text.clear();
237 self.temp_badge_color = None;
238 self.temp_badge_color_alpha = None;
239 self.temp_badge_font.clear();
240 self.temp_badge_font_bold = None;
241 self.temp_badge_top_margin = None;
242 self.temp_badge_right_margin = None;
243 self.temp_badge_max_width = None;
244 self.temp_badge_max_height = None;
245 self.temp_ssh_host.clear();
246 self.temp_ssh_user.clear();
247 self.temp_ssh_port.clear();
248 self.temp_ssh_identity_file.clear();
249 self.temp_ssh_extra_args.clear();
250 self.validation_error = None;
251 }
252
253 fn load_profile_to_form(&mut self, profile: &Profile) {
255 self.temp_name = profile.name.clone();
256 self.temp_working_dir = profile.working_directory.clone().unwrap_or_default();
257 self.temp_shell = profile.shell.clone();
258 self.temp_login_shell = profile.login_shell;
259 self.temp_command = profile.command.clone().unwrap_or_default();
260 self.temp_args = profile
261 .command_args
262 .as_ref()
263 .map(|args| args.join(" "))
264 .unwrap_or_default();
265 self.temp_tab_name = profile.tab_name.clone().unwrap_or_default();
266 self.temp_icon = profile.icon.clone().unwrap_or_default();
267 self.temp_tags = profile.tags.join(", ");
269 self.temp_parent_id = profile.parent_id;
270 self.temp_keyboard_shortcut = profile.keyboard_shortcut.clone().unwrap_or_default();
271 self.temp_hostname_patterns = profile.hostname_patterns.join(", ");
272 self.temp_tmux_session_patterns = profile.tmux_session_patterns.join(", ");
273 self.temp_directory_patterns = profile.directory_patterns.join(", ");
274 self.temp_badge_text = profile.badge_text.clone().unwrap_or_default();
275 self.temp_badge_color = profile.badge_color;
277 self.temp_badge_color_alpha = profile.badge_color_alpha;
278 self.temp_badge_font = profile.badge_font.clone().unwrap_or_default();
279 self.temp_badge_font_bold = profile.badge_font_bold;
280 self.temp_badge_top_margin = profile.badge_top_margin;
281 self.temp_badge_right_margin = profile.badge_right_margin;
282 self.temp_badge_max_width = profile.badge_max_width;
283 self.temp_badge_max_height = profile.badge_max_height;
284 self.temp_ssh_host = profile.ssh_host.clone().unwrap_or_default();
286 self.temp_ssh_user = profile.ssh_user.clone().unwrap_or_default();
287 self.temp_ssh_port = profile.ssh_port.map(|p| p.to_string()).unwrap_or_default();
288 self.temp_ssh_identity_file = profile.ssh_identity_file.clone().unwrap_or_default();
289 self.temp_ssh_extra_args = profile.ssh_extra_args.clone().unwrap_or_default();
290 }
291
292 fn form_to_profile(&self, id: ProfileId, order: usize) -> Profile {
294 let mut profile = Profile::with_id(id, self.temp_name.trim());
295 profile.order = order;
296
297 if !self.temp_working_dir.is_empty() {
298 profile.working_directory = Some(self.temp_working_dir.clone());
299 }
300 profile.shell = self.temp_shell.clone();
301 profile.login_shell = self.temp_login_shell;
302 if !self.temp_command.is_empty() {
303 profile.command = Some(self.temp_command.clone());
304 }
305 if !self.temp_args.is_empty() {
306 profile.command_args = Some(
308 self.temp_args
309 .split_whitespace()
310 .map(String::from)
311 .collect(),
312 );
313 }
314 if !self.temp_tab_name.is_empty() {
315 profile.tab_name = Some(self.temp_tab_name.clone());
316 }
317 if !self.temp_icon.is_empty() {
318 profile.icon = Some(self.temp_icon.clone());
319 }
320 if !self.temp_tags.is_empty() {
322 profile.tags = self
323 .temp_tags
324 .split(',')
325 .map(|s| s.trim().to_string())
326 .filter(|s| !s.is_empty())
327 .collect();
328 }
329 profile.parent_id = self.temp_parent_id;
330 if !self.temp_keyboard_shortcut.is_empty() {
331 profile.keyboard_shortcut = Some(self.temp_keyboard_shortcut.clone());
332 }
333 if !self.temp_hostname_patterns.is_empty() {
334 profile.hostname_patterns = self
335 .temp_hostname_patterns
336 .split(',')
337 .map(|s| s.trim().to_string())
338 .filter(|s| !s.is_empty())
339 .collect();
340 }
341 if !self.temp_tmux_session_patterns.is_empty() {
342 profile.tmux_session_patterns = self
343 .temp_tmux_session_patterns
344 .split(',')
345 .map(|s| s.trim().to_string())
346 .filter(|s| !s.is_empty())
347 .collect();
348 }
349 if !self.temp_directory_patterns.is_empty() {
350 profile.directory_patterns = self
351 .temp_directory_patterns
352 .split(',')
353 .map(|s| s.trim().to_string())
354 .filter(|s| !s.is_empty())
355 .collect();
356 }
357 if !self.temp_badge_text.is_empty() {
358 profile.badge_text = Some(self.temp_badge_text.clone());
359 }
360 profile.badge_color = self.temp_badge_color;
362 profile.badge_color_alpha = self.temp_badge_color_alpha;
363 if !self.temp_badge_font.is_empty() {
364 profile.badge_font = Some(self.temp_badge_font.clone());
365 }
366 profile.badge_font_bold = self.temp_badge_font_bold;
367 profile.badge_top_margin = self.temp_badge_top_margin;
368 profile.badge_right_margin = self.temp_badge_right_margin;
369 profile.badge_max_width = self.temp_badge_max_width;
370 profile.badge_max_height = self.temp_badge_max_height;
371 if !self.temp_ssh_host.is_empty() {
373 profile.ssh_host = Some(self.temp_ssh_host.clone());
374 }
375 if !self.temp_ssh_user.is_empty() {
376 profile.ssh_user = Some(self.temp_ssh_user.clone());
377 }
378 if !self.temp_ssh_port.is_empty() {
379 profile.ssh_port = self.temp_ssh_port.parse().ok();
380 }
381 if !self.temp_ssh_identity_file.is_empty() {
382 profile.ssh_identity_file = Some(self.temp_ssh_identity_file.clone());
383 }
384 if !self.temp_ssh_extra_args.is_empty() {
385 profile.ssh_extra_args = Some(self.temp_ssh_extra_args.clone());
386 }
387
388 profile
389 }
390
391 fn validate_form(&self) -> Option<String> {
393 if self.temp_name.trim().is_empty() {
394 return Some("Profile name is required".to_string());
395 }
396 None
397 }
398
399 fn start_edit(&mut self, id: ProfileId) {
401 if let Some(profile) = self.working_profiles.iter().find(|p| p.id == id).cloned() {
402 self.load_profile_to_form(&profile);
403 self.editing_id = Some(id);
404 self.mode = ModalMode::Edit(id);
405 }
406 }
407
408 fn start_create(&mut self) {
410 self.clear_form();
411 self.temp_name = "New Profile".to_string();
412 let new_id = uuid::Uuid::new_v4();
413 self.editing_id = Some(new_id);
414 self.mode = ModalMode::Create;
415 }
416
417 fn save_form(&mut self) {
419 if let Some(error) = self.validate_form() {
420 self.validation_error = Some(error);
421 return;
422 }
423
424 if let Some(id) = self.editing_id {
425 match &self.mode {
426 ModalMode::Create => {
427 let order = self.working_profiles.len();
428 let profile = self.form_to_profile(id, order);
429 self.working_profiles.push(profile);
430 log::info!("Created new profile: {}", self.temp_name);
431 }
432 ModalMode::Edit(edit_id) => {
433 if let Some(existing) =
434 self.working_profiles.iter().position(|p| p.id == *edit_id)
435 {
436 let order = self.working_profiles[existing].order;
437 let profile = self.form_to_profile(id, order);
438 self.working_profiles[existing] = profile;
439 log::info!("Updated profile: {}", self.temp_name);
440 }
441 }
442 ModalMode::List => {}
443 }
444 self.has_changes = true;
445 }
446
447 self.mode = ModalMode::List;
448 self.editing_id = None;
449 self.clear_form();
450 }
451
452 fn cancel_edit(&mut self) {
454 self.mode = ModalMode::List;
455 self.editing_id = None;
456 self.clear_form();
457 }
458
459 fn request_delete(&mut self, id: ProfileId, name: String) {
461 self.pending_delete = Some((id, name));
462 }
463
464 fn confirm_delete(&mut self) {
466 if let Some((id, name)) = self.pending_delete.take() {
467 self.working_profiles.retain(|p| p.id != id);
468 self.has_changes = true;
469 if self.selected_id == Some(id) {
470 self.selected_id = None;
471 }
472 log::info!("Deleted profile: {}", name);
473 }
474 }
475
476 fn cancel_delete(&mut self) {
478 self.pending_delete = None;
479 }
480
481 fn move_up(&mut self, id: ProfileId) {
483 if let Some(pos) = self.working_profiles.iter().position(|p| p.id == id)
484 && pos > 0
485 {
486 self.working_profiles.swap(pos, pos - 1);
487 for (i, p) in self.working_profiles.iter_mut().enumerate() {
489 p.order = i;
490 }
491 self.has_changes = true;
492 }
493 }
494
495 fn move_down(&mut self, id: ProfileId) {
497 if let Some(pos) = self.working_profiles.iter().position(|p| p.id == id)
498 && pos < self.working_profiles.len() - 1
499 {
500 self.working_profiles.swap(pos, pos + 1);
501 for (i, p) in self.working_profiles.iter_mut().enumerate() {
503 p.order = i;
504 }
505 self.has_changes = true;
506 }
507 }
508
509 pub fn show_inline(&mut self, ui: &mut egui::Ui) -> ProfileModalAction {
515 let action = match &self.mode.clone() {
516 ModalMode::List => self.render_list_view(ui),
517 ModalMode::Edit(_) | ModalMode::Create => {
518 self.render_edit_view(ui);
519 ProfileModalAction::None
520 }
521 };
522
523 if self.pending_delete.is_some() {
525 self.render_delete_confirmation(ui.ctx());
526 }
527
528 action
529 }
530
531 pub fn show(&mut self, ctx: &egui::Context) -> ProfileModalAction {
533 if !self.visible {
534 return ProfileModalAction::None;
535 }
536
537 let mut action = ProfileModalAction::None;
538
539 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
541 match &self.mode {
542 ModalMode::Edit(_) | ModalMode::Create => {
543 self.cancel_edit();
544 }
545 ModalMode::List => {
546 self.close();
547 return ProfileModalAction::Cancel;
548 }
549 }
550 }
551
552 let modal_size = egui::vec2(550.0, 580.0);
553
554 egui::Window::new("Manage Profiles")
555 .collapsible(false)
556 .resizable(false)
557 .order(egui::Order::Foreground)
558 .default_size(modal_size)
559 .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
560 .frame(
561 egui::Frame::window(&ctx.style())
562 .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 250))
563 .inner_margin(egui::Margin::same(16)),
564 )
565 .show(ctx, |ui| match &self.mode.clone() {
566 ModalMode::List => {
567 action = self.render_list_view(ui);
568 }
569 ModalMode::Edit(_) | ModalMode::Create => {
570 self.render_edit_view(ui);
571 }
572 });
573
574 if self.pending_delete.is_some() {
576 self.render_delete_confirmation(ctx);
577 }
578
579 action
580 }
581
582 fn render_delete_confirmation(&mut self, ctx: &egui::Context) {
584 let (_, profile_name) = self.pending_delete.as_ref().unwrap();
585 let name = profile_name.clone();
586
587 egui::Window::new("Confirm Delete")
588 .collapsible(false)
589 .resizable(false)
590 .order(egui::Order::Foreground)
591 .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
592 .frame(
593 egui::Frame::window(&ctx.style())
594 .fill(egui::Color32::from_rgba_unmultiplied(40, 40, 40, 255))
595 .inner_margin(egui::Margin::same(20)),
596 )
597 .show(ctx, |ui| {
598 ui.vertical_centered(|ui| {
599 ui.label(format!("Delete profile \"{}\"?", name));
600 ui.add_space(8.0);
601 ui.label(
602 egui::RichText::new("This action cannot be undone.")
603 .small()
604 .color(egui::Color32::GRAY),
605 );
606 ui.add_space(16.0);
607 ui.horizontal(|ui| {
608 if ui.button("Delete").clicked() {
609 self.confirm_delete();
610 }
611 if ui.button("Cancel").clicked() {
612 self.cancel_delete();
613 }
614 });
615 });
616 });
617 }
618
619 pub(crate) fn render_list_view(&mut self, ui: &mut egui::Ui) -> ProfileModalAction {
621 let mut action = ProfileModalAction::None;
622
623 ui.horizontal(|ui| {
625 ui.heading("Profiles");
626 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
627 if ui.button("+ New Profile").clicked() {
628 self.start_create();
629 }
630 });
631 });
632 ui.separator();
633
634 let available_height = ui.available_height() - 50.0; egui::ScrollArea::vertical()
637 .max_height(available_height)
638 .show(ui, |ui| {
639 if self.working_profiles.is_empty() {
640 ui.vertical_centered(|ui| {
641 ui.add_space(40.0);
642 ui.label(
643 egui::RichText::new("No profiles yet")
644 .italics()
645 .color(egui::Color32::GRAY),
646 );
647 ui.add_space(10.0);
648 ui.label("Click '+ New Profile' to create one");
649 });
650 } else {
651 for (idx, profile) in self.working_profiles.clone().iter().enumerate() {
652 let is_selected = self.selected_id == Some(profile.id);
653
654 ui.push_id(profile.id, |ui| {
656 let bg_color = if is_selected {
657 egui::Color32::from_rgba_unmultiplied(70, 100, 140, 150)
658 } else {
659 egui::Color32::TRANSPARENT
660 };
661
662 let frame = egui::Frame::NONE
663 .fill(bg_color)
664 .inner_margin(egui::Margin::symmetric(8, 4))
665 .corner_radius(4.0);
666
667 frame.show(ui, |ui| {
668 ui.horizontal(|ui| {
669 ui.add_enabled_ui(idx > 0, |ui| {
671 if ui.small_button("Up").clicked() {
672 self.move_up(profile.id);
673 }
674 });
675 ui.add_enabled_ui(
676 idx < self.working_profiles.len() - 1,
677 |ui| {
678 if ui.small_button("Dn").clicked() {
679 self.move_down(profile.id);
680 }
681 },
682 );
683
684 if let Some(icon) = &profile.icon {
686 ui.label(icon);
687 }
688 let name_response =
689 ui.selectable_label(is_selected, &profile.name);
690 if name_response.clicked() {
691 self.selected_id = Some(profile.id);
692 }
693 if name_response.double_clicked() {
694 self.start_edit(profile.id);
695 }
696
697 if profile.source.is_dynamic() {
699 ui.label(
700 egui::RichText::new("[dynamic]")
701 .color(egui::Color32::from_rgb(100, 180, 255))
702 .small(),
703 );
704 }
705
706 let is_dynamic = profile.source.is_dynamic();
708 ui.with_layout(
709 egui::Layout::right_to_left(egui::Align::Center),
710 |ui| {
711 ui.add_enabled_ui(!is_dynamic, |ui| {
713 if ui.small_button("๐").clicked() {
714 self.request_delete(
715 profile.id,
716 profile.name.clone(),
717 );
718 }
719 });
720 let edit_label =
722 if is_dynamic { "๐" } else { "โ" };
723 if ui.small_button(edit_label).clicked() {
724 self.start_edit(profile.id);
725 }
726 },
727 );
728 });
729 });
730 });
731 }
732 }
733 });
734
735 ui.separator();
737 ui.horizontal(|ui| {
738 if ui.button("Save").clicked() {
739 action = ProfileModalAction::Save;
740 self.visible = false;
743 }
744 if ui.button("Cancel").clicked() {
745 action = ProfileModalAction::Cancel;
746 self.close();
747 }
748
749 if self.has_changes {
750 ui.colored_label(egui::Color32::YELLOW, "* Unsaved changes");
751 }
752 });
753
754 action
755 }
756
757 pub(crate) fn render_edit_view(&mut self, ui: &mut egui::Ui) {
759 let is_dynamic_profile = self
761 .editing_id
762 .and_then(|id| self.working_profiles.iter().find(|p| p.id == id))
763 .is_some_and(|p| p.source.is_dynamic());
764
765 let title = match &self.mode {
766 ModalMode::Create => "Create Profile",
767 ModalMode::Edit(_) => {
768 if is_dynamic_profile {
769 "View Profile"
770 } else {
771 "Edit Profile"
772 }
773 }
774 _ => "Profile",
775 };
776
777 ui.heading(title);
778
779 if is_dynamic_profile {
781 ui.add_space(4.0);
782 ui.horizontal(|ui| {
783 ui.label(egui::RichText::new("โน").color(egui::Color32::from_rgb(100, 180, 255)));
784 ui.colored_label(
785 egui::Color32::from_rgb(100, 180, 255),
786 "This profile is managed by a remote source and cannot be edited locally.",
787 );
788 });
789 }
790
791 ui.separator();
792
793 egui::ScrollArea::vertical()
795 .max_height(ui.available_height() - 60.0)
796 .show(ui, |ui| {
797 if is_dynamic_profile {
799 ui.disable();
800 }
801
802 egui::Grid::new("profile_form")
803 .num_columns(2)
804 .spacing([10.0, 8.0])
805 .show(ui, |ui| {
806 ui.label("Name:");
808 ui.text_edit_singleline(&mut self.temp_name);
809 ui.end_row();
810
811 ui.label("Icon:");
812 ui.horizontal(|ui| {
813 ui.text_edit_singleline(&mut self.temp_icon);
814 let picker_label = if self.temp_icon.is_empty() {
815 "๐"
816 } else {
817 &self.temp_icon
818 };
819 let picker_btn = ui.button(picker_label);
820 egui::Popup::from_toggle_button_response(&picker_btn)
821 .close_behavior(
822 egui::PopupCloseBehavior::CloseOnClickOutside,
823 )
824 .show(|ui| {
825 ui.set_min_width(280.0);
826 egui::ScrollArea::vertical()
827 .max_height(300.0)
828 .show(ui, |ui| {
829 for (category, emojis) in EMOJI_PRESETS {
830 ui.label(
831 egui::RichText::new(*category)
832 .small()
833 .strong(),
834 );
835 ui.horizontal_wrapped(|ui| {
836 for emoji in *emojis {
837 let btn = ui.add_sized(
838 [28.0, 28.0],
839 egui::Button::new(*emoji)
840 .frame(false),
841 );
842 if btn.clicked() {
843 self.temp_icon =
844 emoji.to_string();
845 egui::Popup::close_all(
846 ui.ctx(),
847 );
848 }
849 }
850 });
851 ui.add_space(2.0);
852 }
853 ui.add_space(4.0);
854 if ui.button("Clear icon").clicked() {
855 self.temp_icon.clear();
856 egui::Popup::close_all(ui.ctx());
857 }
858 });
859 });
860 });
861 ui.end_row();
862
863 ui.label("Working Directory:");
864 ui.horizontal(|ui| {
865 ui.text_edit_singleline(&mut self.temp_working_dir);
866 if ui.small_button("Browse...").clicked()
867 && let Some(path) = rfd::FileDialog::new().pick_folder()
868 {
869 self.temp_working_dir = path.display().to_string();
870 }
871 });
872 ui.end_row();
873
874 ui.label("Shell:");
876 ui.horizontal(|ui| {
877 let shells = shell_detection::detected_shells();
878 let selected_label = self
879 .temp_shell
880 .as_ref()
881 .map(|path| {
882 shells
884 .iter()
885 .find(|s| s.path == *path)
886 .map(|s| s.name.clone())
887 .unwrap_or_else(|| path.clone())
888 })
889 .unwrap_or_else(|| "Default (inherit global)".to_string());
890
891 egui::ComboBox::from_id_salt("shell_selector")
892 .selected_text(&selected_label)
893 .show_ui(ui, |ui| {
894 if ui
896 .selectable_label(
897 self.temp_shell.is_none(),
898 "Default (inherit global)",
899 )
900 .clicked()
901 {
902 self.temp_shell = None;
903 }
904 ui.separator();
905 for shell in shells {
907 let is_selected = self
908 .temp_shell
909 .as_ref()
910 .is_some_and(|s| s == &shell.path);
911 if ui
912 .selectable_label(
913 is_selected,
914 format!("{} ({})", shell.name, shell.path),
915 )
916 .clicked()
917 {
918 self.temp_shell = Some(shell.path.clone());
919 }
920 }
921 });
922 });
923 ui.end_row();
924
925 ui.label("Login Shell:");
927 ui.horizontal(|ui| {
928 let mut use_custom = self.temp_login_shell.is_some();
929 if ui.checkbox(&mut use_custom, "").changed() {
930 if use_custom {
931 self.temp_login_shell = Some(true);
932 } else {
933 self.temp_login_shell = None;
934 }
935 }
936 if let Some(ref mut login) = self.temp_login_shell {
937 ui.checkbox(login, "Use login shell (-l)");
938 } else {
939 ui.label(
940 egui::RichText::new("(inherit global)")
941 .small()
942 .color(egui::Color32::GRAY),
943 );
944 }
945 });
946 ui.end_row();
947
948 ui.label("Command:");
949 ui.horizontal(|ui| {
950 ui.text_edit_singleline(&mut self.temp_command);
951 ui.label(
952 egui::RichText::new("(overrides shell)")
953 .small()
954 .color(egui::Color32::GRAY),
955 );
956 });
957 ui.end_row();
958
959 ui.label("Arguments:");
960 ui.horizontal(|ui| {
961 ui.text_edit_singleline(&mut self.temp_args);
962 ui.label(
963 egui::RichText::new("(space-separated)")
964 .small()
965 .color(egui::Color32::GRAY),
966 );
967 });
968 ui.end_row();
969
970 ui.label("Tab Name:");
971 ui.horizontal(|ui| {
972 ui.text_edit_singleline(&mut self.temp_tab_name);
973 ui.label(
974 egui::RichText::new("(optional)")
975 .small()
976 .color(egui::Color32::GRAY),
977 );
978 });
979 ui.end_row();
980 });
981
982 ui.add_space(12.0);
984 ui.separator();
985 ui.label(
986 egui::RichText::new("Enhanced Features")
987 .strong()
988 .color(egui::Color32::LIGHT_BLUE),
989 );
990 ui.add_space(4.0);
991
992 egui::Grid::new("profile_form_enhanced")
993 .num_columns(2)
994 .spacing([10.0, 8.0])
995 .show(ui, |ui| {
996 ui.label("Tags:");
998 ui.horizontal(|ui| {
999 ui.text_edit_singleline(&mut self.temp_tags);
1000 ui.label(
1001 egui::RichText::new("(comma-separated)")
1002 .small()
1003 .color(egui::Color32::GRAY),
1004 );
1005 });
1006 ui.end_row();
1007
1008 ui.label("Inherit From:");
1010 self.render_parent_selector(ui);
1011 ui.end_row();
1012
1013 ui.label("Keyboard Shortcut:");
1015 ui.horizontal(|ui| {
1016 ui.text_edit_singleline(&mut self.temp_keyboard_shortcut);
1017 ui.label(
1018 egui::RichText::new({
1019 #[cfg(target_os = "macos")]
1020 { "(e.g. Cmd+1)" }
1021 #[cfg(not(target_os = "macos"))]
1022 { "(e.g. Ctrl+Shift+1)" }
1023 })
1024 .small()
1025 .color(egui::Color32::GRAY),
1026 );
1027 });
1028 ui.end_row();
1029
1030 ui.label("Auto-Switch Hosts:");
1032 ui.horizontal(|ui| {
1033 ui.text_edit_singleline(&mut self.temp_hostname_patterns);
1034 ui.label(
1035 egui::RichText::new("(*.example.com)")
1036 .small()
1037 .color(egui::Color32::GRAY),
1038 );
1039 });
1040 ui.end_row();
1041
1042 ui.label("Auto-Switch Tmux:");
1044 ui.horizontal(|ui| {
1045 ui.text_edit_singleline(&mut self.temp_tmux_session_patterns);
1046 ui.label(
1047 egui::RichText::new("(work-*, *-dev)")
1048 .small()
1049 .color(egui::Color32::GRAY),
1050 );
1051 });
1052 ui.end_row();
1053
1054 ui.label("Auto-Switch Dirs:");
1056 ui.horizontal(|ui| {
1057 ui.text_edit_singleline(&mut self.temp_directory_patterns);
1058 ui.label(
1059 egui::RichText::new("(~/projects/work-*)")
1060 .small()
1061 .color(egui::Color32::GRAY),
1062 );
1063 });
1064 ui.end_row();
1065
1066 ui.label("Badge Text:");
1068 ui.horizontal(|ui| {
1069 ui.text_edit_singleline(&mut self.temp_badge_text);
1070 ui.label(
1071 egui::RichText::new("(overrides global)")
1072 .small()
1073 .color(egui::Color32::GRAY),
1074 );
1075 });
1076 ui.end_row();
1077 });
1078
1079 ui.add_space(8.0);
1081 egui::CollapsingHeader::new(
1082 egui::RichText::new("Badge Appearance")
1083 .strong()
1084 .color(egui::Color32::LIGHT_BLUE),
1085 )
1086 .default_open(self.badge_section_expanded)
1087 .show(ui, |ui| {
1088 self.badge_section_expanded = true;
1089 egui::Grid::new("profile_form_badge_appearance")
1090 .num_columns(2)
1091 .spacing([10.0, 8.0])
1092 .show(ui, |ui| {
1093 ui.label("Color:");
1095 ui.horizontal(|ui| {
1096 let mut use_custom = self.temp_badge_color.is_some();
1097 if ui.checkbox(&mut use_custom, "").changed() {
1098 if use_custom {
1099 self.temp_badge_color = Some([255, 0, 0]); } else {
1101 self.temp_badge_color = None;
1102 }
1103 }
1104 if let Some(ref mut color) = self.temp_badge_color {
1105 let mut egui_color =
1106 egui::Color32::from_rgb(color[0], color[1], color[2]);
1107 if egui::color_picker::color_edit_button_srgba(
1108 ui,
1109 &mut egui_color,
1110 egui::color_picker::Alpha::Opaque,
1111 )
1112 .changed()
1113 {
1114 *color = [egui_color.r(), egui_color.g(), egui_color.b()];
1115 }
1116 } else {
1117 ui.label(
1118 egui::RichText::new("(use global)")
1119 .small()
1120 .color(egui::Color32::GRAY),
1121 );
1122 }
1123 });
1124 ui.end_row();
1125
1126 ui.label("Opacity:");
1128 ui.horizontal(|ui| {
1129 let mut use_custom = self.temp_badge_color_alpha.is_some();
1130 if ui.checkbox(&mut use_custom, "").changed() {
1131 if use_custom {
1132 self.temp_badge_color_alpha = Some(0.5);
1133 } else {
1134 self.temp_badge_color_alpha = None;
1135 }
1136 }
1137 if let Some(ref mut alpha) = self.temp_badge_color_alpha {
1138 ui.add(egui::Slider::new(alpha, 0.0..=1.0).step_by(0.05));
1139 } else {
1140 ui.label(
1141 egui::RichText::new("(use global)")
1142 .small()
1143 .color(egui::Color32::GRAY),
1144 );
1145 }
1146 });
1147 ui.end_row();
1148
1149 ui.label("Font:");
1151 ui.horizontal(|ui| {
1152 ui.text_edit_singleline(&mut self.temp_badge_font);
1153 ui.label(
1154 egui::RichText::new("(blank = global)")
1155 .small()
1156 .color(egui::Color32::GRAY),
1157 );
1158 });
1159 ui.end_row();
1160
1161 ui.label("Bold:");
1163 ui.horizontal(|ui| {
1164 let mut use_custom = self.temp_badge_font_bold.is_some();
1165 if ui.checkbox(&mut use_custom, "").changed() {
1166 if use_custom {
1167 self.temp_badge_font_bold = Some(true);
1168 } else {
1169 self.temp_badge_font_bold = None;
1170 }
1171 }
1172 if let Some(ref mut bold) = self.temp_badge_font_bold {
1173 ui.checkbox(bold, "Bold text");
1174 } else {
1175 ui.label(
1176 egui::RichText::new("(use global)")
1177 .small()
1178 .color(egui::Color32::GRAY),
1179 );
1180 }
1181 });
1182 ui.end_row();
1183
1184 ui.label("Top Margin:");
1186 ui.horizontal(|ui| {
1187 let mut use_custom = self.temp_badge_top_margin.is_some();
1188 if ui.checkbox(&mut use_custom, "").changed() {
1189 if use_custom {
1190 self.temp_badge_top_margin = Some(0.0);
1191 } else {
1192 self.temp_badge_top_margin = None;
1193 }
1194 }
1195 if let Some(ref mut margin) = self.temp_badge_top_margin {
1196 ui.add(egui::DragValue::new(margin).range(0.0..=100.0).suffix(" px"));
1197 } else {
1198 ui.label(
1199 egui::RichText::new("(use global)")
1200 .small()
1201 .color(egui::Color32::GRAY),
1202 );
1203 }
1204 });
1205 ui.end_row();
1206
1207 ui.label("Right Margin:");
1209 ui.horizontal(|ui| {
1210 let mut use_custom = self.temp_badge_right_margin.is_some();
1211 if ui.checkbox(&mut use_custom, "").changed() {
1212 if use_custom {
1213 self.temp_badge_right_margin = Some(16.0);
1214 } else {
1215 self.temp_badge_right_margin = None;
1216 }
1217 }
1218 if let Some(ref mut margin) = self.temp_badge_right_margin {
1219 ui.add(egui::DragValue::new(margin).range(0.0..=100.0).suffix(" px"));
1220 } else {
1221 ui.label(
1222 egui::RichText::new("(use global)")
1223 .small()
1224 .color(egui::Color32::GRAY),
1225 );
1226 }
1227 });
1228 ui.end_row();
1229
1230 ui.label("Max Width:");
1232 ui.horizontal(|ui| {
1233 let mut use_custom = self.temp_badge_max_width.is_some();
1234 if ui.checkbox(&mut use_custom, "").changed() {
1235 if use_custom {
1236 self.temp_badge_max_width = Some(0.5);
1237 } else {
1238 self.temp_badge_max_width = None;
1239 }
1240 }
1241 if let Some(ref mut width) = self.temp_badge_max_width {
1242 ui.add(
1243 egui::Slider::new(width, 0.1..=1.0)
1244 .step_by(0.05)
1245 .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
1246 );
1247 } else {
1248 ui.label(
1249 egui::RichText::new("(use global)")
1250 .small()
1251 .color(egui::Color32::GRAY),
1252 );
1253 }
1254 });
1255 ui.end_row();
1256
1257 ui.label("Max Height:");
1259 ui.horizontal(|ui| {
1260 let mut use_custom = self.temp_badge_max_height.is_some();
1261 if ui.checkbox(&mut use_custom, "").changed() {
1262 if use_custom {
1263 self.temp_badge_max_height = Some(0.2);
1264 } else {
1265 self.temp_badge_max_height = None;
1266 }
1267 }
1268 if let Some(ref mut height) = self.temp_badge_max_height {
1269 ui.add(
1270 egui::Slider::new(height, 0.05..=0.5)
1271 .step_by(0.05)
1272 .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
1273 );
1274 } else {
1275 ui.label(
1276 egui::RichText::new("(use global)")
1277 .small()
1278 .color(egui::Color32::GRAY),
1279 );
1280 }
1281 });
1282 ui.end_row();
1283 });
1284
1285 ui.add_space(4.0);
1286 ui.label(
1287 egui::RichText::new("Check boxes to override global badge settings for this profile.")
1288 .small()
1289 .color(egui::Color32::GRAY),
1290 );
1291 });
1292
1293 ui.add_space(8.0);
1295 egui::CollapsingHeader::new(
1296 egui::RichText::new("SSH Connection")
1297 .strong()
1298 .color(egui::Color32::LIGHT_BLUE),
1299 )
1300 .default_open(self.ssh_section_expanded)
1301 .show(ui, |ui| {
1302 self.ssh_section_expanded = true;
1303 ui.horizontal(|ui| {
1304 ui.label("Host:");
1305 ui.text_edit_singleline(&mut self.temp_ssh_host);
1306 });
1307 ui.horizontal(|ui| {
1308 ui.label("User:");
1309 ui.text_edit_singleline(&mut self.temp_ssh_user);
1310 });
1311 ui.horizontal(|ui| {
1312 ui.label("Port:");
1313 ui.add(egui::TextEdit::singleline(&mut self.temp_ssh_port).desired_width(60.0));
1314 });
1315 ui.horizontal(|ui| {
1316 ui.label("Identity File:");
1317 ui.text_edit_singleline(&mut self.temp_ssh_identity_file);
1318 });
1319 ui.horizontal(|ui| {
1320 ui.label("Extra Args:");
1321 ui.text_edit_singleline(&mut self.temp_ssh_extra_args);
1322 });
1323 ui.add_space(4.0);
1324 ui.label(
1325 egui::RichText::new("When SSH Host is set, opening this profile connects via SSH instead of launching a shell.")
1326 .weak()
1327 .size(11.0),
1328 );
1329 });
1330
1331 if let Some(error) = &self.validation_error {
1333 ui.add_space(8.0);
1334 ui.colored_label(egui::Color32::RED, error);
1335 }
1336
1337 ui.add_space(16.0);
1339 ui.label(
1340 egui::RichText::new(
1341 "Note: Inherited settings from parent profiles are used when this profile's field is empty.",
1342 )
1343 .small()
1344 .color(egui::Color32::GRAY),
1345 );
1346 });
1347
1348 ui.add_space(8.0);
1350 ui.separator();
1351 ui.horizontal(|ui| {
1352 if is_dynamic_profile {
1353 if ui.button("Back").clicked() {
1355 self.cancel_edit();
1356 }
1357 } else {
1358 if ui.button("Save Profile").clicked() {
1359 self.save_form();
1360 }
1361 if ui.button("Cancel").clicked() {
1362 self.cancel_edit();
1363 }
1364 }
1365 });
1366 }
1367
1368 fn has_ancestor(&self, profile_id: ProfileId, ancestor_id: ProfileId) -> bool {
1370 let mut current_id = profile_id;
1371 let mut visited = vec![current_id];
1372 while let Some(parent_id) = self
1373 .working_profiles
1374 .iter()
1375 .find(|p| p.id == current_id)
1376 .and_then(|p| p.parent_id)
1377 {
1378 if parent_id == ancestor_id {
1379 return true;
1380 }
1381 if visited.contains(&parent_id) {
1382 return false;
1383 }
1384 visited.push(parent_id);
1385 current_id = parent_id;
1386 }
1387 false
1388 }
1389
1390 fn render_parent_selector(&mut self, ui: &mut egui::Ui) {
1392 let current_id = self.editing_id;
1394 let valid_parents: Vec<_> = self
1395 .working_profiles
1396 .iter()
1397 .filter(|p| {
1398 if Some(p.id) == current_id {
1400 return false;
1401 }
1402 if let Some(cid) = current_id
1404 && self.has_ancestor(p.id, cid)
1405 {
1406 return false;
1407 }
1408 true
1409 })
1410 .map(|p| (p.id, p.display_label()))
1411 .collect();
1412
1413 let selected_label = self
1414 .temp_parent_id
1415 .and_then(|id| self.working_profiles.iter().find(|p| p.id == id))
1416 .map(|p| p.display_label())
1417 .unwrap_or_else(|| "(None)".to_string());
1418
1419 egui::ComboBox::from_id_salt("parent_profile_selector")
1420 .selected_text(&selected_label)
1421 .show_ui(ui, |ui| {
1422 if ui
1424 .selectable_label(self.temp_parent_id.is_none(), "(None)")
1425 .clicked()
1426 {
1427 self.temp_parent_id = None;
1428 }
1429 for (id, label) in valid_parents {
1431 if ui
1432 .selectable_label(self.temp_parent_id == Some(id), &label)
1433 .clicked()
1434 {
1435 self.temp_parent_id = Some(id);
1436 }
1437 }
1438 });
1439 }
1440}
1441
1442impl Default for ProfileModalUI {
1443 fn default() -> Self {
1444 Self::new()
1445 }
1446}