Files
nexus/rust/app/src/chrome.rs
T

1027 lines
47 KiB
Rust

//! Window chrome: custom title bar, status bar, command palette, credential and host-key
//! prompts, the main window's modal dialogs, toasts and resize edges.
use crate::app::{App, Dialog, InputThen, Prompt};
use crate::state::{Request, SettingsTab, ToastKind, keys};
use crate::theme::{self, ThemeKind, Tokens};
use crate::views::View;
use crate::widgets;
use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use egui_phosphor::regular as icon;
use nexus_core::models::{Protocol, SessionState};
pub const LOGO_PNG: &[u8] = include_bytes!("../assets/icon/icon-256.png");
/// The Nexus mark, uploaded once.
pub fn logo(ctx: &egui::Context) -> egui::TextureHandle {
let id = egui::Id::new("nexus-logo");
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(id)) {
return t;
}
let img = image::load_from_memory(LOGO_PNG).map(|i| i.to_rgba8()).unwrap_or_default();
let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
let opts = egui::TextureOptions { mipmap_mode: Some(egui::TextureFilter::Linear), ..egui::TextureOptions::LINEAR };
let tex = ctx.load_texture("nexus-logo", color, opts);
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
tex
}
fn brand(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.add(egui::Image::new(&logo(ui.ctx())).fit_to_exact_size(vec2(22.0, 22.0)));
ui.label(RichText::new("Nexus").font(theme::semibold(13.5)).color(t.accent));
ui.add_space(-6.0);
ui.label(RichText::new("Terminal").font(theme::semibold(13.5)));
}
fn item(ui: &mut Ui, glyph: &str, label: &str, shortcut: &str) -> bool {
let clicked = ui.add(egui::Button::new(format!("{glyph} {label}")).shortcut_text(keys(shortcut)).min_size(vec2(230.0, 0.0))).clicked();
if clicked {
ui.close();
}
clicked
}
pub fn title_bar(app: &mut App, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let bar = ui.max_rect();
let drag = ui.interact(bar, ui.id().with("titlebar"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(ViewportCommand::Maximized(!max));
}
let mut menus_right = bar.left();
ui.horizontal_centered(|ui| {
ui.add_space(10.0);
brand(ui);
ui.add_space(8.0);
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
if item(ui, icon::PLUS_CIRCLE, "New connection…", "Mod+N") {
app.s.request(Request::NewProfile { protocol: None, folder: String::new() });
}
if item(ui, icon::LIGHTNING, "Quick connect…", "Mod+Shift+Q") {
app.quick.open();
}
if item(ui, icon::TERMINAL_WINDOW, "Local terminal", "") {
app.connect_local();
}
ui.separator();
if item(ui, icon::DOWNLOAD_SIMPLE, "Import profiles…", "") {
app.s.request(Request::ImportEncrypted);
}
if item(ui, icon::UPLOAD_SIMPLE, "Export profiles…", "") {
app.s.request(Request::ExportEncrypted(vec![]));
}
if item(ui, icon::FILE_ARROW_DOWN, "Import ~/.ssh/config", "") {
match app.s.svc.import_ssh_config(None, "SSH Config") {
Ok(n) => {
app.s.success(format!("Imported {n} host(s) from ~/.ssh/config"));
app.s.reload_profiles();
}
Err(e) => app.s.error(format!("Import failed: {e}")),
}
}
ui.separator();
if item(ui, icon::GEAR, "Settings", "Mod+,") {
app.settings.open(SettingsTab::General);
}
if item(ui, icon::SIGN_OUT, "Quit", "") {
ui.ctx().send_viewport_cmd(ViewportCommand::Close);
}
});
ui.menu_button("Session", |ui| {
let focused = app.s.focused_session();
let ssh = focused.as_deref().and_then(|f| app.s.pane(f)).is_some_and(|p| p.info.protocol == Protocol::Ssh);
if item(ui, icon::MAGNIFYING_GLASS, "Find in terminal", "Mod+F") {
app.open_find();
}
if item(ui, icon::SQUARE_SPLIT_HORIZONTAL, "Split right", "Mod+Shift+D") {
crate::splits::split_focused(app, crate::splits::Dir::Right);
}
if item(ui, icon::SQUARE_SPLIT_VERTICAL, "Split down", "Mod+Shift+E") {
crate::splits::split_focused(app, crate::splits::Dir::Down);
}
ui.separator();
if item(ui, icon::BROADCAST, if app.s.broadcast { "Stop broadcast" } else { "Broadcast input" }, "Mod+Shift+B") {
app.toggle_broadcast();
}
if item(ui, icon::USERS_THREE, "Broadcast targets…", "") {
app.pick_broadcast_targets();
}
if item(ui, icon::RECORD, "Toggle recording", "Mod+Shift+R")
&& let Some(f) = focused.clone()
{
app.toggle_recording(&f);
}
if item(ui, icon::CODE, "Snippets", "Mod+Shift+S") {
app.snippets.open();
}
if item(ui, icon::LIGHTNING_A, "Trigger rules", "") {
app.triggers.open();
}
if item(ui, icon::FILM_STRIP, "Recordings", "") {
app.recordings.open();
}
ui.separator();
ui.add_enabled_ui(ssh, |ui| {
if item(ui, icon::FOLDER_OPEN, "SFTP browser", "")
&& let Some(f) = focused.clone()
{
app.sftp.toggle(&f);
}
if item(ui, icon::ARROWS_LEFT_RIGHT, "Port forwards", "")
&& let Some(f) = focused.clone()
{
app.forwards.open(&f);
}
});
if item(ui, icon::KEY, "SSH key manager", "") {
app.keys.open();
}
});
ui.menu_button("View", |ui| {
if item(ui, icon::COMMAND, "Command palette", "Mod+K") {
app.palette = Some(Palette::default());
}
if item(ui, icon::CORNERS_OUT, "Full screen", "F11") {
let full = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false));
ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!full));
}
ui.separator();
for k in ThemeKind::ALL {
let mark = if k == app.theme { icon::CHECK } else { " " };
if item(ui, mark, k.label(), "") {
set_theme(app, k);
}
}
ui.menu_button(format!("{} Terminal colours", icon::PALETTE), |ui| {
for th in crate::terminal_themes::THEMES {
let mark = if th.id == app.s.prefs.terminal_theme { icon::CHECK } else { " " };
if item(ui, mark, th.label, "") {
app.s.prefs.terminal_theme = th.id.into();
let _ = app.s.svc.save_preferences(&app.s.prefs);
}
}
});
});
ui.menu_button("Help", |ui| {
if item(ui, icon::BOOK_OPEN, "Help", "") {
app.settings.open(SettingsTab::Help);
}
if item(ui, icon::INFO, "About Nexus Terminal", "") {
app.settings.open(SettingsTab::About);
}
});
});
menus_right = ui.min_rect().right();
});
let mut x = bar.right() - 3.0 * 44.0;
window_controls(ui, Rect::from_min_max(pos2(x, bar.top()), bar.max));
x -= 34.0;
let (next, glyph) = if app.theme == ThemeKind::Light { (ThemeKind::Pro, icon::MOON) } else { (ThemeKind::Light, icon::SUN) };
let tr = Rect::from_center_size(pos2(x + 14.0, bar.center().y), vec2(24.0, 24.0));
if widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(tr)), glyph, "Toggle light / dark").clicked() {
set_theme(app, next);
}
let w = (bar.width() * 0.22).clamp(140.0, 300.0);
x -= w + 8.0;
let r = Rect::from_min_size(pos2(x, bar.center().y - 12.0), vec2(w, 24.0));
let resp = ui.interact(r, ui.id().with("palette-launch"), Sense::click());
ui.painter().rect(r, 6.0, if resp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
ui.painter().text(r.left_center() + vec2(9.0, 0.0), Align2::LEFT_CENTER, icon::MAGNIFYING_GLASS, egui::FontId::proportional(13.0), t.text_faint);
let show_keys = w > 250.0;
let clip = Rect::from_min_max(r.min, pos2(r.right() - if show_keys { 52.0 } else { 4.0 }, r.bottom()));
let hint = if w > 290.0 { "Search connections and commands" } else { "Search" };
ui.painter().with_clip_rect(clip).text(r.left_center() + vec2(28.0, 0.0), Align2::LEFT_CENTER, hint, egui::FontId::proportional(12.0), t.text_faint);
if show_keys {
ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, keys("Mod+K").replace('+', " "), theme::semibold(10.5), t.text_faint);
}
if resp.clicked() {
app.palette = Some(Palette::default());
}
if let Some(p) = app.s.active.as_deref().and_then(|a| app.s.pane(a)) {
let title = format!("{} — {}", p.info.tab_name, p.info.host);
let gap = Rect::from_min_max(pos2(menus_right + 16.0, bar.top()), pos2(x - 16.0, bar.bottom()));
if gap.width() > 60.0 {
ui.painter().with_clip_rect(gap).text(gap.center(), Align2::CENTER_CENTER, title, egui::FontId::proportional(12.0), t.text_dim);
}
}
}
pub fn set_theme(app: &mut App, k: ThemeKind) {
app.theme = k;
theme::apply(&app.s.ctx, k);
app.s.prefs.theme = k.id().into();
let _ = app.s.svc.save_preferences(&app.s.prefs);
}
fn window_controls(ui: &mut Ui, area: Rect) {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
for (k, (glyph, close)) in [(icon::MINUS, false), (if max { icon::CORNERS_IN } else { icon::SQUARE }, false), (icon::X, true)].into_iter().enumerate() {
let r = Rect::from_min_size(pos2(area.left() + k as f32 * 44.0, area.top()), vec2(44.0, area.height()));
let resp = ui.interact(r, ui.id().with(("winctl", k)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, if close { Color32::from_rgb(196, 43, 28) } else { t.hover });
}
let c = if close && resp.hovered() { Color32::WHITE } else { t.text_dim };
ui.painter().text(r.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(14.0), c);
if resp.clicked() {
match k {
0 => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
1 => ctx.send_viewport_cmd(ViewportCommand::Maximized(!max)),
_ => ctx.send_viewport_cmd(ViewportCommand::Close),
}
}
}
}
/// Invisible window edges that resize the undecorated window.
pub fn resize_edges(ctx: &egui::Context) {
use egui::{CursorIcon, ResizeDirection as D};
if ctx.input(|i| i.viewport().maximized.unwrap_or(false) || i.viewport().fullscreen.unwrap_or(false)) {
return;
}
let r = ctx.content_rect();
let e = 5.0;
let zones = [
(Rect::from_min_max(r.min, r.min + vec2(12.0, 12.0)), D::NorthWest, CursorIcon::ResizeNorthWest),
(Rect::from_min_max(pos2(r.right() - 12.0, r.top()), pos2(r.right(), r.top() + 12.0)), D::NorthEast, CursorIcon::ResizeNorthEast),
(Rect::from_min_max(pos2(r.left(), r.bottom() - 12.0), pos2(r.left() + 12.0, r.bottom())), D::SouthWest, CursorIcon::ResizeSouthWest),
(Rect::from_min_max(r.max - vec2(12.0, 12.0), r.max), D::SouthEast, CursorIcon::ResizeSouthEast),
(Rect::from_min_max(r.min, pos2(r.right(), r.top() + e)), D::North, CursorIcon::ResizeVertical),
(Rect::from_min_max(pos2(r.left(), r.bottom() - e), r.max), D::South, CursorIcon::ResizeVertical),
(Rect::from_min_max(r.min, pos2(r.left() + e, r.bottom())), D::West, CursorIcon::ResizeHorizontal),
(Rect::from_min_max(pos2(r.right() - e, r.top()), r.max), D::East, CursorIcon::ResizeHorizontal),
];
let Some(p) = ctx.input(|i| i.pointer.hover_pos()) else {
return;
};
for (zone, dir, cursor) in zones {
if zone.contains(p) {
ctx.set_cursor_icon(cursor);
if ctx.input(|i| i.pointer.primary_pressed()) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
}
return;
}
}
}
// --- status bar -----------------------------------------------------------------------------
pub fn state_color(t: &Tokens, s: SessionState) -> Color32 {
match s {
SessionState::Connected => t.success,
SessionState::Connecting | SessionState::Reconnecting => t.warning,
SessionState::Error => t.danger,
SessionState::Disconnected => t.text_faint,
}
}
pub fn state_label(s: SessionState) -> &'static str {
match s {
SessionState::Connecting => "Connecting",
SessionState::Connected => "Connected",
SessionState::Disconnected => "Disconnected",
SessionState::Reconnecting => "Reconnecting",
SessionState::Error => "Error",
}
}
pub fn status_bar(app: &mut App, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
latency_tick(app);
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 14.0;
ui.add_space(8.0);
let dim = |s: String| RichText::new(s).size(11.5).color(t.text_dim);
let active = app.s.active.clone();
match active.as_deref().and_then(|a| app.s.pane(a)) {
Some(p) => {
let st = app.s.session_state(&p.info.id).unwrap_or(p.info.state);
ui.label(RichText::new("●").color(state_color(&t, st)).size(10.0));
ui.add_space(-10.0);
ui.label(dim(state_label(st).into()));
ui.label(RichText::new(crate::views::protocol_badge(p.info.protocol)).size(11.0).color(crate::views::protocol_color(p.info.protocol)));
if p.recording {
ui.label(RichText::new(format!("{} REC", icon::RECORD)).size(11.0).color(t.danger));
}
}
None => {
ui.label(dim("No active session".into()));
}
}
if let Some((sid, r)) = &app.latency
&& active.as_deref() == Some(sid)
&& let Ok(ms) = r
{
let c = if *ms < 100 {
t.success
} else if *ms < 250 {
t.warning
} else {
t.danger
};
ui.label(RichText::new(format!("{ms} ms")).size(11.5).color(c));
}
if app.s.broadcast {
ui.label(RichText::new(format!("{} BROADCAST", icon::BROADCAST)).size(11.0).color(t.warning));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
let vault = app.s.svc.secrets.vault.is_unlocked();
let keychain = app.s.svc.secrets.keychain_ok;
let label = if vault { format!("{} Vault unlocked", icon::LOCK_OPEN) } else { format!("{} Vault locked", icon::LOCK) };
if ui
.add(egui::Label::new(dim(label)).sense(Sense::click()))
.on_hover_text(if keychain {
"Saved credentials use the OS keychain; the vault is the fallback."
} else {
"No OS keychain available: saved credentials use the encrypted vault."
})
.clicked()
{
app.settings.open(SettingsTab::Security);
}
let total = app.s.panes.len();
let connected = app.s.panes.keys().filter(|id| app.s.session_state(id) == Some(SessionState::Connected)).count();
ui.label(dim(format!("{connected}/{total} sessions")));
if let Some(u) = &app.update
&& u.available
{
let txt = format!(
"{} Update available{}",
icon::ARROW_CIRCLE_UP,
if u.latest_version.is_empty() { String::new() } else { format!(" ({})", u.latest_version) }
);
if ui.add(egui::Label::new(RichText::new(txt).size(11.5).color(t.accent)).sense(Sense::click())).clicked() {
app.settings.open(SettingsTab::About);
}
}
});
});
}
static LATENCY: std::sync::Mutex<Option<(String, Result<i64, String>)>> = std::sync::Mutex::new(None);
/// Pings the active SSH session every 10 s for the status-bar latency.
fn latency_tick(app: &mut App) {
let id = app.s.active.clone();
let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh);
if let Some(r) = LATENCY.lock().unwrap().take() {
app.latency = Some(r);
}
let Some(id) = id.filter(|_| ssh) else { return };
if app.s.session_state(&id) != Some(SessionState::Connected) {
return;
}
let last = app.s.ctx.data(|d| d.get_temp::<(String, f64)>(egui::Id::new("nexus-latency-at")));
let due = last.as_ref().is_none_or(|(sid, at)| sid != &id || app.s.now - at > 10.0);
// A failed ping stops pinging that session, as before.
let failed = matches!(&app.latency, Some((sid, Err(_))) if sid == &id);
if !due || failed {
app.s.ctx.request_repaint_after(std::time::Duration::from_secs(10));
return;
}
app.s.ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-latency-at"), (id.clone(), app.s.now)));
let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone());
std::thread::spawn(move || {
let r = svc.sessions.ping(&id).map_err(|e| e.to_string());
*LATENCY.lock().unwrap() = Some((id, r));
ctx.request_repaint();
});
}
// --- palette --------------------------------------------------------------------------------
#[derive(Default)]
pub struct Palette {
pub query: String,
pub sel: usize,
}
#[derive(Clone)]
enum Cmd {
Connect(String),
NewProfile(Protocol),
Quick,
Local,
CloseTab,
NextTab,
PrevTab,
Tab(String),
Settings,
Import,
Export,
ExportJson,
ImportJson,
Lock,
Unlock,
Keys,
Triggers,
Snippets,
Recordings,
SplitRight,
SplitDown,
Clear,
Reset,
Broadcast,
Theme(ThemeKind),
About,
Fullscreen,
Sftp(String),
}
/// Fuzzy subsequence match with bonuses for consecutive and leading hits (utils.js fuzzyMatch).
pub fn fuzzy_score(text: &str, query: &str) -> Option<i32> {
if query.is_empty() {
return Some(1);
}
let text: Vec<char> = text.to_lowercase().chars().collect();
let (mut idx, mut score, mut bonus) = (0usize, 0i32, 0i32);
for qc in query.to_lowercase().chars() {
let found = (idx..text.len()).find(|&i| text[i] == qc)?;
bonus = if found == idx { bonus + 1 } else { 0 };
score += 1 + bonus + if found == 0 { 2 } else { 0 };
idx = found + 1;
}
Some(score)
}
fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)> {
let mut v: Vec<(Cmd, String, &'static str, &'static str, String)> = vec![];
for p in &app.s.profiles {
let name = if p.display_name.is_empty() { &p.host } else { &p.display_name };
v.push((Cmd::Connect(p.id.clone()), format!("Connect to {name}"), "Connections", icon::PLUGS_CONNECTED, String::new()));
}
for (pr, label) in [
(Protocol::Ssh, "New SSH Connection"),
(Protocol::Telnet, "New Telnet Connection"),
(Protocol::Raw, "New RAW Connection"),
(Protocol::Serial, "New Serial Connection"),
] {
v.push((Cmd::NewProfile(pr), label.into(), "Actions", icon::PLUS_CIRCLE, if pr == Protocol::Ssh { keys("Mod+N") } else { String::new() }));
}
v.push((Cmd::Quick, "Quick Connect…".into(), "Actions", icon::LIGHTNING, keys("Mod+Shift+Q")));
v.push((Cmd::Local, "Open Local Terminal".into(), "Actions", icon::TERMINAL_WINDOW, String::new()));
v.push((Cmd::Broadcast, "Toggle Broadcast Mode".into(), "Actions", icon::BROADCAST, keys("Mod+Shift+B")));
v.push((Cmd::Snippets, "Snippet Library".into(), "Actions", icon::CODE, keys("Mod+Shift+S")));
v.push((Cmd::Recordings, "Recordings".into(), "Actions", icon::FILM_STRIP, String::new()));
if !app.s.tabs.is_empty() {
v.push((Cmd::CloseTab, "Close Tab".into(), "Tabs", icon::X, keys("Mod+W")));
v.push((Cmd::NextTab, "Next Tab".into(), "Tabs", icon::ARROW_RIGHT, keys("Mod+Tab")));
v.push((Cmd::PrevTab, "Previous Tab".into(), "Tabs", icon::ARROW_LEFT, keys("Mod+Shift+Tab")));
for t in &app.s.tabs {
if let Some(p) = app.s.pane(&t.id) {
v.push((Cmd::Tab(t.id.clone()), format!("Switch to {}", p.info.tab_name), "Tabs", icon::BROWSER, String::new()));
}
}
}
v.push((Cmd::Settings, "Settings".into(), "Application", icon::GEAR, keys("Mod+,")));
v.push((Cmd::Import, "Import Profiles".into(), "Application", icon::DOWNLOAD_SIMPLE, String::new()));
v.push((Cmd::Export, "Export Profiles".into(), "Application", icon::UPLOAD_SIMPLE, String::new()));
v.push((Cmd::ImportJson, "Import Profiles (JSON)".into(), "Application", icon::FILE_ARROW_DOWN, String::new()));
v.push((Cmd::ExportJson, "Export Profiles (JSON)".into(), "Application", icon::FILE_ARROW_UP, String::new()));
for k in ThemeKind::ALL {
v.push((Cmd::Theme(k), format!("Theme: {}", k.label()), "Application", icon::PALETTE, String::new()));
}
v.push((Cmd::Fullscreen, "Toggle Full Screen".into(), "Application", icon::CORNERS_OUT, "F11".into()));
v.push((Cmd::About, "About Nexus Terminal".into(), "Application", icon::INFO, String::new()));
if app.s.svc.secrets.vault.is_unlocked() {
v.push((Cmd::Lock, "Lock Secret Store".into(), "Security", icon::LOCK, String::new()));
} else {
v.push((Cmd::Unlock, "Unlock Secret Store".into(), "Security", icon::LOCK_OPEN, String::new()));
}
v.push((Cmd::Keys, "SSH Key Manager".into(), "Security", icon::KEY, String::new()));
v.push((Cmd::Triggers, "Trigger Rules".into(), "Terminal", icon::LIGHTNING_A, String::new()));
if app.s.focused_session().and_then(|f| app.s.pane(&f)).is_some_and(|p| matches!(p.view, View::Terminal(_))) {
v.push((Cmd::SplitRight, "Split Pane Right".into(), "Terminal", icon::SQUARE_SPLIT_HORIZONTAL, keys("Mod+Shift+D")));
v.push((Cmd::SplitDown, "Split Pane Down".into(), "Terminal", icon::SQUARE_SPLIT_VERTICAL, keys("Mod+Shift+E")));
if let Some(id) = app.s.focused_session().filter(|f| app.s.pane(f).is_some_and(|p| p.info.protocol == Protocol::Ssh)) {
v.push((Cmd::Sftp(id), "SFTP Browser".into(), "Terminal", icon::FOLDER_OPEN, String::new()));
}
v.push((Cmd::Clear, "Clear Terminal".into(), "Terminal", icon::ERASER, String::new()));
v.push((Cmd::Reset, "Reset Terminal".into(), "Terminal", icon::ARROW_COUNTER_CLOCKWISE, String::new()));
}
v
}
/// Runs the palette command with this label (case-insensitive); false when there is none.
pub fn run_labelled(app: &mut App, ctx: &egui::Context, label: &str) -> bool {
match commands(app).into_iter().find(|c| c.1.eq_ignore_ascii_case(label)) {
Some(c) => {
run(app, ctx, c.0);
true
}
None => false,
}
}
fn run(app: &mut App, ctx: &egui::Context, c: Cmd) {
match c {
Cmd::Sftp(id) => app.s.request(Request::Sftp(id)),
Cmd::Connect(id) => app.connect_profile(&id, false),
Cmd::NewProfile(p) => app.s.request(Request::NewProfile { protocol: Some(p), folder: String::new() }),
Cmd::Quick => app.quick.open(),
Cmd::Local => app.connect_local(),
Cmd::CloseTab => {
if let Some(id) = app.s.focused_session() {
app.request_close(&id);
}
}
Cmd::NextTab | Cmd::PrevTab => {
let order = crate::tabs::ordered(&app.s);
if let Some(cur) = app.s.active.as_ref().and_then(|a| order.iter().position(|x| x == a)) {
let n = order.len() as i32;
let next = (cur as i32 + if matches!(c, Cmd::NextTab) { 1 } else { -1 }).rem_euclid(n) as usize;
let id = order[next].clone();
app.activate(&id);
}
}
Cmd::Tab(id) => app.activate(&id),
Cmd::Settings => app.settings.open(SettingsTab::General),
Cmd::Import => app.s.request(Request::ImportEncrypted),
Cmd::Export => app.s.request(Request::ExportEncrypted(vec![])),
Cmd::ExportJson => app.transfer.export_json(),
Cmd::ImportJson => app.transfer.import_json(),
Cmd::Lock => {
app.s.svc.secrets.vault.lock();
app.s.info("Secret store locked");
}
Cmd::Unlock => app.settings.open(SettingsTab::Security),
Cmd::Keys => app.keys.open(),
Cmd::Triggers => app.triggers.open(),
Cmd::Snippets => app.snippets.open(),
Cmd::Recordings => app.recordings.open(),
Cmd::SplitRight => crate::splits::split_focused(app, crate::splits::Dir::Right),
Cmd::SplitDown => crate::splits::split_focused(app, crate::splits::Dir::Down),
Cmd::Clear | Cmd::Reset => {
if let Some(id) = app.s.focused_session() {
app.terminal_menu(&id, if matches!(c, Cmd::Clear) { crate::terminal::MenuAction::Clear } else { crate::terminal::MenuAction::Reset });
}
}
Cmd::Broadcast => app.toggle_broadcast(),
Cmd::Theme(k) => set_theme(app, k),
Cmd::About => app.settings.open(SettingsTab::About),
Cmd::Fullscreen => {
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
ctx.send_viewport_cmd(ViewportCommand::Fullscreen(!full));
}
}
}
pub fn modal_backdrop(ctx: &egui::Context) {
egui::Area::new("backdrop".into()).order(egui::Order::Middle).fixed_pos(pos2(0.0, 0.0)).show(ctx, |ui| {
let r = ctx.content_rect();
ui.painter().rect_filled(r, 0.0, Color32::from_black_alpha(90));
ui.allocate_rect(r, Sense::click());
});
}
pub fn palette(app: &mut App, ctx: &egui::Context) {
let Some(mut pal) = app.palette.take() else {
return;
};
let t = Tokens::get(ctx);
let mut matches: Vec<_> = commands(app).into_iter().filter_map(|c| fuzzy_score(&format!("{} {}", c.1, c.2), pal.query.trim()).map(|s| (s, c))).collect();
if !pal.query.trim().is_empty() {
matches.sort_by_key(|m| std::cmp::Reverse(m.0));
}
let matches: Vec<_> = matches.into_iter().map(|(_, c)| c).collect();
pal.sel = pal.sel.min(matches.len().saturating_sub(1));
let mut chosen = None;
let mut close = ctx.input(|i| i.key_pressed(Key::Escape));
if ctx.input(|i| i.key_pressed(Key::ArrowDown)) {
pal.sel = (pal.sel + 1).min(matches.len().saturating_sub(1));
}
if ctx.input(|i| i.key_pressed(Key::ArrowUp)) {
pal.sel = pal.sel.saturating_sub(1);
}
if ctx.input(|i| i.key_pressed(Key::Enter)) {
chosen = matches.get(pal.sel).map(|m| m.0.clone());
}
modal_backdrop(ctx);
egui::Area::new("palette".into()).order(egui::Order::Foreground).anchor(Align2::CENTER_TOP, vec2(0.0, 80.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).corner_radius(10).inner_margin(8).show(ui, |ui| {
ui.set_width(580.0);
ui.horizontal(|ui| {
ui.label(RichText::new(icon::MAGNIFYING_GLASS).size(16.0).color(t.text_faint));
let r = ui.add(
egui::TextEdit::singleline(&mut pal.query)
.hint_text("Connect, switch tab, or run a command…")
.frame(egui::Frame::NONE)
.desired_width(f32::INFINITY)
.font(egui::FontId::proportional(15.0)),
);
r.request_focus();
if r.changed() {
pal.sel = 0;
}
});
ui.separator();
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
let mut last = "";
for (i, (cmd, label, section, glyph, keys)) in matches.iter().enumerate() {
if *section != last {
last = section;
ui.label(RichText::new(section.to_uppercase()).font(theme::semibold(10.0)).color(t.text_faint));
}
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
if i == pal.sel {
ui.painter().rect_filled(r, 6.0, t.accent_soft);
ui.scroll_to_rect(r, None);
} else if resp.hovered() {
ui.painter().rect_filled(r, 6.0, t.hover);
}
ui.painter().text(r.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, *glyph, egui::FontId::proportional(15.0), t.accent);
ui.painter().text(r.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, label, egui::FontId::proportional(13.0), t.text);
if !keys.is_empty() {
ui.painter().text(r.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, keys, theme::semibold(10.5), t.text_faint);
}
if resp.clicked() {
chosen = Some(cmd.clone());
}
}
if matches.is_empty() {
widgets::hint(ui, "No matching command.");
}
});
});
});
if let Some(c) = chosen {
close = true;
run(app, ctx, c);
}
if !close {
app.palette = Some(pal);
}
}
// --- prompts --------------------------------------------------------------------------------
pub fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut Ui)) {
modal_backdrop(ctx);
egui::Area::new(egui::Id::new(id)).order(egui::Order::Foreground).anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).corner_radius(10).inner_margin(16).show(ui, |ui| {
ui.set_width(width);
body(ui);
});
});
}
pub fn prompts(app: &mut App, ctx: &egui::Context) {
let Some(p) = app.prompts.front_mut() else {
return;
};
let t = Tokens::get(ctx);
let esc = ctx.input(|i| i.key_pressed(Key::Escape));
let enter = ctx.input(|i| i.key_pressed(Key::Enter));
let mut done = false;
match p {
Prompt::Secret { session, title, message, value, reply } => {
let who = app.s.pane(session).map(|p| format!("{} ({})", p.info.tab_name, p.info.host));
modal(ctx, "secret-prompt", 380.0, |ui| {
ui.label(RichText::new(format!("{} {title}", icon::KEY)).font(theme::semibold(15.0)));
if let Some(w) = &who {
ui.label(RichText::new(w).color(t.text_dim));
}
ui.add_space(4.0);
ui.label(message.as_str());
let r = ui.add(egui::TextEdit::singleline(value).password(true).desired_width(f32::INFINITY));
r.request_focus();
ui.add_space(6.0);
ui.horizontal(|ui| {
if widgets::primary(ui, icon::CHECK, "OK").clicked() || enter {
let _ = reply.send(std::mem::take(value));
done = true;
}
if ui.button("Cancel").clicked() || esc {
done = true; // dropping the sender cancels
}
});
});
}
Prompt::HostKey { host, key_type, fingerprint, reply } => {
modal(ctx, "hostkey-prompt", 460.0, |ui| {
ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0)));
ui.label(format!("The authenticity of {host} can't be established."));
ui.add_space(4.0);
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Fingerprint", fingerprint.clone())]);
widgets::hint(ui, "Accept only if this matches the fingerprint the server's administrator gave you.");
ui.add_space(6.0);
ui.horizontal(|ui| {
if widgets::primary(ui, icon::CHECK, "Accept & Save").clicked() {
let _ = reply.send(true);
done = true;
}
if ui.button("Reject").clicked() || esc {
let _ = reply.send(false);
done = true;
}
});
});
}
}
if done {
app.prompts.pop_front();
}
}
// --- dialogs --------------------------------------------------------------------------------
pub fn dialogs(app: &mut App, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else {
return;
};
let t = Tokens::get(ctx);
let esc = ctx.input(|i| i.key_pressed(Key::Escape));
let mut keep = !esc;
match &mut d {
Dialog::Reuse { profile, existing } => {
let name = app.s.profile(profile).map(|p| p.display_name.clone()).unwrap_or_default();
modal(ctx, "reuse", 400.0, |ui| {
ui.label(RichText::new("Connection Already Open").font(theme::semibold(15.0)));
ui.label(format!("{name} already has an open session."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if widgets::primary(ui, icon::BROWSER, "Use Existing").clicked() {
let id = existing.clone();
app.activate(&id);
keep = false;
}
if ui.button("New Connection").clicked() {
let id = profile.clone();
app.connect_profile(&id, true);
keep = false;
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::DirectVnc { profile } => {
modal(ctx, "direct-vnc", 420.0, |ui| {
ui.label(RichText::new(format!("{} Unencrypted VNC", icon::WARNING)).font(theme::semibold(15.0)).color(t.warning));
ui.label("This VNC connection isn't tunnelled over SSH. VNC traffic (including keystrokes and screen contents) is sent unencrypted. Enable the SSH tunnel in the profile to protect it.");
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Connect Anyway").clicked() {
let id = profile.clone();
app.connect_profile(&id, true);
keep = false;
}
if ui.button("Edit Profile").clicked() {
app.s.request(Request::EditProfile(profile.clone()));
keep = false;
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::ConfirmClose { session } => {
let name = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_default();
modal(ctx, "confirm-close", 360.0, |ui| {
ui.label(RichText::new("Close session?").font(theme::semibold(15.0)));
ui.label(format!("{name} is still connected."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if widgets::primary(ui, icon::X, "Close").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)) {
let id = session.clone();
app.close_session(&id);
keep = false;
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::ConfirmDisconnectMany { sessions } => {
modal(ctx, "confirm-many", 360.0, |ui| {
ui.label(RichText::new("Disconnect sessions?").font(theme::semibold(15.0)));
ui.label(format!("{} open session(s) will be closed.", sessions.len()));
ui.horizontal(|ui| {
if widgets::primary(ui, icon::PLUGS, "Disconnect").clicked() {
for id in sessions.clone() {
app.close_session(&id);
}
keep = false;
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::Rename { session, name } => {
modal(ctx, "rename", 340.0, |ui| {
ui.label(RichText::new("Rename tab").font(theme::semibold(15.0)));
let r = ui.add(egui::TextEdit::singleline(name).desired_width(f32::INFINITY));
r.request_focus();
ui.horizontal(|ui| {
if widgets::primary(ui, icon::CHECK, "Rename").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)) {
let n = name.trim().to_string();
if !n.is_empty() {
let _ = app.s.svc.sessions.rename(session, &n);
if let Some(p) = app.s.pane_mut(session) {
p.info.tab_name = n;
}
}
keep = false;
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::Broadcast { picks } => {
modal(ctx, "broadcast", 360.0, |ui| {
ui.label(RichText::new("Broadcast Targets").font(theme::semibold(15.0)));
widgets::hint(ui, "Select which sessions should receive broadcast input:");
for (_, label, on) in picks.iter_mut() {
ui.checkbox(on, label.as_str());
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if widgets::primary(ui, icon::BROADCAST, "Apply & Enable").clicked() {
let sel: std::collections::HashSet<String> = picks.iter().filter(|p| p.2).map(|p| p.0.clone()).collect();
if sel.is_empty() {
app.s.error("Select at least one target session");
} else {
let all = sel.len() == picks.len();
app.s.broadcast_targets = if all { None } else { Some(sel.clone()) };
if !app.s.broadcast {
app.toggle_broadcast();
} else {
app.s.info(if all { "Broadcasting to all sessions".to_string() } else { format!("Broadcasting to {} sessions", sel.len()) });
}
keep = false;
}
}
if ui.button("Select All").clicked() {
for p in picks.iter_mut() {
p.2 = true;
}
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::Guided { session, title, body, known_hosts } => {
let label = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_else(|| "Session".into());
let profile_id = app.s.pane(session).map(|p| p.info.profile_id.clone()).unwrap_or_default();
modal(ctx, "guided", 440.0, |ui| {
ui.label(RichText::new(title.as_str()).font(theme::semibold(15.0)).color(t.danger));
ui.label(RichText::new(label).strong());
ui.add(egui::Label::new(body.as_str()).wrap());
ui.add_space(8.0);
ui.horizontal(|ui| {
if widgets::primary(ui, icon::ARROW_CLOCKWISE, "Retry").clicked() {
let id = session.clone();
app.reconnect(&id);
keep = false;
}
if !profile_id.is_empty() && ui.button("Edit Profile").clicked() {
app.s.request(crate::state::Request::EditProfile(profile_id.clone()));
keep = false;
}
if *known_hosts && ui.button("Known Hosts").clicked() {
app.settings.open(SettingsTab::KnownHosts);
keep = false;
}
if ui.button("Close").clicked() {
keep = false;
}
});
});
}
Dialog::Delete { profiles } => {
let names: Vec<String> = profiles.iter().filter_map(|id| app.s.profile(id)).map(|p| p.display_name.clone()).collect();
modal(ctx, "delete", 380.0, |ui| {
ui.label(RichText::new("Delete connection?").font(theme::semibold(15.0)));
ui.label(if names.len() == 1 {
format!("\"{}\" will be deleted, with its saved credentials.", names[0])
} else {
format!("{} connections will be deleted.", names.len())
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(Color32::WHITE)).fill(t.danger)).clicked() {
for id in profiles.clone() {
if let Err(e) = app.s.svc.delete_profile(&id) {
app.s.error(e.to_string());
}
}
app.s.reload_profiles();
app.sidebar.selected.clear();
keep = false;
}
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
}
Dialog::Input { title, label, value, then } => {
let mut submit = false;
modal(ctx, "input", 360.0, |ui| {
ui.label(RichText::new(title.as_str()).font(theme::semibold(15.0)));
ui.label(label.as_str());
let r = ui.add(egui::TextEdit::singleline(value).desired_width(f32::INFINITY));
r.request_focus();
ui.horizontal(|ui| {
submit = widgets::primary(ui, icon::CHECK, "OK").clicked() || ctx.input(|i| i.key_pressed(Key::Enter));
if ui.button("Cancel").clicked() {
keep = false;
}
});
});
if submit {
keep = false;
let v = value.trim().to_string();
match then {
InputThen::Tags { profiles, replace } => {
let tags: Vec<String> = v.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
for id in profiles.iter() {
if let Some(mut p) = app.s.profile(id).cloned() {
if *replace {
p.tags = tags.clone();
} else {
for tg in &tags {
if !p.tags.contains(tg) {
p.tags.push(tg.clone());
}
}
}
let _ = app.s.svc.update_profile(p);
}
}
}
InputThen::MoveGroup { profiles } => {
for id in profiles.iter() {
if let Some(mut p) = app.s.profile(id).cloned() {
p.folder = v.clone();
let _ = app.s.svc.update_profile(p);
}
}
}
}
app.s.reload_profiles();
}
}
}
if keep && app.dialog.is_none() {
app.dialog = Some(d);
}
}
// --- toasts ---------------------------------------------------------------------------------
pub fn toasts(app: &mut App, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let now = app.s.now;
app.s.toasts.retain(|x| x.until > now);
if app.s.toasts.is_empty() {
return;
}
ctx.request_repaint_after(std::time::Duration::from_millis(250));
let mut dismiss = None;
egui::Area::new("toasts".into()).order(egui::Order::Tooltip).anchor(Align2::RIGHT_TOP, vec2(-16.0, 48.0)).show(ctx, |ui| {
for (i, toast) in app.s.toasts.iter().enumerate() {
egui::Frame::popup(ui.style()).corner_radius(8).inner_margin(egui::Margin::symmetric(12, 8)).show(ui, |ui| {
ui.set_max_width(420.0);
ui.horizontal_top(|ui| {
let (g, c) = match toast.kind {
ToastKind::Error => (icon::WARNING_CIRCLE, t.danger),
ToastKind::Warning => (icon::WARNING, t.warning),
ToastKind::Success => (icon::CHECK_CIRCLE, t.success),
ToastKind::Info => (icon::INFO, t.accent),
};
ui.label(RichText::new(g).color(c).size(15.0));
ui.add(egui::Label::new(&toast.text).wrap());
if ui.add(egui::Button::new(RichText::new(icon::X).size(11.0)).frame(false)).clicked() {
dismiss = Some(i);
}
});
});
ui.add_space(6.0);
}
});
if let Some(i) = dismiss {
app.s.toasts.remove(i);
}
}