//! The connection sidebar: Local Machine, Favourites, Recent, folders, Ungrouped; fuzzy search; //! click to connect, Ctrl/Cmd-click to multi-select, double-click to edit; bulk actions. use crate::app::{App, Dialog, InputThen}; use crate::chrome::fuzzy_score; use crate::state::Request; use crate::theme::{self, Tokens}; use crate::views::{protocol_badge, protocol_color}; use crate::widgets; use egui::{Align2, Rect, RichText, Sense, Ui, vec2}; use egui_phosphor::regular as icon; use nexus_core::models::Profile; use std::collections::HashSet; #[derive(Default)] pub struct Sidebar { pub search: String, pub collapsed: HashSet, pub selected: HashSet, } const RECENT: usize = 5; pub fn ui(app: &mut App, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(8.0); ui.label(RichText::new(icon::MAGNIFYING_GLASS).color(t.text_faint)); ui.add(egui::TextEdit::singleline(&mut app.sidebar.search).hint_text("Search connections").desired_width(ui.available_width() - 10.0)); }); ui.add_space(4.0); // Footer actions, pinned to the bottom of the sidebar. egui::Panel::bottom("sidebar-footer").exact_size(36.0).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| { ui.horizontal_centered(|ui| { ui.add_space(6.0); if widgets::icon_button(ui, icon::PLUS, "New connection (Ctrl/Cmd+N)").clicked() { app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); } if widgets::icon_button(ui, icon::COMMAND, "Command palette (Ctrl/Cmd+K)").clicked() { app.palette = Some(Default::default()); } if widgets::icon_button(ui, icon::DOWNLOAD_SIMPLE, "Import profiles").clicked() { app.s.request(Request::ImportEncrypted); } if widgets::icon_button(ui, icon::UPLOAD_SIMPLE, "Export profiles").clicked() { app.s.request(Request::ExportEncrypted(vec![])); } if widgets::icon_button(ui, icon::FILM_SLATE, "Recordings").clicked() { app.recordings.open(); } if widgets::icon_button(ui, icon::GEAR, "Settings (Ctrl/Cmd+,)").clicked() { app.settings.open(crate::state::SettingsTab::General); } }); }); if !app.sidebar.selected.is_empty() { egui::Panel::bottom("sidebar-bulk").frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)).show(ui, |ui| bulk_bar(app, ui)); } egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| list(app, ui)); } fn list(app: &mut App, ui: &mut Ui) { let q = app.sidebar.search.trim().to_string(); // Search: one ranked list. if !q.is_empty() { let mut hits: Vec<(i32, Profile)> = app .s .profiles .iter() .filter_map(|p| { let fields = format!("{} {} {} {}", p.display_name, p.host, p.tags.join(" "), p.protocol.id()); fuzzy_score(&fields, &q).map(|s| (s, p.clone())) }) .collect(); hits.sort_by_key(|h| std::cmp::Reverse(h.0)); if hits.is_empty() { widgets::empty(ui, icon::MAGNIFYING_GLASS, "No matches", "Try a host, name, tag or protocol."); } for (_, p) in hits { row(app, ui, &p, "search"); } return; } local_row(app, ui); let profiles = app.s.profiles.clone(); let favs: Vec<&Profile> = profiles.iter().filter(|p| p.favourite).collect(); if !favs.is_empty() { section(app, ui, "★ Favourites", "favourites", &favs); } let mut recent: Vec<&Profile> = profiles.iter().filter(|p| p.last_connected.is_some()).collect(); recent.sort_by_key(|p| std::cmp::Reverse(p.last_connected)); recent.truncate(RECENT); if !recent.is_empty() { section(app, ui, "Recent", "recent", &recent); } let mut folders: std::collections::BTreeMap> = Default::default(); let mut ungrouped = vec![]; for p in &profiles { let f = p.folder.trim(); if f.is_empty() { ungrouped.push(p); } else { folders.entry(f.to_string()).or_default().push(p); } } let mut names: Vec<&String> = folders.keys().collect(); names.sort_by_key(|a| a.to_lowercase()); let has_groups = !names.is_empty(); for f in names { section(app, ui, f, &format!("group:{f}"), &folders[f]); } if !ungrouped.is_empty() { section(app, ui, if has_groups { "Ungrouped" } else { "All Connections" }, "ungrouped", &ungrouped); } if profiles.is_empty() { widgets::empty(ui, icon::PLUGS, "No connections yet", "Create one with the + button below."); ui.vertical_centered(|ui| { if widgets::primary(ui, icon::PLUS, "New connection").clicked() { app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); } }); } } fn header(app: &mut App, ui: &mut Ui, title: &str, key: &str, count: usize) -> bool { let t = Tokens::get(ui.ctx()); let folded = app.sidebar.collapsed.contains(key); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); let c = if resp.hovered() { t.text_dim } else { t.text_faint }; let caret = if folded { icon::CARET_RIGHT } else { icon::CARET_DOWN }; ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, caret, egui::FontId::proportional(10.0), c); ui.painter().text(r.left_center() + vec2(24.0, 0.0), Align2::LEFT_CENTER, title.to_uppercase(), theme::semibold(10.5), c); ui.painter().text(r.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, count.to_string(), egui::FontId::proportional(10.5), t.text_faint); if resp.clicked() { if folded { app.sidebar.collapsed.remove(key); } else { app.sidebar.collapsed.insert(key.into()); } } // Right-click a folder to add a connection to it. if let Some(folder) = key.strip_prefix("group:") { resp.context_menu(|ui| { if ui.button(format!("{} New connection in {folder}", icon::PLUS)).clicked() { app.s.request(Request::NewProfile { protocol: None, folder: folder.into() }); ui.close(); } }); } !folded } fn section(app: &mut App, ui: &mut Ui, title: &str, key: &str, list: &[&Profile]) { ui.add_space(4.0); if header(app, ui, title, key, list.len()) { for p in list { row(app, ui, p, key); } } } fn local_row(app: &mut App, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); let r = r.shrink2(vec2(6.0, 1.0)); if resp.hovered() { ui.painter().rect_filled(r, 6.0, t.hover); } ui.painter().text( r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, icon::TERMINAL_WINDOW, egui::FontId::proportional(15.0), crate::views::protocol_color(nexus_core::models::Protocol::Local), ); ui.painter().text(r.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, "Local Machine", egui::FontId::proportional(13.0), t.text); if resp.on_hover_text("Open a terminal on this computer").clicked() { app.connect_local(); } } fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) { let t = Tokens::get(ui.ctx()); let selected = app.sidebar.selected.contains(&p.id); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click()); let r = r.shrink2(vec2(6.0, 1.0)); if selected { ui.painter().rect_filled(r, 6.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 6.0, t.hover); } let live = app.s.panes.values().any(|pn| pn.info.profile_id == p.id); let dot = Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(8.0, 8.0)); let col = protocol_color(p.protocol); if live { ui.painter().circle_filled(dot.center(), 4.0, col); } else { ui.painter().circle_stroke(dot.center(), 3.5, egui::Stroke::new(1.5, col)); } let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; let text_r = Rect::from_min_max(r.min + vec2(24.0, 0.0), r.max - vec2(46.0, 0.0)); let painter = ui.painter().with_clip_rect(text_r); // Name, then a clear gap, then user@host; sized from the name's real row height so the two // lines never crowd each other. let name_font = egui::FontId::proportional(13.0); let name_h = ui.fonts_mut(|f| f.row_height(&name_font)); painter.text(text_r.left_top() + vec2(0.0, 4.0), Align2::LEFT_TOP, name, name_font, t.text); let sub = if p.username.is_empty() || p.protocol == nexus_core::models::Protocol::Serial { p.host.clone() } else { format!("{}@{}", p.username, p.host) }; painter.text(text_r.left_top() + vec2(0.0, 4.0 + name_h + 3.0), Align2::LEFT_TOP, sub, egui::FontId::proportional(11.0), t.text_faint); ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, protocol_badge(p.protocol), theme::semibold(9.0), col); if p.favourite { ui.painter().text(r.right_center() - vec2(36.0, 0.0), Align2::RIGHT_CENTER, "★", egui::FontId::proportional(11.0), t.warning); } let tip = format!( "{}\n{} {}:{}{}", name, protocol_badge(p.protocol), p.host, p.port, if p.description.is_empty() { String::new() } else { format!("\n{}", p.description) } ); let resp = resp.on_hover_text(tip); let multi = ui.input(|i| i.modifiers.command); if resp.double_clicked() { app.s.request(Request::EditProfile(p.id.clone())); } else if resp.clicked() { if multi { if !app.sidebar.selected.remove(&p.id) { app.sidebar.selected.insert(p.id.clone()); } } else { app.sidebar.selected.clear(); app.connect_profile(&p.id, false); } } resp.context_menu(|ui| { let _ = section; if ui.button(format!("{} Connect", icon::PLUGS_CONNECTED)).clicked() { app.connect_profile(&p.id, false); ui.close(); } if ui.button(format!("{} Edit", icon::PENCIL_SIMPLE)).clicked() { app.s.request(Request::EditProfile(p.id.clone())); ui.close(); } if ui.button(format!("{} Duplicate", icon::COPY)).clicked() { match app.s.svc.duplicate_profile(&p.id) { Ok(d) => app.s.success(format!("Created {}", d.display_name)), Err(e) => app.s.error(e.to_string()), } app.s.reload_profiles(); ui.close(); } let fav = if p.favourite { "Remove from Favourites" } else { "Add to Favourites" }; if ui.button(format!("{} {fav}", icon::STAR)).clicked() { let _ = app.s.svc.set_favourite(&p.id, !p.favourite); app.s.reload_profiles(); ui.close(); } if ui.button(format!("{} Export…", icon::UPLOAD_SIMPLE)).clicked() { app.s.request(Request::ExportEncrypted(vec![p.id.clone()])); ui.close(); } ui.separator(); if ui.button(RichText::new(format!("{} Delete", icon::TRASH)).color(t.danger)).clicked() { app.dialog = Some(Dialog::Delete { profiles: vec![p.id.clone()] }); ui.close(); } }); } fn bulk_bar(app: &mut App, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let ids: Vec = app.sidebar.selected.iter().cloned().collect(); egui::Frame::NONE.fill(t.card).corner_radius(6).inner_margin(6).show(ui, |ui| { ui.label(RichText::new(format!("{} selected", ids.len())).size(11.5).color(t.text_dim)); ui.horizontal_wrapped(|ui| { if ui.small_button(format!("{} Connect all", icon::PLUGS_CONNECTED)).clicked() { for id in &ids { app.connect_profile(id, true); } app.sidebar.selected.clear(); } if ui.small_button(format!("{} Disconnect", icon::PLUGS)).clicked() { let sessions: Vec = app.s.panes.values().filter(|p| ids.contains(&p.info.profile_id)).map(|p| p.info.id.clone()).collect(); if sessions.is_empty() { app.s.info("None of the selected connections are open"); } else if app.s.prefs.confirm_on_disconnect { app.dialog = Some(Dialog::ConfirmDisconnectMany { sessions }); } else { for s in sessions { app.close_session(&s); } } } ui.menu_button(format!("{} Tags", icon::TAG), |ui| { if ui.button("Add tags…").clicked() { app.dialog = Some(Dialog::Input { title: "Add tags".into(), label: "Comma-separated tags to add".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: false }, }); ui.close(); } if ui.button("Replace tags…").clicked() { app.dialog = Some(Dialog::Input { title: "Replace tags".into(), label: "Comma-separated tags".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: true }, }); ui.close(); } }); if ui.small_button(format!("{} Move group", icon::FOLDER)).clicked() { app.dialog = Some(Dialog::Input { title: "Move to group".into(), label: "Group name (empty for Ungrouped)".into(), value: String::new(), then: InputThen::MoveGroup { profiles: ids.clone() }, }); } if ui.small_button(format!("{} Delete", icon::TRASH)).clicked() { app.dialog = Some(Dialog::Delete { profiles: ids.clone() }); } if ui.small_button("Clear").clicked() { app.sidebar.selected.clear(); } }); }); }