Files
nexus/rust/app/src/app.rs
T
Andy Dixon 5acb41158c feat: credentials page, master password change and a Diagnostics tab
- Settings → Security lists every profile's stored credentials (kind and
  whether it's in the keychain or the vault; the keychain is probed per
  kind on a background thread since it can't be listed) with Forget.
- Change Master Password re-encrypts every vault entry under a fresh
  salt and key with a new verifier, after checking the current password,
  in one atomic write (Vault::change_password; tested that the old
  password stops working and every value survives).
- Settings → Diagnostics shows version, platform, data/database/log
  paths and sizes, keychain and vault state, counts, sync and launch
  settings, with Copy Diagnostics; it never includes credentials.
  Openable with --open settings:diagnostics.
2026-10-12 19:26:52 +01:00

1080 lines
45 KiB
Rust

//! The main window: title bar, connection sidebar, tab strip and workspace (terminals, split
//! panes, protocol viewers), right dock (SFTP or the profile editor), status bar, command
//! palette, prompts and toasts. Session events from nexus-core are drained here every frame.
use crate::state::{Pane, Request, SettingsTab, Shared, Tab, ToastKind};
use crate::terminal::{CursorStyle, MenuAction, TermView};
use crate::theme::{self, ThemeKind, Tokens};
use crate::views::View;
use crate::{chrome, forwards, profile_editor, quick_connect, recordings, settings, sftp, sidebar, snippets, ssh_keys, tabs, transfer, triggers};
use egui::{Key, KeyboardShortcut, Modifiers, Ui};
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
use nexus_core::service::Service;
use nexus_core::sessions::{Event, HostKeyDecision};
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, mpsc};
pub struct Options {
pub screenshot: Option<PathBuf>,
pub theme: Option<String>,
/// Open a local terminal at startup and type this command once connected (testing aid).
pub run_local: Option<String>,
/// Open a window at startup: editor, quick, settings[:tab], keys, snippets, triggers,
/// recordings, palette, export, import (screenshot checks).
pub open: Option<String>,
/// Connect saved profiles (by name or id) at startup.
pub connect: Vec<String>,
/// Command-palette actions (by label) to run once sessions have had time to connect.
pub actions: Vec<String>,
/// Replies to the first credential prompts, in order (screenshots of token-protected sessions).
pub answers: Vec<String>,
/// How long to wait before taking the screenshot.
pub shot_delay: std::time::Duration,
}
/// Errors from worker threads, drained into toasts each frame.
static POSTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
pub fn post_error(e: String) {
POSTED.lock().push(e);
}
/// For the panic hook: the panicking thread may already hold the lock.
pub fn post_error_nonblocking(e: String) {
if let Some(mut p) = POSTED.try_lock() {
p.push(e);
}
}
/// A credential or host-key question from a connecting session.
pub enum Prompt {
/// `value` is wiped when the prompt is dropped (answered, cancelled or abandoned).
Secret {
session: String,
title: String,
message: String,
value: zeroize::Zeroizing<String>,
reply: mpsc::Sender<String>,
},
HostKey {
host: String,
key_type: String,
fingerprint: String,
stored: Option<String>,
reply: mpsc::Sender<HostKeyDecision>,
},
}
/// Modal dialogs owned by the main window.
pub enum Dialog {
/// A profile already has a live session.
Reuse {
profile: String,
existing: String,
},
/// Unencrypted VNC without an SSH tunnel.
DirectVnc {
profile: String,
},
ConfirmClose {
session: String,
},
ConfirmDisconnectMany {
sessions: Vec<String>,
},
Rename {
session: String,
name: String,
},
Broadcast {
picks: Vec<(String, String, bool)>,
},
/// Multi-line paste into a terminal without bracketed paste.
ConfirmPaste {
session: String,
text: String,
},
/// Guided help for an auth or host-key failure.
Guided {
session: String,
title: String,
body: String,
known_hosts: bool,
},
Delete {
profiles: Vec<String>,
},
Input {
title: String,
label: String,
value: String,
then: InputThen,
},
}
pub enum InputThen {
Tags { profiles: Vec<String>, replace: bool },
MoveGroup { profiles: Vec<String> },
}
pub struct App {
pub s: Shared,
/// The tab layout as last saved for restoring (see restore.rs), and when it was last checked.
pub saved_layout: Option<String>,
layout_checked: f64,
/// A profile sync in flight; when the next automatic one is due; whether it was asked for.
sync_job: Option<crate::settings::Job<Result<nexus_core::service::SyncReport, String>>>,
sync_due: f64,
sync_manual: bool,
rx: mpsc::Receiver<Event>,
pub theme: ThemeKind,
pub editor: profile_editor::ProfileEditor,
pub quick: quick_connect::QuickConnect,
pub settings: settings::Settings,
pub keys: ssh_keys::SshKeys,
pub transfer: transfer::Transfer,
pub sftp: sftp::Sftp,
pub forwards: forwards::Forwards,
pub snippets: snippets::Snippets,
pub triggers: triggers::Triggers,
pub recordings: recordings::Recordings,
pub sidebar: sidebar::Sidebar,
pub palette: Option<chrome::Palette>,
pub prompts: VecDeque<Prompt>,
pub dialog: Option<Dialog>,
pub tabs_ui: tabs::TabsUi,
pub latency: Option<(String, Result<i64, String>)>,
pub update: Option<nexus_core::updater::UpdateInfo>,
screenshot: Option<(PathBuf, std::time::Instant, std::time::Duration)>,
shot_settle: u32,
actions: Vec<String>,
answers: Vec<String>,
run_local: Option<(String, Option<String>)>,
/// Apply `prefs.sidebar_width` to the panel on the next frame (after Settings changed it).
resize_sidebar: bool,
}
const fn sc(m: Modifiers, k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(m, k)
}
const CMD: Modifiers = Modifiers::COMMAND;
const CMD_SHIFT: Modifiers = Modifiers::COMMAND.plus(Modifiers::SHIFT);
impl App {
pub fn new(cc: &eframe::CreationContext, svc: Arc<Service>, opts: Options) -> App {
theme::install_fonts(&cc.egui_ctx);
let (tx, rx) = mpsc::channel();
let ctx = cc.egui_ctx.clone();
let tx = Mutex::new(tx);
svc.sessions.set_sink(Arc::new(move |e| {
let _ = tx.lock().send(e);
ctx.request_repaint();
}));
let s = Shared::new(svc.clone(), cc.egui_ctx.clone());
let theme = opts.theme.as_deref().and_then(ThemeKind::from_id).unwrap_or_else(|| theme_for(&s.prefs.theme));
theme::apply(&cc.egui_ctx, theme);
let mut app = App {
s,
rx,
theme,
editor: Default::default(),
quick: Default::default(),
settings: Default::default(),
keys: Default::default(),
transfer: Default::default(),
sftp: Default::default(),
forwards: Default::default(),
snippets: Default::default(),
triggers: Default::default(),
recordings: Default::default(),
sidebar: Default::default(),
palette: None,
prompts: VecDeque::new(),
dialog: None,
tabs_ui: Default::default(),
latency: None,
update: None,
screenshot: opts.screenshot.map(|p| (p, std::time::Instant::now(), opts.shot_delay)),
shot_settle: 0,
actions: opts.actions,
answers: opts.answers,
run_local: opts.run_local.map(|c| (c, None)),
resize_sidebar: false,
saved_layout: None,
layout_checked: 0.0,
sync_job: None,
sync_due: 0.0,
sync_manual: false,
};
let restore = opts.connect.is_empty() && app.run_local.is_none() && opts.open.is_none() && app.s.prefs.restore_sessions;
if restore {
crate::restore::restore(&mut app);
}
for c in &opts.connect {
match app.s.profiles.iter().find(|p| &p.id == c || &p.display_name == c).map(|p| p.id.clone()) {
Some(id) => app.connect_profile(&id, true),
None => eprintln!("--connect: no profile named {c:?}"),
}
}
if let Some(w) = opts.open.as_deref() {
app.open_window(w);
}
if let Some((_, sid)) = &mut app.run_local {
let id = app.s.svc.connect_local();
*sid = Some(id.clone());
app.add_session(&id, true);
}
// Background update check, once per launch.
if app.screenshot.is_none() && app.s.prefs.check_for_updates {
let svc2 = svc.clone();
let ctx = cc.egui_ctx.clone();
std::thread::spawn(move || {
if let Ok(info) = svc2.check_for_updates() {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-update"), Arc::new(info)));
ctx.request_repaint();
}
});
}
app
}
fn open_window(&mut self, w: &str) {
let (w, arg) = w.split_once(':').unwrap_or((w, ""));
match w {
"editor" => match self.s.profiles.iter().find(|p| p.protocol.id() == arg || p.id == arg).map(|p| p.id.clone()) {
Some(id) => self.editor.open_edit(&self.s, &id),
None => self.editor.open_new(&self.s, Protocol::from_id(arg), ""),
},
"quick" => self.quick.open(),
"settings" => self.settings.open(match arg {
"terminal" => SettingsTab::Terminal,
"security" => SettingsTab::Security,
"knownhosts" => SettingsTab::KnownHosts,
"diagnostics" => SettingsTab::Diagnostics,
"help" => SettingsTab::Help,
"about" => SettingsTab::About,
_ => SettingsTab::General,
}),
"keys" => self.keys.open(),
"snippets" => self.snippets.open(),
"triggers" => self.triggers.open(),
"recordings" => self.recordings.open(),
"palette" => self.palette = Some(Default::default()),
"export" => self.transfer.export_encrypted(vec![]),
"import" => self.transfer.import_encrypted(),
_ => eprintln!("--open: unknown window {w:?}"),
}
}
// --- sessions -------------------------------------------------------------------------
fn view_for(&self, info: &SessionInfo, profile: Option<&Profile>) -> View {
let prefs = &self.s.prefs;
match (info.protocol, profile) {
(Protocol::Raw, Some(p)) => View::Raw(Box::new(crate::raw_view::RawView::new(&info.id, p))),
(Protocol::Irc, Some(p)) => View::Irc(Box::new(crate::irc_view::IrcView::new(&info.id, p))),
(Protocol::Github, Some(p)) => View::Github(Box::new(crate::github_view::GithubView::new(&info.id, &p.display_name))),
(Protocol::Vnc | Protocol::Rdp, Some(p)) => View::Desktop(Box::new(crate::desktop_view::DesktopView::new(&info.id, p))),
(_, p) => {
let (scroll, size, cursor) = match p {
Some(p) => (p.terminal_scrollback, p.terminal_font_size, p.terminal_cursor_style.clone()),
None => (prefs.default_scrollback, prefs.default_font_size, prefs.default_cursor_style.clone()),
};
View::Terminal(Box::new(TermView::new(scroll.max(0) as usize, size as f32, CursorStyle::from_id(&cursor))))
}
}
}
/// Registers a just-opened session as a pane (and a tab unless it's a split child).
pub fn add_session(&mut self, id: &str, tab: bool) {
let Some(info) = self.s.svc.sessions.info(id) else {
return;
};
let profile = self.s.svc.sessions.profile(id);
let view = self.view_for(&info, profile.as_ref());
let lines = profile.as_ref().and_then(|p| p.serial.as_ref()).map_or((true, true), |c| (c.dtr_on_connect, c.rts_on_connect));
self.s.panes.insert(id.into(), Pane { info, view, recording: false, local_exited: false, connected_since: None, highlight_until: 0.0, lines });
if tab {
self.s.tabs.push(Tab { id: id.into(), activity: false, split: None });
self.s.active = Some(id.into());
self.s.focused = Some(id.into());
}
}
/// Connect a saved profile, asking first if it already has a live session.
pub fn connect_profile(&mut self, id: &str, skip_checks: bool) {
let Some(p) = self.s.profile(id).cloned() else {
self.s.error("Profile not found");
return;
};
if !skip_checks {
let live = self.s.panes.values().find(|pn| {
pn.info.profile_id == id
&& matches!(self.s.session_state(&pn.info.id), Some(SessionState::Connected | SessionState::Connecting | SessionState::Reconnecting))
});
if let Some(pn) = live {
self.dialog = Some(Dialog::Reuse { profile: id.into(), existing: pn.info.id.clone() });
return;
}
if p.protocol == Protocol::Vnc && !p.vnc.as_ref().is_some_and(|v| v.ssh_tunnel) {
self.dialog = Some(Dialog::DirectVnc { profile: id.into() });
return;
}
}
match self.s.svc.connect_profile(id) {
Ok(sid) => {
self.add_session(&sid, true);
self.s.reload_profiles();
self.s.info(format!("Connecting to {}…", if p.display_name.is_empty() { &p.host } else { &p.display_name }));
}
Err(e) => self.s.error(format!("Connection failed: {e}")),
}
}
pub fn connect_local(&mut self) {
let sid = self.s.svc.connect_local();
self.add_session(&sid, true);
self.s.info("Opening local terminal…");
}
/// Closes a session's UI and backend. Tabs close their split children too.
pub fn close_session(&mut self, id: &str) {
let children: Vec<String> = self.s.tab(id).and_then(|t| t.split.as_ref()).map(|sp| sp.sessions()).unwrap_or_default();
for c in children.iter().filter(|c| *c != id) {
self.close_one(c);
}
if let Some(pos) = self.s.tabs.iter().position(|t| t.id == id) {
self.s.tabs.remove(pos);
if self.s.active.as_deref() == Some(id) {
// The nearest neighbour becomes active.
let next = self.s.tabs.get(pos).or_else(|| self.s.tabs.get(pos.wrapping_sub(1))).map(|t| t.id.clone());
self.s.active = next.clone();
self.s.focused = next;
}
} else {
crate::splits::remove_session(&mut self.s, id);
}
self.close_one(id);
}
fn close_one(&mut self, id: &str) {
self.sftp.close(id);
self.triggers.cleanup(id);
self.s.panes.remove(id);
if self.s.focused.as_deref() == Some(id) {
self.s.focused = self.s.active.clone();
}
let svc = self.s.svc.clone();
let id = id.to_string();
std::thread::spawn(move || {
let _ = svc.sessions.disconnect(&id);
});
}
/// Close with the "confirm on close" preference (not for GitHub tabs).
pub fn request_close(&mut self, id: &str) {
let proto = self.s.pane(id).map(|p| p.info.protocol);
let live = matches!(self.s.session_state(id), Some(SessionState::Connected));
if self.s.prefs.confirm_on_close && live && proto != Some(Protocol::Github) {
self.dialog = Some(Dialog::ConfirmClose { session: id.into() });
} else {
self.close_session(id);
}
}
pub fn reconnect(&mut self, id: &str) {
let old = self.s.pane(id).map(|p| p.info.tab_name.clone());
match self.s.svc.reconnect(id) {
Ok(new) => {
// Keep the tab in place; swap in the new session id.
let info = self.s.svc.sessions.info(&new);
if let (Some(mut pane), Some(info)) = (self.s.panes.remove(id), info) {
pane.info = info;
pane.local_exited = false;
if let View::Terminal(t) = &mut pane.view {
t.feed(b"\r\n\x1b[2m--- reconnecting ---\x1b[0m\r\n");
}
self.s.panes.insert(new.clone(), pane);
}
for t in &mut self.s.tabs {
if t.id == id {
t.id = new.clone();
}
if let Some(sp) = &mut t.split {
sp.rename(id, &new);
}
}
if self.s.active.as_deref() == Some(id) {
self.s.active = Some(new.clone());
}
if self.s.focused.as_deref() == Some(id) {
self.s.focused = Some(new.clone());
}
self.s.info(format!("Reconnecting {}…", old.unwrap_or_default()));
}
Err(e) => self.s.error(format!("Reconnect failed: {e}")),
}
}
/// Sends user input to a session and, in broadcast mode, to the other targets.
pub fn send_input(&mut self, from: &str, data: &[u8]) {
if data.is_empty() {
return;
}
let svc = self.s.svc.clone();
let _ = svc.sessions.write(from, data);
if self.s.broadcast {
for id in self.s.all_sessions() {
if id == from || !self.s.broadcast_targets.as_ref().is_none_or(|t| t.contains(&id)) {
continue;
}
if self.s.session_state(&id) == Some(SessionState::Connected) && self.s.pane(&id).is_some_and(|p| matches!(p.view, View::Terminal(_))) {
let _ = svc.sessions.write(&id, data);
}
}
}
}
// --- events ---------------------------------------------------------------------------
fn drain_events(&mut self) {
for e in POSTED.lock().drain(..) {
self.s.error(e);
}
while let Ok(e) = self.rx.try_recv() {
self.on_event(e);
}
if let Some(info) = self.s.ctx.data_mut(|d| d.remove_temp::<Arc<nexus_core::updater::UpdateInfo>>(egui::Id::new("nexus-update"))) {
self.update = Some((*info).clone());
}
}
fn mark_activity(&mut self, session: &str) {
let active = self.s.active.clone();
if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session)))
&& active.as_deref() != Some(&t.id)
{
t.activity = true;
}
}
fn on_event(&mut self, e: Event) {
match e {
Event::Data { session, data } => {
if let Some(View::Terminal(t)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
t.feed(&data);
}
self.mark_activity(&session);
let now = self.s.now;
if self.triggers.check(&mut self.s, &session, &data)
&& let Some(p) = self.s.pane_mut(&session)
{
p.highlight_until = now + 1.5;
}
}
Event::State { session, state, error, retry } => self.on_state(&session, state, &error, retry),
Event::Closed { session } => {
self.sftp.close(&session);
self.triggers.cleanup(&session);
}
Event::RawData { session, data, direction, timestamp } => {
if let Some(View::Raw(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_data(&data, &direction, timestamp);
}
self.mark_activity(&session);
}
Event::Debug { .. } => {}
Event::GithubStatus { session, status } => {
if let Some(View::Github(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_status(status);
}
}
Event::GithubRepos { session, login, repos } => {
if let Some(View::Github(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_repos(login, repos);
}
}
Event::GithubError { session, error } => {
if let Some(View::Github(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_error(error);
}
}
Event::Warning { message, .. } => self.s.error(message),
Event::Info { message, .. } => self.s.success(message),
Event::Irc { session, event } => {
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_event(event);
}
self.mark_activity(&session);
}
Event::ForwardStarted { forward, .. } => {
let label = if forward.label.is_empty() {
format!("{}:{} → {}:{}", forward.bind_host, forward.bind_port, forward.dest_host, forward.dest_port)
} else {
forward.label.clone()
};
self.s.success(format!("Forward started: {label}"));
}
Event::ForwardStopped { forward_id, .. } => self.s.info(format!("Forward stopped: {forward_id}")),
Event::ForwardError { error, .. } => self.s.error(format!("Forward error: {error}")),
Event::RecordingStarted { session } => {
if let Some(p) = self.s.pane_mut(&session) {
p.recording = true;
}
}
Event::RecordingStopped { session, duration, events } => {
if let Some(p) = self.s.pane_mut(&session) {
p.recording = false;
}
self.s.success(format!("Recording saved ({:.0}s, {events} events)", duration));
}
Event::SftpProgress { session, progress } => self.sftp.on_progress(&session, progress),
Event::SecretPrompt { session, title, message, reply } if !self.answers.is_empty() => {
let _ = (session, title, message, reply.send(self.answers.remove(0)));
}
Event::SecretPrompt { session, title, message, reply } => {
self.prompts.push_back(Prompt::Secret { session, title, message, value: Default::default(), reply });
}
Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => {
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply });
}
Event::DesktopReady { session } => {
if let Some(View::Desktop(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_ready();
}
}
Event::DesktopFrame { .. } => {}
Event::DesktopClipboard { text, .. } => self.remote_clipboard(&text),
}
}
fn on_state(&mut self, session: &str, state: SessionState, error: &str, retry: Option<nexus_core::sessions::Retry>) {
// A reconnect replaces the info wholesale.
if let Some(info) = self.s.svc.sessions.info(session)
&& let Some(p) = self.s.pane_mut(session)
{
p.info = info;
p.info.state = state;
if !error.is_empty() {
p.info.error = error.into();
}
}
let Some(p) = self.s.pane_mut(session) else {
return;
};
let local = p.info.protocol == Protocol::Local;
match state {
SessionState::Connected => {
if let Some((cmd, Some(sid))) = &self.run_local
&& sid == session
{
let _ = self.s.svc.sessions.write(session, format!("{cmd}\n").as_bytes());
self.run_local = None;
}
let Some(p) = self.s.pane_mut(session) else {
return;
};
p.local_exited = false;
p.connected_since = Some(chrono::Local::now());
// The PTY was created before the view knew its size: send it now.
if let View::Terminal(t) = &mut p.view {
let (c, r) = t.size();
let _ = self.s.svc.sessions.resize(session, c, r);
}
}
SessionState::Disconnected if local => {
p.local_exited = true;
if let View::Terminal(t) = &mut p.view {
t.feed(b"\r\n\x1b[2m[Exited - press enter to restart]\x1b[0m\r\n");
}
}
SessionState::Reconnecting => {
if let Some(r) = retry {
self.s.info(format!("Reconnecting (attempt {}/{}) in {}s…", r.attempt, r.max, r.delay_secs));
}
}
SessionState::Error if !error.is_empty() => {
if let Some((title, body, kh)) = classify_error(error) {
self.dialog = Some(Dialog::Guided { session: session.into(), title: title.into(), body: format!("{body}\n\n{error}"), known_hosts: kh });
}
}
_ => {}
}
}
// --- requests, shortcuts, commands ----------------------------------------------------
fn handle_requests(&mut self) {
for r in std::mem::take(&mut self.s.requests) {
match r {
Request::NewProfile { protocol, folder } => self.editor.open_new(&self.s, protocol, &folder),
Request::EditProfile(id) => self.editor.open_edit(&self.s, &id),
Request::Connect(id) => self.connect_profile(&id, false),
Request::ConnectQuick(p) => {
let name = if p.display_name.is_empty() { p.host.clone() } else { p.display_name.clone() };
match self.s.svc.connect_quick(p) {
Ok(sid) => {
self.add_session(&sid, true);
self.s.info(format!("Connecting to {name}…"));
}
Err(e) => self.s.error(format!("Can't connect to {name}: {e}")),
}
}
Request::ReloadProfiles => {
self.s.reload_profiles();
// A profile changed: sync it out shortly (batched with any further edits).
self.sync_due = self.sync_due.min(self.s.now + 3.0);
}
Request::SyncProfiles => {
self.sync_manual = true;
self.sync_due = 0.0;
}
Request::ReloadPreferences => {
self.s.prefs = self.s.svc.preferences();
self.resize_sidebar = true;
self.theme = theme_for(&self.s.prefs.theme);
theme::apply(&self.s.ctx, self.theme);
}
Request::ExportEncrypted(ids) => self.transfer.export_encrypted(ids),
Request::ImportEncrypted => self.transfer.import_encrypted(),
Request::Sftp(id) => {
let at = match self.s.pane(&id).map(|p| &p.view) {
Some(View::Terminal(t)) => t.reported_cwd.as_ref().map(|c| c.1.clone()),
_ => None,
};
self.sftp.toggle(&id, at);
}
Request::SendToActive(text) => match self.s.focused_session() {
Some(id) if self.s.session_state(&id) == Some(SessionState::Connected) => self.send_input(&id, text.as_bytes()),
_ => self.s.error("No active connected session"),
},
Request::Write(id, data) => self.send_input(&id, &data),
}
}
}
fn shortcuts(&mut self, ctx: &egui::Context) {
let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s));
if pressed(sc(CMD, Key::K)) {
self.palette = if self.palette.is_some() { None } else { Some(chrome::Palette::default()) };
}
if pressed(sc(CMD, Key::N)) {
self.s.request(Request::NewProfile { protocol: None, folder: String::new() });
}
if pressed(sc(CMD, Key::W))
&& let Some(id) = self.s.focused_session()
{
self.request_close(&id);
}
if pressed(sc(CMD_SHIFT, Key::Tab)) {
self.cycle_tab(-1);
}
if pressed(sc(CMD, Key::Tab)) {
self.cycle_tab(1);
}
for (i, k) in [Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9].into_iter().enumerate() {
if pressed(sc(CMD, k))
&& let Some(t) = tabs::ordered(&self.s).get(i)
{
let id = t.clone();
self.activate(&id);
}
}
if pressed(sc(CMD, Key::Comma)) {
self.settings.open(SettingsTab::General);
}
if pressed(sc(CMD, Key::F)) {
self.open_find();
}
if pressed(sc(CMD_SHIFT, Key::D)) {
crate::splits::split_focused(self, crate::splits::Dir::Right);
}
if pressed(sc(CMD_SHIFT, Key::E)) {
crate::splits::split_focused(self, crate::splits::Dir::Down);
}
if pressed(sc(CMD_SHIFT, Key::R))
&& let Some(id) = self.s.focused_session()
{
self.toggle_recording(&id);
}
if pressed(sc(CMD_SHIFT, Key::B)) {
self.toggle_broadcast();
}
if pressed(sc(CMD_SHIFT, Key::S)) {
self.snippets.open();
}
if pressed(sc(CMD_SHIFT, Key::Q)) {
self.quick.open();
}
if pressed(sc(Modifiers::NONE, Key::F11)) {
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full));
}
}
pub fn activate(&mut self, id: &str) {
self.s.active = Some(id.into());
self.s.focused = Some(id.into());
if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == id) {
t.activity = false;
}
if let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) {
t.request_focus();
}
}
fn cycle_tab(&mut self, dir: i32) {
let order = tabs::ordered(&self.s);
if order.is_empty() {
return;
}
let cur = self.s.active.as_ref().and_then(|a| order.iter().position(|x| x == a)).unwrap_or(0) as i32;
let next = (cur + dir).rem_euclid(order.len() as i32) as usize;
let id = order[next].clone();
self.activate(&id);
}
pub fn open_find(&mut self) {
if let Some(id) = self.s.focused_session()
&& let Some(View::Terminal(t)) = self.s.panes.get_mut(&id).map(|p| &mut p.view)
{
if t.find.is_none() {
t.find = Some(Default::default());
}
self.tabs_ui.find_focus = true;
}
}
pub fn toggle_recording(&mut self, id: &str) {
let svc = self.s.svc.clone();
if svc.sessions.is_recording(id) {
if let Err(e) = svc.sessions.stop_recording(id) {
self.s.error(e.to_string());
}
} else {
let (c, r) = match self.s.pane(id).map(|p| &p.view) {
Some(View::Terminal(t)) => t.size(),
_ => (80, 24),
};
match svc.sessions.start_recording(id, c, r) {
Ok(()) => self.s.info("Recording started"),
Err(e) => self.s.error(e.to_string()),
}
}
}
/// Telnet or serial break.
pub fn send_break(&mut self, id: &str) {
match self.s.svc.sessions.send_break(id) {
Ok(()) => self.s.info("Break sent"),
Err(e) => self.s.error(e.to_string()),
}
}
/// Toggles a serial modem line: `dtr` true for DTR, false for RTS.
pub fn toggle_line(&mut self, id: &str, dtr: bool) {
let Some((d, r)) = self.s.pane(id).map(|p| p.lines) else { return };
let (d, r) = if dtr { (!d, r) } else { (d, !r) };
match self.s.svc.sessions.set_serial_lines(id, d, r) {
Ok(()) => {
if let Some(p) = self.s.pane_mut(id) {
p.lines = (d, r);
}
self.s.info(format!("DTR {} · RTS {}", if d { "on" } else { "off" }, if r { "on" } else { "off" }));
}
Err(e) => self.s.error(e.to_string()),
}
}
/// A remote host (OSC 52 or VNC) wants to set the clipboard: allowed per preference, and always
/// announced so a silent swap can't set up a paste into another shell.
pub fn remote_clipboard(&mut self, text: &str) {
let first: String = text.lines().next().unwrap_or("").chars().take(60).collect();
if self.s.prefs.allow_remote_clipboard {
self.s.ctx.copy_text(text.into());
self.s.info(format!("Remote host set the clipboard: {first}"));
} else {
self.s.info("Blocked a remote clipboard write (Settings → General to allow)");
}
}
/// Profile sync: at launch, a few seconds after a profile changes, every 10 minutes and on
/// request, when a folder and password are set. Automatic runs stay quiet unless something
/// changed or a manual one failed.
fn drive_sync(&mut self, ctx: &egui::Context) {
if let Some(r) = crate::settings::poll(&mut self.sync_job) {
let manual = std::mem::take(&mut self.sync_manual);
match r {
Ok(rep) => {
if rep.pulled + rep.removed > 0 {
self.s.reload_profiles();
self.s.info(format!("Profile sync: {} updated, {} removed", rep.pulled, rep.removed));
} else if manual {
self.s.success(if rep.pushed { "Profiles synced" } else { "Profiles already in sync" });
}
if rep.skipped > 0 {
self.s.error(format!("Profile sync skipped {} invalid profile(s) from the shared file", rep.skipped));
}
}
Err(e) if manual => self.s.error(format!("Profile sync failed: {e}")),
Err(e) => tracing::warn!(error = %e, "profile sync failed"),
}
}
if self.screenshot.is_some() || self.sync_job.is_some() || self.s.now < self.sync_due {
return;
}
self.sync_due = self.s.now + 600.0;
if self.s.prefs.sync_folder.trim().is_empty() {
if std::mem::take(&mut self.sync_manual) {
self.s.error("Choose a sync folder first");
}
return;
}
let svc = self.s.svc.clone();
self.sync_job = Some(crate::settings::Job::spawn(ctx, move || svc.sync_profiles().map_err(|e| e.to_string())));
}
pub fn toggle_broadcast(&mut self) {
self.s.broadcast = !self.s.broadcast;
if !self.s.broadcast {
self.s.broadcast_targets = None;
}
self.s.info(if self.s.broadcast { "Broadcast mode enabled — input sent to all sessions" } else { "Broadcast mode disabled" });
}
pub fn pick_broadcast_targets(&mut self) {
let focused = self.s.focused_session();
let picks: Vec<(String, String, bool)> = self
.s
.panes
.values()
.filter(|p| Some(&p.info.id) != focused.as_ref() && self.s.session_state(&p.info.id) == Some(SessionState::Connected))
.map(|p| (p.info.id.clone(), p.info.tab_name.clone(), self.s.broadcast_targets.as_ref().is_none_or(|t| t.contains(&p.info.id))))
.collect();
if picks.is_empty() {
self.s.error("No other connected sessions to broadcast to");
return;
}
self.dialog = Some(Dialog::Broadcast { picks });
}
/// A terminal's context-menu action.
pub fn terminal_menu(&mut self, id: &str, a: MenuAction) {
match a {
MenuAction::Paste => {
if let Ok(text) = arboard::Clipboard::new().and_then(|mut c| c.get_text())
&& let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view)
{
let mut out = crate::terminal::TermResponse::default();
t.paste(&text, &mut out);
if let Some(text) = out.confirm_paste {
self.dialog = Some(Dialog::ConfirmPaste { session: id.into(), text });
}
let input = out.input;
self.send_input(id, &input);
}
}
MenuAction::Find => {
self.s.focused = Some(id.into());
self.open_find();
}
MenuAction::SendSnippet => self.snippets.open(),
MenuAction::PortForwards => {
if self.s.pane(id).is_some_and(|p| p.info.protocol == Protocol::Ssh) {
self.forwards.open(id);
} else {
self.s.error("Port forwards are only available on SSH sessions");
}
}
MenuAction::SplitRight | MenuAction::SplitDown => {
self.s.focused = Some(id.into());
crate::splits::split_focused(self, if a == MenuAction::SplitRight { crate::splits::Dir::Right } else { crate::splits::Dir::Down });
}
MenuAction::Export => {
if let Some(View::Terminal(t)) = self.s.panes.get(id).map(|p| &p.view) {
let text = t.buffer_text();
if let Some(path) = rfd::FileDialog::new().set_file_name("terminal-output.txt").save_file() {
match std::fs::write(&path, text) {
Ok(()) => self.s.success(format!("Saved {}", path.display())),
Err(e) => self.s.error(format!("Export failed: {e}")),
}
}
}
}
MenuAction::Clear => {
if let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) {
t.clear();
}
}
MenuAction::Reset => {
if let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) {
t.reset();
}
}
// Handled inside the terminal view.
MenuAction::Copy | MenuAction::SelectAll | MenuAction::PrevPrompt | MenuAction::NextPrompt | MenuAction::CopyOutput => {}
}
}
fn file_drops(&mut self, ctx: &egui::Context) {
let dropped: Vec<PathBuf> = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect());
if dropped.is_empty() {
return;
}
match self.s.focused_session() {
Some(id) if self.s.pane(&id).is_some_and(|p| matches!(p.view, View::Terminal(_))) => self.sftp.drop_files(&mut self.s, &id, dropped),
_ => self.s.error("Drop files on a terminal session"),
}
}
// --- screenshot automation ---------------------------------------------------------
fn screenshot(&mut self, ctx: &egui::Context) {
let Some((path, started, delay)) = &mut self.screenshot else {
return;
};
let (elapsed, delay) = (started.elapsed(), *delay);
if !self.actions.is_empty() && elapsed >= delay / 2 {
for a in std::mem::take(&mut self.actions) {
if !chrome::run_labelled(self, ctx, &a) {
eprintln!("--do: no palette command {a:?}");
}
}
ctx.request_repaint();
return;
}
let path = path.clone();
if elapsed < delay {
std::thread::sleep(std::time::Duration::from_millis(15));
ctx.request_repaint();
return;
}
if self.shot_settle == 0 {
self.shot_settle = 1;
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
}
let shot = ctx.input(|i| i.events.iter().find_map(|e| if let egui::Event::Screenshot { image, .. } = e { Some(image.clone()) } else { None }));
if let Some(img) = shot {
let [w, h] = img.size;
let raw: Vec<u8> = img.pixels.iter().flat_map(|p| p.to_array()).collect();
if let Err(e) = image::save_buffer(&path, &raw, w as u32, h as u32, image::ExtendedColorType::Rgba8) {
eprintln!("screenshot: {e}");
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
ctx.request_repaint();
}
}
/// App chrome theme from the "theme" preference (the web build used dark/light).
pub fn theme_for(pref: &str) -> ThemeKind {
ThemeKind::from_id(pref).unwrap_or(if pref == "light" { ThemeKind::Light } else { ThemeKind::Pro })
}
/// Recognises auth and host-key failures for the guided error dialog.
pub fn classify_error(msg: &str) -> Option<(&'static str, &'static str, bool)> {
let m = msg.to_lowercase();
if nexus_core::sessions::is_auth_error(&m) {
return Some(("Authentication Failed", "The server rejected the provided credentials. Check username, password, keys, and auth methods.", false));
}
if ["host key", "fingerprint"].iter().any(|p| m.contains(p)) {
return Some(("Host Key Verification Failed", "The server's host key could not be verified. Manage known hosts or update the profile's policy.", true));
}
None
}
impl eframe::App for App {
fn persist_egui_memory(&self) -> bool {
self.screenshot.is_none()
}
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let t = Tokens::get(&ctx);
self.s.now = ctx.input(|i| i.time);
self.drain_events();
self.handle_requests();
// Dialogs and the palette take the keyboard while open.
if self.palette.is_none() && self.dialog.is_none() && self.prompts.is_empty() {
self.shortcuts(&ctx);
}
if crate::chrome::modal_key(&ctx, Key::Escape) && ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) && self.palette.is_none() {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
}
self.file_drops(&ctx);
egui::Panel::top("titlebar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| chrome::title_bar(self, ui));
if self.s.prefs.show_status_bar {
egui::Panel::bottom("status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| chrome::status_bar(self, ui));
}
let width = self.s.prefs.sidebar_width.clamp(180, 500) as f32;
let mut panel = egui::Panel::left("sidebar").default_size(width);
if std::mem::take(&mut self.resize_sidebar) {
panel = panel.exact_size(width);
}
let side = panel.min_size(180.0).max_size(500.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| sidebar::ui(self, ui));
let w = side.response.rect.width().round() as i64;
if (w - self.s.prefs.sidebar_width).abs() > 2 && !ctx.input(|i| i.pointer.any_down()) {
self.s.prefs.sidebar_width = w;
let _ = self.s.svc.save_preferences(&self.s.prefs);
}
if self.editor.is_open() {
egui::Panel::right("editor")
.default_size(460.0)
.min_size(380.0)
.max_size(800.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(6))
.show(ui, |ui| {
self.editor.ui(ui, &mut self.s);
});
} else if let Some(id) = self.sftp.open_for().map(String::from) {
if self.s.pane(&id).is_some() {
egui::Panel::right("sftp")
.default_size(380.0)
.min_size(280.0)
.max_size(800.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(6))
.show(ui, |ui| {
self.sftp.ui(ui, &mut self.s);
});
} else {
self.sftp.close(&id);
}
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| tabs::workspace(self, ui));
// Floating windows and modals.
let prompt_open = !self.prompts.is_empty();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-prompt-open"), prompt_open));
self.quick.ui(&ctx, &mut self.s);
self.settings.ui(&ctx, &mut self.s);
self.keys.ui(&ctx, &mut self.s);
self.transfer.ui(&ctx, &mut self.s);
self.sftp.dialogs(&ctx, &mut self.s);
self.forwards.ui(&ctx, &mut self.s);
self.snippets.ui(&ctx, &mut self.s);
self.triggers.ui(&ctx, &mut self.s);
self.recordings.ui(&ctx, &mut self.s);
chrome::dialogs(self, &ctx);
chrome::prompts(self, &ctx);
chrome::palette(self, &ctx);
chrome::toasts(self, &ctx);
self.screenshot(&ctx);
self.drive_sync(&ctx);
if self.screenshot.is_none() && self.s.prefs.restore_sessions && self.s.now - self.layout_checked > 1.0 {
self.layout_checked = self.s.now;
crate::restore::save_if_changed(self);
}
chrome::resize_edges(&ctx);
// Requests raised while drawing are handled on the next frame.
if !self.s.requests.is_empty() {
ctx.request_repaint();
}
let _ = ToastKind::Info;
}
}