rust: Nexus Terminal app shell (title bar, sidebar, tabs, split panes, terminal view, palette, prompts, dialogs, toasts, status bar)

This commit is contained in:
Andy Dixon
2026-10-11 16:48:33 +01:00
parent c36f4633a1
commit 43acc57c51
31 changed files with 3723 additions and 8 deletions
+2
View File
@@ -19,6 +19,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
alacritty_terminal = "0.26.0"
chrono = "0.4.45"
arboard = "3.6.1"
[build-dependencies]
winresource = "0.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+819
View File
@@ -0,0 +1,819 @@
//! 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;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, mpsc};
pub struct Options {
pub screenshot: Option<PathBuf>,
pub theme: Option<String>,
}
/// 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().unwrap().push(e);
}
/// A credential or host-key question from a connecting session.
pub enum Prompt {
Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender<String> },
HostKey { host: String, key_type: String, fingerprint: String, reply: mpsc::Sender<bool> },
}
/// 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)> },
/// 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,
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, u32)>,
shot_settle: u32,
}
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().unwrap().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, 0)),
shot_settle: 0,
};
if let Some(from) = &svc.config.migrated_from {
app.s.success(format!("Imported your Alchementary Link data from {}", from.display()));
}
// Background update check, once per launch.
if app.screenshot.is_none() {
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
}
// --- 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, Some(p)) => View::Vnc(Box::new(crate::vnc_view::VncView::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());
self.s.panes.insert(id.into(), Pane { info, view, recording: false, local_exited: false, connected_since: None, highlight_until: 0.0 });
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().unwrap().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))) {
if 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::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 } => {
self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply });
}
Event::HostKeyVerify { host, key_type, fingerprint, reply, .. } => {
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, reply });
}
Event::VncReady { session } => {
if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_ready();
}
}
Event::VncFrame { .. } => {}
}
}
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 => {
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() };
let sid = self.s.svc.connect_quick(p);
self.add_session(&sid, true);
self.s.info(format!("Connecting to {name}…"));
}
Request::ConnectLocal => self.connect_local(),
Request::ReloadProfiles => self.s.reload_profiles(),
Request::ReloadPreferences => {
self.s.prefs = self.s.svc.preferences();
self.theme = theme_for(&self.s.prefs.theme);
theme::apply(&self.s.ctx, self.theme);
}
Request::QuickConnect => self.quick.open(),
Request::Settings(t) => self.settings.open(t),
Request::SshKeys => self.keys.open(),
Request::ExportEncrypted(ids) => self.transfer.export_encrypted(ids),
Request::ImportEncrypted => self.transfer.import_encrypted(),
Request::ExportJson => self.transfer.export_json(),
Request::ImportJson => self.transfer.import_json(),
Request::Recordings => self.recordings.open(),
Request::Snippets => self.snippets.open(),
Request::Triggers => self.triggers.open(),
Request::Sftp(id) => self.sftp.toggle(&id),
Request::PortForwards(id) => self.forwards.open(&id),
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),
Request::Disconnect(id) => self.close_session(&id),
Request::CommandPalette => self.palette = Some(chrome::Palette::default()),
Request::About => self.settings.open(SettingsTab::About),
}
}
}
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()),
}
}
}
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);
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();
}
}
MenuAction::Copy | MenuAction::SelectAll => {}
}
}
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, frames)) = &mut self.screenshot else { return };
*frames += 1;
if *frames < 40 {
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 ["unable to authenticate", "authentication failed", "permission denied", "no supported methods"].iter().any(|p| m.contains(p)) {
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 ctx.input(|i| i.key_pressed(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 side = egui::Panel::left("sidebar")
.default_size(width)
.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.
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);
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;
}
}
+963
View File
@@ -0,0 +1,963 @@
//! 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, "Imported") {
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);
ui.painter().with_clip_rect(r.shrink(2.0)).text(r.left_center() + vec2(28.0, 0.0), Align2::LEFT_CENTER, "Search connections and commands", egui::FontId::proportional(12.0), t.text_faint);
if w > 250.0 {
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,
Lock,
Unlock,
Keys,
Triggers,
Snippets,
Recordings,
SplitRight,
SplitDown,
Clear,
Reset,
Broadcast,
Theme(ThemeKind),
About,
Fullscreen,
}
/// 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()));
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")));
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
}
fn run(app: &mut App, ctx: &egui::Context, c: Cmd) {
match c {
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::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(|a, b| b.0.cmp(&a.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 --------------------------------------------------------------------------------
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);
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Port forwards dialog for a live SSH session: list, stop, add.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct Forwards {
session: Option<String>,
}
impl Forwards {
pub fn open(&mut self, session: &str) {
self.session = Some(session.into());
}
pub fn ui(&mut self, _ctx: &egui::Context, _s: &mut Shared) {}
}
+25
View File
@@ -0,0 +1,25 @@
//! GitHub repository browser.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
use nexus_core::models::GitHubRepo;
pub struct GithubView {
pub session: String,
}
impl GithubView {
pub fn new(session: &str, _title: &str) -> GithubView {
GithubView { session: session.into() }
}
pub fn on_status(&mut self, _status: String) {}
pub fn on_repos(&mut self, _login: String, _repos: Vec<GitHubRepo>) {}
pub fn on_error(&mut self, _error: String) {}
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
ui.label("GitHub");
}
}
+23
View File
@@ -0,0 +1,23 @@
//! IRC client view: room tabs, message log, composer and user/room lists.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
use nexus_core::clients::irc;
use nexus_core::models::Profile;
pub struct IrcView {
pub session: String,
}
impl IrcView {
pub fn new(session: &str, _profile: &Profile) -> IrcView {
IrcView { session: session.into() }
}
pub fn on_event(&mut self, _event: irc::Event) {}
/// True when an event for a room other than the visible one arrived (for the tab marker).
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
ui.label("IRC");
}
}
+96 -2
View File
@@ -1,7 +1,101 @@
//! Nexus Terminal: SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals.
//! Nexus Terminal: SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals in one
//! native window. `nexus-terminal [--theme pro|midnight|light] [--screenshot out.png] [--size WxH]`.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod chrome;
mod forwards;
mod github_view;
mod irc_view;
mod profile_editor;
mod quick_connect;
mod raw_view;
mod recordings;
mod settings;
mod sftp;
mod sidebar;
mod snippets;
mod splits;
mod ssh_keys;
mod state;
mod tabs;
mod terminal;
mod terminal_themes;
mod theme;
mod transfer;
mod triggers;
mod views;
mod vnc_view;
mod widgets;
fn main() {}
use nexus_core::config::AppConfig;
use nexus_core::service::Service;
use std::sync::Arc;
fn main() -> eframe::Result {
let mut opts = app::Options { screenshot: None, theme: None };
let mut size = [1360.0, 860.0];
let mut data_dir = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--theme" => opts.theme = args.next(),
"--screenshot" => opts.screenshot = args.next().map(Into::into),
// Use another data directory (testing, screenshots, portable installs).
"--data-dir" => data_dir = args.next(),
"--size" => {
if let Some((w, h)) = args.next().as_deref().and_then(|s| s.split_once('x')) {
size = [w.parse().unwrap_or(size[0]), h.parse().unwrap_or(size[1])];
}
}
"-V" | "--version" => {
println!("Nexus Terminal {}", nexus_core::service::VERSION);
return Ok(());
}
_ => {
println!("usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--screenshot out.png] [--size WxH]");
return Ok(());
}
}
}
let config = match data_dir {
Some(d) => {
let c = AppConfig::in_dir(d.into());
std::fs::create_dir_all(&c.vault_dir).ok();
Ok(c)
}
None => AppConfig::load(),
};
let svc = match config.map_err(|e| e.to_string()).and_then(|c| Service::open(c).map_err(|e| e.to_string())) {
Ok(s) => Arc::new(s),
Err(e) => {
eprintln!("Nexus Terminal couldn't open its data: {e}");
rfd::MessageDialog::new().set_title("Nexus Terminal").set_description(format!("Couldn't open the application data:\n{e}")).show();
std::process::exit(1);
}
};
let headless = opts.screenshot.is_some();
let native = eframe::NativeOptions {
persist_window: !headless,
viewport: egui::ViewportBuilder::default()
.with_title("Nexus Terminal")
.with_app_id("nexus-terminal")
.with_icon(window_icon())
.with_inner_size(size)
.with_min_inner_size([820.0, 520.0])
.with_decorations(false)
.with_transparent(false)
.with_drag_and_drop(true),
..Default::default()
};
let svc2 = svc.clone();
let r = eframe::run_native("nexus-terminal", native, Box::new(move |cc| Ok(Box::new(app::App::new(cc, svc2, opts)))));
svc.sessions.close_all();
svc.secrets.vault.lock();
r
}
fn window_icon() -> egui::IconData {
let img = image::load_from_memory(chrome::LOGO_PNG).map(|i| i.to_rgba8()).unwrap_or_default();
egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }
}
+31
View File
@@ -0,0 +1,31 @@
//! Profile editor (right dock): create and edit every protocol's settings.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
use nexus_core::models::Protocol;
#[derive(Default)]
pub struct ProfileEditor {
open: bool,
}
impl ProfileEditor {
pub fn open_new(&mut self, _s: &Shared, _protocol: Option<Protocol>, _folder: &str) {
self.open = true;
}
pub fn open_edit(&mut self, _s: &Shared, _id: &str) {
self.open = true;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Drawn inside the right dock while open.
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
if ui.button("Close").clicked() {
self.open = false;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Quick Connect dialog: an ad-hoc session without saving a profile.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct QuickConnect {
open: bool,
}
impl QuickConnect {
pub fn open(&mut self) {
self.open = true;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Called every frame; draws its window(s) while open.
pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) {
let mut open = self.open;
egui::Window::new("QuickConnect").open(&mut open).show(ctx, |ui| ui.label("todo"));
self.open = open;
}
}
+22
View File
@@ -0,0 +1,22 @@
//! RAW TCP viewer: text / hex / split views of the capture and a send bar.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
use nexus_core::models::Profile;
pub struct RawView {
pub session: String,
}
impl RawView {
pub fn new(session: &str, _profile: &Profile) -> RawView {
RawView { session: session.into() }
}
/// Data from `Event::RawData` (direction "send"/"recv", unix ms timestamp).
pub fn on_data(&mut self, _data: &[u8], _direction: &str, _timestamp: i64) {}
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
ui.label("RAW viewer");
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Recordings browser and playback window.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct Recordings {
open: bool,
}
impl Recordings {
pub fn open(&mut self) {
self.open = true;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Called every frame; draws its window(s) while open.
pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) {
let mut open = self.open;
egui::Window::new("Recordings").open(&mut open).show(ctx, |ui| ui.label("todo"));
self.open = open;
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Settings window: General, Terminal, Security, Known Hosts, Help, About.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct Settings {
open: bool,
}
impl Settings {
pub fn open(&mut self, _tab: crate::state::SettingsTab) {
self.open = true;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Called every frame; draws its window(s) while open.
pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) {
let mut open = self.open;
egui::Window::new("Settings").open(&mut open).show(ctx, |ui| ui.label("todo"));
self.open = open;
}
}
+41
View File
@@ -0,0 +1,41 @@
//! SFTP browser (right dock), the file-drop dialog and the remote directory picker.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
use nexus_core::clients::sftp::Progress;
use std::path::PathBuf;
#[derive(Default)]
pub struct Sftp {
open_for: Option<String>,
}
impl Sftp {
pub fn toggle(&mut self, session: &str) {
self.open_for = if self.open_for.as_deref() == Some(session) { None } else { Some(session.into()) };
}
/// The session whose browser is showing in the dock, if any.
pub fn open_for(&self) -> Option<&str> {
self.open_for.as_deref()
}
pub fn close(&mut self, session: &str) {
if self.open_for.as_deref() == Some(session) {
self.open_for = None;
}
}
pub fn on_progress(&mut self, _session: &str, _p: Progress) {}
/// Files dropped on a terminal: offer "paste contents" or (SSH) "upload to a directory".
pub fn drop_files(&mut self, _s: &mut Shared, _session: &str, _paths: Vec<PathBuf>) {}
/// The dock panel.
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
ui.label("SFTP");
}
/// Modal dialogs (file drop, directory picker), drawn every frame.
pub fn dialogs(&mut self, _ctx: &egui::Context, _s: &mut Shared) {}
}
+305
View File
@@ -0,0 +1,305 @@
//! 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<String>,
pub selected: HashSet<String>,
}
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);
let footer_h = 36.0 + if app.sidebar.selected.is_empty() { 0.0 } else { 62.0 };
let list_h = (ui.available_height() - footer_h).max(60.0);
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| list(app, ui));
if !app.sidebar.selected.is_empty() {
bulk_bar(app, ui);
}
// Footer actions.
ui.separator();
ui.horizontal(|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_STRIP, "Recordings").clicked() {
app.recordings.open();
}
if widgets::icon_button(ui, icon::GEAR, "Settings (Ctrl/Cmd+,)").clicked() {
app.settings.open(crate::state::SettingsTab::General);
}
});
}
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(|a, b| b.0.cmp(&a.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(|a, b| b.last_connected.cmp(&a.last_connected));
recent.truncate(RECENT);
if !recent.is_empty() {
section(app, ui, "Recent", "recent", &recent);
}
let mut folders: std::collections::BTreeMap<String, Vec<&Profile>> = 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(), 34.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);
painter.text(text_r.left_top() + vec2(0.0, 3.0), Align2::LEFT_TOP, name, egui::FontId::proportional(13.0), 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_bottom() - vec2(0.0, 3.0), Align2::LEFT_BOTTOM, 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<String> = 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<String> = 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();
}
});
});
}
+26
View File
@@ -0,0 +1,26 @@
//! Snippet library window and the {{variable}} prompt.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct Snippets {
open: bool,
}
impl Snippets {
pub fn open(&mut self) {
self.open = true;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Called every frame; draws its window(s) while open.
pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) {
let mut open = self.open;
egui::Window::new("Snippets").open(&mut open).show(ctx, |ui| ui.label("todo"));
self.open = open;
}
}
+319
View File
@@ -0,0 +1,319 @@
//! Split panes: a binary tree of sessions inside one tab. A new split starts as a placeholder
//! pane offering Local Machine and the saved connections; ✕ on a pane promotes its sibling.
use crate::app::App;
use crate::state::Shared;
use crate::theme::Tokens;
use crate::views::protocol_color;
use crate::widgets;
use egui::{Align2, Rect, RichText, Sense, Ui, pos2, vec2};
use egui_phosphor::regular as icon;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Dir {
/// Side by side (Split Right).
Right,
/// Stacked (Split Down).
Down,
}
/// A tab's layout. Leaf `None` is the placeholder pane showing the connection picker.
#[derive(Debug)]
pub enum Split {
Leaf(Option<String>),
Node { dir: Dir, ratio: f32, a: Box<Split>, b: Box<Split> },
}
const MIN_PANE: f32 = 100.0;
impl Split {
pub fn sessions(&self) -> Vec<String> {
match self {
Split::Leaf(Some(s)) => vec![s.clone()],
Split::Leaf(None) => vec![],
Split::Node { a, b, .. } => {
let mut v = a.sessions();
v.extend(b.sessions());
v
}
}
}
pub fn rename(&mut self, from: &str, to: &str) {
match self {
Split::Leaf(Some(s)) if s == from => *s = to.into(),
Split::Node { a, b, .. } => {
a.rename(from, to);
b.rename(from, to);
}
_ => {}
}
}
fn at(&mut self, path: &[bool]) -> &mut Split {
match (self, path.split_first()) {
(Split::Node { a, b, .. }, Some((side, rest))) => if *side { b.at(rest) } else { a.at(rest) },
(s, _) => s,
}
}
/// Splits the leaf holding `session` (or the first placeholder when `session` is None).
fn split_leaf(&mut self, session: Option<&str>, dir: Dir) -> bool {
match self {
Split::Leaf(s) if s.as_deref() == session => {
let old = std::mem::replace(self, Split::Leaf(None));
*self = Split::Node { dir, ratio: 0.5, a: Box::new(old), b: Box::new(Split::Leaf(None)) };
true
}
Split::Node { a, b, .. } => a.split_leaf(session, dir) || b.split_leaf(session, dir),
_ => false,
}
}
/// Removes the leaf at `path`, replacing its parent with the sibling.
fn remove_at(&mut self, path: &[bool]) {
let Some((last, parent)) = path.split_last() else {
*self = Split::Leaf(None);
return;
};
let node = self.at(parent);
if let Split::Node { a, b, .. } = std::mem::replace(node, Split::Leaf(None)) {
*node = if *last { *a } else { *b };
}
}
fn find(&self, session: &str, path: &mut Vec<bool>) -> bool {
match self {
Split::Leaf(Some(s)) => s == session,
Split::Leaf(None) => false,
Split::Node { a, b, .. } => {
path.push(false);
if a.find(session, path) {
return true;
}
path.pop();
path.push(true);
if b.find(session, path) {
return true;
}
path.pop();
false
}
}
}
fn leaves(&self, rect: Rect, path: &mut Vec<bool>, out: &mut Vec<(Vec<bool>, Option<String>, Rect)>, dividers: &mut Vec<(Vec<bool>, Dir, Rect, Rect)>) {
match self {
Split::Leaf(s) => out.push((path.clone(), s.clone(), rect)),
Split::Node { dir, ratio, a, b } => {
let (ra, rb, div) = divide(rect, *dir, *ratio);
dividers.push((path.clone(), *dir, div, rect));
path.push(false);
a.leaves(ra, path, out, dividers);
path.pop();
path.push(true);
b.leaves(rb, path, out, dividers);
path.pop();
}
}
}
}
fn divide(r: Rect, dir: Dir, ratio: f32) -> (Rect, Rect, Rect) {
const GAP: f32 = 4.0;
match dir {
Dir::Right => {
let x = r.left() + (r.width() - GAP) * ratio;
(Rect::from_min_max(r.min, pos2(x, r.bottom())), Rect::from_min_max(pos2(x + GAP, r.top()), r.max), Rect::from_min_max(pos2(x, r.top()), pos2(x + GAP, r.bottom())))
}
Dir::Down => {
let y = r.top() + (r.height() - GAP) * ratio;
(Rect::from_min_max(r.min, pos2(r.right(), y)), Rect::from_min_max(pos2(r.left(), y + GAP), r.max), Rect::from_min_max(pos2(r.left(), y), pos2(r.right(), y + GAP)))
}
}
}
/// Splits the focused pane of the active tab.
pub fn split_focused(app: &mut App, dir: Dir) {
let Some(tab_id) = app.s.active.clone() else { return };
let focused = app.s.focused_session().unwrap_or(tab_id.clone());
let Some(tab) = app.s.tabs.iter_mut().find(|t| t.id == tab_id) else { return };
match &mut tab.split {
None => tab.split = Some(Split::Node { dir, ratio: 0.5, a: Box::new(Split::Leaf(Some(tab_id.clone()))), b: Box::new(Split::Leaf(None)) }),
Some(sp) => {
if !sp.split_leaf(Some(&focused), dir) {
sp.split_leaf(Some(&tab_id), dir);
}
}
}
}
/// Takes a split child out of its tab (after its session closed). The tab's own session can be
/// removed too: the next pane takes over the tab.
pub fn remove_session(s: &mut Shared, session: &str) {
for i in 0..s.tabs.len() {
let Some(sp) = &mut s.tabs[i].split else { continue };
let mut path = vec![];
if !sp.find(session, &mut path) {
continue;
}
sp.remove_at(&path);
let rest = sp.sessions();
if let Split::Leaf(Some(only)) = sp {
let only = only.clone();
s.tabs[i].split = None;
s.tabs[i].id = only;
} else if s.tabs[i].id == session {
match rest.first() {
Some(next) => s.tabs[i].id = next.clone(),
None => {
s.tabs.remove(i);
}
}
}
if s.active.as_deref() == Some(session) {
s.active = s.tabs.get(i.min(s.tabs.len().saturating_sub(1))).map(|t| t.id.clone());
}
return;
}
}
pub fn render(app: &mut App, ui: &mut Ui, tab_id: &str) {
let t = Tokens::get(ui.ctx());
let area = ui.available_rect_before_wrap();
let (mut leaves, mut dividers) = (vec![], vec![]);
if let Some(sp) = app.s.tab(tab_id).and_then(|tb| tb.split.as_ref()) {
sp.leaves(area, &mut vec![], &mut leaves, &mut dividers);
}
// Dividers: drag to resize, never squeezing a pane under MIN_PANE.
for (path, dir, div, total) in dividers {
let resp = ui.interact(div.expand(2.0), ui.id().with(("divider", &path)), Sense::drag());
let c = if resp.hovered() || resp.dragged() { t.accent } else { t.separator };
ui.painter().rect_filled(div, 0.0, c);
if resp.hovered() {
ui.ctx().set_cursor_icon(if dir == Dir::Right { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
}
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
let (pos, start, len) = match dir {
Dir::Right => (p.x, total.left(), total.width()),
Dir::Down => (p.y, total.top(), total.height()),
};
let min = (MIN_PANE / len).min(0.45);
let ratio = ((pos - start) / len).clamp(min, 1.0 - min);
if let Some(Split::Node { ratio: r, .. }) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| sp.at(&path)) {
*r = ratio;
}
}
}
let mut close: Option<(Vec<bool>, Option<String>)> = None;
for (path, session, rect) in leaves {
let head = Rect::from_min_size(rect.min, vec2(rect.width(), 22.0));
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
let focused = session.is_some() && app.s.focused == session;
ui.painter().rect_filled(head, 0.0, if focused { t.card } else { t.tab_strip });
let title = session.as_deref().and_then(|s| app.s.pane(s)).map(|p| (p.info.tab_name.clone(), protocol_color(p.info.protocol))).unwrap_or(("New pane".into(), t.text_faint));
ui.painter().circle_filled(head.left_center() + vec2(10.0, 0.0), 3.5, title.1);
ui.painter().with_clip_rect(head.shrink2(vec2(24.0, 0.0))).text(head.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, title.0, egui::FontId::proportional(11.5), if focused { t.text } else { t.text_dim });
let xr = Rect::from_center_size(head.right_center() - vec2(12.0, 0.0), vec2(16.0, 16.0));
if widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(xr)), icon::X, "Close pane").clicked() {
close = Some((path.clone(), session.clone()));
}
match session {
Some(id) => {
if ui.interact(head, ui.id().with(("pane-head", &id)), Sense::click()).clicked() {
app.s.focused = Some(id.clone());
}
crate::tabs::pane(app, ui, &id, body);
}
None => picker(app, ui, tab_id, &path, body),
}
}
if let Some((path, session)) = close {
match session {
Some(id) => app.close_session(&id),
None => {
if let Some(tb) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id)
&& let Some(sp) = &mut tb.split
{
sp.remove_at(&path);
if let Split::Leaf(Some(_)) = sp {
tb.split = None;
}
}
}
}
}
}
/// The placeholder pane: pick what to open here.
fn picker(app: &mut App, ui: &mut Ui, tab_id: &str, path: &[bool], rect: Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(rect, 0.0, t.canvas);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(16.0)));
let mut chosen: Option<Option<String>> = None; // Some(None) = local
child.vertical_centered(|ui| {
ui.label(RichText::new("Open in this pane").font(crate::theme::semibold(14.0)));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(rect.height() - 60.0).show(ui, |ui| {
if ui.button(format!("{} Local Machine", icon::TERMINAL_WINDOW)).clicked() {
chosen = Some(None);
}
for p in &app.s.profiles {
if !p.protocol.is_terminal() {
continue;
}
let name = if p.display_name.is_empty() { &p.host } else { &p.display_name };
if ui.button(RichText::new(format!("● {name}")).color(t.text)).clicked() {
chosen = Some(Some(p.id.clone()));
}
}
});
});
let Some(choice) = chosen else { return };
let sid = match choice {
None => app.s.svc.connect_local(),
Some(pid) => match app.s.svc.connect_profile(&pid) {
Ok(s) => s,
Err(e) => {
app.s.error(format!("Connection failed: {e}"));
return;
}
},
};
// The pane may have been closed meanwhile; then drop the new session.
let placed = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| {
let leaf = sp.at(path);
if matches!(leaf, Split::Leaf(None)) {
*leaf = Split::Leaf(Some(sid.clone()));
true
} else {
false
}
});
if placed == Some(true) {
app.add_session(&sid, false);
app.s.focused = Some(sid);
} else {
let _ = app.s.svc.sessions.disconnect(&sid);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_and_collapse() {
let mut sp = Split::Node { dir: Dir::Right, ratio: 0.5, a: Box::new(Split::Leaf(Some("a".into()))), b: Box::new(Split::Leaf(None)) };
assert!(sp.split_leaf(Some("a"), Dir::Down));
assert_eq!(sp.sessions(), vec!["a".to_string()]);
let mut path = vec![];
assert!(sp.find("a", &mut path));
assert_eq!(path, vec![false, false]);
sp.remove_at(&path);
assert!(sp.sessions().is_empty());
sp.rename("a", "b");
}
}
+26
View File
@@ -0,0 +1,26 @@
//! SSH Key Manager window.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct SshKeys {
open: bool,
}
impl SshKeys {
pub fn open(&mut self) {
self.open = true;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Called every frame; draws its window(s) while open.
pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) {
let mut open = self.open;
egui::Window::new("SshKeys").open(&mut open).show(ctx, |ui| ui.label("todo"));
self.open = open;
}
}
+216
View File
@@ -0,0 +1,216 @@
//! State shared by the main window and every feature module: the service, cached profiles and
//! preferences, open tabs, toasts, and a request queue features use to ask the app to act.
use crate::views::View;
use nexus_core::models::{AppPreferences, Profile, Protocol, SessionInfo, SessionState};
use nexus_core::service::Service;
use std::collections::HashSet;
use std::sync::Arc;
/// Things a feature asks the main window to do (handled once per frame, in order).
pub enum Request {
/// Open the profile editor for a new profile (optionally pre-set protocol / folder).
NewProfile { protocol: Option<Protocol>, folder: String },
EditProfile(String),
Connect(String),
ConnectQuick(Profile),
ConnectLocal,
/// Reload `Shared::profiles` from the database.
ReloadProfiles,
/// Re-read and apply preferences (theme, terminal theme, fonts…).
ReloadPreferences,
QuickConnect,
Settings(SettingsTab),
SshKeys,
ExportEncrypted(Vec<String>),
ImportEncrypted,
ExportJson,
ImportJson,
Recordings,
Snippets,
Triggers,
Sftp(String),
PortForwards(String),
/// Type text into the active session (snippets).
SendToActive(String),
/// Bytes to a specific session.
Write(String, Vec<u8>),
Disconnect(String),
CommandPalette,
About,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SettingsTab {
#[default]
General,
Terminal,
Security,
KnownHosts,
Help,
About,
}
pub struct Toast {
pub text: String,
pub kind: ToastKind,
pub until: f64,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Warning,
Error,
}
/// A session on screen (a tab's own session or a split pane) and how it's drawn.
pub struct Pane {
pub info: SessionInfo,
pub view: View,
pub recording: bool,
/// Local shell exited: Enter restarts it.
pub local_exited: bool,
/// Wall-clock time the session last connected.
pub connected_since: Option<chrono::DateTime<chrono::Local>>,
/// Bumped to make trigger highlights flash.
pub highlight_until: f64,
}
/// A tab. Its session is `id`; `split` lays out extra panes (which have no tab of their own).
pub struct Tab {
pub id: String,
/// Output arrived while the tab wasn't active.
pub activity: bool,
pub split: Option<crate::splits::Split>,
}
pub struct Shared {
pub svc: Arc<Service>,
pub profiles: Vec<Profile>,
pub prefs: AppPreferences,
pub tabs: Vec<Tab>,
/// Every on-screen session by id (tabs and split panes).
pub panes: std::collections::HashMap<String, Pane>,
/// Session id of the active tab.
pub active: Option<String>,
/// Session id of the focused pane inside the active tab (the tab itself or a split child).
pub focused: Option<String>,
pub toasts: Vec<Toast>,
pub requests: Vec<Request>,
pub broadcast: bool,
/// None = every connected session.
pub broadcast_targets: Option<HashSet<String>>,
pub now: f64,
pub ctx: egui::Context,
}
impl Shared {
pub fn new(svc: Arc<Service>, ctx: egui::Context) -> Shared {
let prefs = svc.preferences();
let profiles = svc.profiles();
Shared {
svc,
profiles,
prefs,
tabs: vec![],
panes: Default::default(),
active: None,
focused: None,
toasts: vec![],
requests: vec![],
broadcast: false,
broadcast_targets: None,
now: 0.0,
ctx,
}
}
pub fn request(&mut self, r: Request) {
self.requests.push(r);
self.ctx.request_repaint();
}
fn push(&mut self, text: impl Into<String>, kind: ToastKind) {
let secs = if kind == ToastKind::Error { 6.0 } else { 4.0 };
self.toasts.push(Toast { text: text.into(), kind, until: self.now + secs });
self.ctx.request_repaint();
}
pub fn info(&mut self, text: impl Into<String>) {
self.push(text, ToastKind::Info);
}
pub fn success(&mut self, text: impl Into<String>) {
self.push(text, ToastKind::Success);
}
pub fn warn(&mut self, text: impl Into<String>) {
self.push(text, ToastKind::Warning);
}
pub fn error(&mut self, text: impl Into<String>) {
self.push(text, ToastKind::Error);
}
pub fn profile(&self, id: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.id == id)
}
pub fn reload_profiles(&mut self) {
self.profiles = self.svc.profiles();
}
pub fn tab(&self, id: &str) -> Option<&Tab> {
self.tabs.iter().find(|t| t.id == id)
}
pub fn pane(&self, id: &str) -> Option<&Pane> {
self.panes.get(id)
}
pub fn pane_mut(&mut self, id: &str) -> Option<&mut Pane> {
self.panes.get_mut(id)
}
/// The tab that shows `session` (its own, or the one it's split into).
pub fn tab_of(&self, session: &str) -> Option<&Tab> {
self.tabs.iter().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session)))
}
/// The session keyboard actions apply to: the focused pane, else the active tab.
pub fn focused_session(&self) -> Option<String> {
self.focused.clone().or_else(|| self.active.clone())
}
/// Every session id on screen (tabs and split panes).
pub fn all_sessions(&self) -> Vec<String> {
self.panes.keys().cloned().collect()
}
pub fn session_state(&self, id: &str) -> Option<SessionState> {
self.svc.sessions.info(id).map(|i| i.state)
}
/// Runs `f` on a worker thread and toasts its error, if any (for slow service calls).
pub fn spawn<T: Send + 'static>(&self, f: impl FnOnce() -> nexus_core::Result<T> + Send + 'static, done: impl FnOnce(T) + Send + 'static) {
let ctx = self.ctx.clone();
std::thread::spawn(move || {
match f() {
Ok(v) => done(v),
Err(e) => crate::app::post_error(e.to_string()),
}
ctx.request_repaint();
});
}
}
/// Formats a command-key shortcut for menus: "⌘K" on macOS, "Ctrl+K" elsewhere.
pub fn keys(s: &str) -> String {
if cfg!(target_os = "macos") {
s.replace("Mod+Shift+", "⇧⌘").replace("Mod+", "⌘")
} else {
s.replace("Mod+", "Ctrl+")
}
}
+448
View File
@@ -0,0 +1,448 @@
//! The tab strip and the workspace under it: the active tab's session (or its split panes),
//! terminal find bar and connection overlays.
use crate::app::{App, Dialog};
use crate::chrome::{state_color, state_label};
use crate::state::{Request, Shared};
use crate::terminal::TermResponse;
use crate::theme::{self, Tokens};
use crate::views::{View, protocol_badge, protocol_color};
use crate::widgets;
use egui::{Align2, CornerRadius, Key, Rect, RichText, Sense, Stroke, Ui, pos2, vec2};
use egui_phosphor::regular as icon;
use nexus_core::models::{Protocol, SessionState};
#[derive(Default)]
pub struct TabsUi {
pub find_focus: bool,
drag: Option<String>,
}
/// Tab ids in display order: pinned first, then the rest, each in insertion order.
pub fn ordered(s: &Shared) -> Vec<String> {
let pinned = |id: &str| s.pane(id).is_some_and(|p| p.info.pinned);
let mut v: Vec<String> = s.tabs.iter().filter(|t| pinned(&t.id)).map(|t| t.id.clone()).collect();
v.extend(s.tabs.iter().filter(|t| !pinned(&t.id)).map(|t| t.id.clone()));
v
}
pub fn workspace(app: &mut App, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("tabstrip").exact_size(32.0).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| strip(app, ui));
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
let Some(active) = app.s.active.clone().filter(|a| app.s.tab(a).is_some()) else {
empty_state(app, ui);
return;
};
let has_split = app.s.tab(&active).is_some_and(|tb| tb.split.is_some());
if has_split {
crate::splits::render(app, ui, &active);
} else {
let rect = ui.available_rect_before_wrap();
pane(app, ui, &active, rect);
}
});
}
fn empty_state(app: &mut App, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let r = ui.max_rect();
ui.scope_builder(egui::UiBuilder::new().max_rect(Rect::from_center_size(r.center(), vec2(420.0, 420.0))), |ui| {
ui.vertical_centered(|ui| {
ui.add(egui::Image::new(&crate::chrome::logo(ui.ctx())).fit_to_exact_size(vec2(84.0, 84.0)));
ui.label(RichText::new("Nexus Terminal").font(theme::semibold(28.0)));
ui.label(RichText::new("SSH · Telnet · RAW · Serial · VNC · IRC · GitHub").color(t.text_dim));
ui.add_space(18.0);
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 330.0).max(0.0) / 2.0);
if widgets::primary(ui, icon::PLUS, "New Connection").clicked() {
app.s.request(Request::NewProfile { protocol: None, folder: String::new() });
}
if widgets::button(ui, icon::LIGHTNING, "Quick Connect").clicked() {
app.quick.open();
}
if widgets::button(ui, icon::TERMINAL_WINDOW, "Local").clicked() {
app.connect_local();
}
});
ui.add_space(16.0);
let keys = [("Mod+K", "command palette"), ("Mod+N", "new connection"), ("Mod+Shift+Q", "quick connect"), ("Mod+,", "settings")];
egui::Grid::new("welcome-keys").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
for (k, v) in keys {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| widgets::chip(ui, &crate::state::keys(k), t.text_dim));
ui.label(RichText::new(v).color(t.text_faint));
ui.end_row();
}
});
});
});
}
fn strip(app: &mut App, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let order = ordered(&app.s);
let mut close = None;
let mut activate = None;
let mut menu_action: Option<(String, &'static str)> = None;
let full = ui.max_rect();
let right_w = 64.0;
let strip_rect = Rect::from_min_max(full.min, pos2(full.right() - right_w, full.bottom()));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(strip_rect));
egui::ScrollArea::horizontal().id_salt("tabs-scroll").scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(&mut child, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
ui.add_space(4.0);
for id in &order {
let Some(p) = app.s.pane(id) else { continue };
let p_info = p.info.clone();
let (p_recording, p_since) = (p.recording, p.connected_since);
let p = PaneInfo { info: p_info, recording: p_recording, connected_since: p_since };
let tab_activity = app.s.tab(id).is_some_and(|tb| tb.activity);
let active = app.s.active.as_deref() == Some(id.as_str());
let state = app.s.session_state(id).unwrap_or(p.info.state);
let label = p.info.tab_name.clone();
let font = egui::FontId::proportional(12.5);
let galley = ui.painter().layout_no_wrap(label.clone(), font, if active { t.text } else { t.text_dim });
let badge = ui.painter().layout_no_wrap(protocol_badge(p.info.protocol).into(), theme::semibold(9.0), protocol_color(p.info.protocol));
let sftp = p.info.protocol == Protocol::Ssh;
let extra = if p.info.pinned { 16.0 } else { 0.0 } + if sftp { 20.0 } else { 0.0 };
let w = (galley.size().x + badge.size().x + 66.0 + extra).min(280.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
let body = Rect::from_min_max(r.min + vec2(0.0, 3.0), r.max);
if active {
ui.painter().rect_filled(body, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.canvas);
ui.painter().hline(body.x_range().shrink(6.0), body.top() + 0.5, Stroke::new(2.0, protocol_color(p.info.protocol)));
if active && resp.rect.min.x < strip_rect.min.x || resp.rect.max.x > strip_rect.max.x {
ui.scroll_to_rect(r, None);
}
} else if resp.hovered() {
ui.painter().rect_filled(body, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.hover);
}
let mut x = body.left() + 9.0;
ui.painter().circle_filled(pos2(x + 3.0, body.center().y), 3.5, state_color(&t, state));
x += 12.0;
let br = Rect::from_min_size(pos2(x, body.center().y - badge.size().y / 2.0 - 2.0), badge.size() + vec2(8.0, 4.0));
ui.painter().rect_filled(br, 4.0, protocol_color(p.info.protocol).gamma_multiply(0.16));
ui.painter().galley(br.min + vec2(4.0, 2.0), badge, t.text);
x = br.right() + 7.0;
if p.info.pinned {
ui.painter().text(pos2(x, body.center().y), Align2::LEFT_CENTER, icon::PUSH_PIN, egui::FontId::proportional(11.0), t.text_faint);
x += 16.0;
}
let right_controls = 24.0 + if sftp { 20.0 } else { 0.0 };
let gr = Rect::from_min_max(pos2(x, body.top()), pos2(body.right() - right_controls, body.bottom()));
ui.painter().with_clip_rect(gr).galley(pos2(x, body.center().y - galley.size().y / 2.0), galley, t.text);
if tab_activity && !active {
ui.painter().circle_filled(pos2(body.right() - right_controls - 2.0, body.top() + 7.0), 3.0, t.accent);
}
if p.recording {
ui.painter().circle_filled(pos2(body.left() + 4.0, body.top() + 6.0), 3.0, t.danger);
}
if sftp {
let fr = Rect::from_center_size(pos2(body.right() - 33.0, body.center().y), vec2(16.0, 16.0));
let fresp = ui.interact(fr, ui.id().with(("sftp", id)), Sense::click());
let c = if app.sftp.open_for() == Some(id.as_str()) { t.accent } else if fresp.hovered() { t.text } else { t.text_faint };
ui.painter().text(fr.center(), Align2::CENTER_CENTER, icon::FOLDER_SIMPLE, egui::FontId::proportional(12.0), c);
if fresp.on_hover_text("SFTP browser").clicked() {
app.s.request(Request::Sftp(id.clone()));
}
}
let xr = Rect::from_center_size(pos2(body.right() - 13.0, body.center().y), vec2(16.0, 16.0));
let xresp = ui.interact(xr, ui.id().with(("close", id)), Sense::click());
if active || resp.hovered() || xresp.hovered() {
if xresp.hovered() {
ui.painter().rect_filled(xr, 3.0, t.hover);
}
ui.painter().text(xr.center(), Align2::CENTER_CENTER, icon::X, egui::FontId::proportional(11.0), t.text_dim);
}
// Drag to reorder.
if resp.drag_started() {
app.tabs_ui.drag = Some(id.clone());
}
if let Some(d) = app.tabs_ui.drag.clone()
&& d != *id
&& resp.contains_pointer()
&& ui.input(|i| i.pointer.primary_down())
{
let from = app.s.tabs.iter().position(|x| x.id == d);
let to = app.s.tabs.iter().position(|x| x.id == *id);
if let (Some(f), Some(to)) = (from, to) {
let tb = app.s.tabs.remove(f);
app.s.tabs.insert(to, tb);
}
}
if xresp.clicked() {
close = Some(id.clone());
} else if resp.clicked() {
activate = Some(id.clone());
}
let since = p.connected_since.map(|t| format!("\nConnected since {}", t.format("%H:%M:%S"))).unwrap_or_default();
let tip = format!("{} — {}\n{}{since}", p.info.tab_name, p.info.host, state_label(state));
let resp = resp.on_hover_text(tip);
resp.context_menu(|ui| {
for (label, a, glyph) in [
("Rename…", "rename", icon::PENCIL_SIMPLE),
("Duplicate", "duplicate", icon::COPY),
(if p.info.pinned { "Unpin" } else { "Pin" }, "pin", icon::PUSH_PIN),
("Move Left", "left", icon::ARROW_LEFT),
("Move Right", "right", icon::ARROW_RIGHT),
("Reconnect", "reconnect", icon::ARROW_CLOCKWISE),
("Close", "close", icon::X),
] {
if matches!(a, "left" | "reconnect" | "close") {
ui.separator();
}
if ui.button(format!("{glyph} {label}")).clicked() {
menu_action = Some((id.clone(), a));
ui.close();
}
}
});
}
if !ui.input(|i| i.pointer.primary_down()) {
app.tabs_ui.drag = None;
}
});
});
// Right cluster: new tab and broadcast.
let rr = Rect::from_min_max(pos2(full.right() - right_w, full.top()), full.max);
let mut right = ui.new_child(egui::UiBuilder::new().max_rect(rr).layout(egui::Layout::left_to_right(egui::Align::Center)));
right.add_space(6.0);
if widgets::icon_button(&mut right, icon::PLUS, "New connection").clicked() {
app.s.request(Request::NewProfile { protocol: None, folder: String::new() });
}
let b = widgets::icon_button(&mut right, icon::BROADCAST, if app.s.broadcast { "Broadcast ON — click to stop, right-click for targets" } else { "Broadcast input (right-click to pick targets)" });
if app.s.broadcast {
right.painter().rect_stroke(b.rect, 4.0, Stroke::new(1.5, t.warning), egui::StrokeKind::Inside);
}
if b.clicked() {
app.toggle_broadcast();
}
if b.secondary_clicked() {
app.pick_broadcast_targets();
}
if let Some(id) = activate {
app.activate(&id);
}
if let Some(id) = close {
app.request_close(&id);
}
if let Some((id, a)) = menu_action {
tab_action(app, &id, a);
}
}
struct PaneInfo {
info: nexus_core::models::SessionInfo,
recording: bool,
connected_since: Option<chrono::DateTime<chrono::Local>>,
}
fn tab_action(app: &mut App, id: &str, a: &str) {
match a {
"rename" => {
let name = app.s.pane(id).map(|p| p.info.tab_name.clone()).unwrap_or_default();
app.dialog = Some(Dialog::Rename { session: id.into(), name });
}
"duplicate" => {
let Some(p) = app.s.pane(id) else { return };
if p.info.protocol == Protocol::Local {
app.connect_local();
} else if let Some(prof) = app.s.svc.sessions.profile(id) {
if app.s.profile(&prof.id).is_some() {
let pid = prof.id.clone();
app.connect_profile(&pid, true);
} else {
app.s.request(Request::ConnectQuick(prof));
}
}
}
"pin" => {
if let Some(p) = app.s.pane_mut(id) {
p.info.pinned = !p.info.pinned;
let pinned = p.info.pinned;
let _ = app.s.svc.sessions.pin(id, pinned);
}
}
"left" | "right" => {
if let Some(i) = app.s.tabs.iter().position(|t| t.id == id) {
let j = if a == "left" { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < app.s.tabs.len()) };
if let Some(j) = j {
app.s.tabs.swap(i, j);
}
}
}
"reconnect" => app.reconnect(id),
"close" => app.request_close(id),
_ => {}
}
}
/// Draws one session into `rect`.
pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) {
let Some(mut pane) = app.s.panes.remove(id) else { return };
let t = Tokens::get(ui.ctx());
let theme_id = app.s.prefs.terminal_theme.clone();
let focused_tab = app.s.focused.as_deref() == Some(id) || (app.s.focused.is_none() && app.s.active.as_deref() == Some(id));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("pane", id)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
let ui = &mut child;
let mut response = None;
match &mut pane.view {
View::Terminal(term) => {
if term.find.is_some() {
find_bar(app, ui, term);
}
let theme = crate::terminal_themes::get(&theme_id);
let r = term.ui(ui, theme, focused_tab);
if pane.highlight_until > app.s.now {
ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(2.0, t.warning), egui::StrokeKind::Inside);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
response = Some(r);
}
View::Raw(v) => v.ui(ui, &mut app.s),
View::Irc(v) => v.ui(ui, &mut app.s),
View::Github(v) => v.ui(ui, &mut app.s),
View::Vnc(v) => v.ui(ui, &mut app.s),
}
let state = app.s.session_state(id).unwrap_or(pane.info.state);
let local_exited = pane.local_exited;
let error = pane.info.error.clone();
let proto = pane.info.protocol;
app.s.panes.insert(id.into(), pane);
if let Some(r) = response {
terminal_response(app, id, r, local_exited);
}
overlay(app, ui, id, rect, state, &error, local_exited, proto);
}
fn terminal_response(app: &mut App, id: &str, r: TermResponse, local_exited: bool) {
if r.focused && app.s.focused.as_deref() != Some(id) {
app.s.focused = Some(id.into());
}
if let Some((c, rows)) = r.resized {
let _ = app.s.svc.sessions.resize(id, c, rows);
}
if !r.reply.is_empty() {
let _ = app.s.svc.sessions.write(id, &r.reply);
}
if local_exited {
if r.input.iter().any(|b| *b == b'\r' || *b == b'\n') {
let size = match app.s.pane(id).map(|p| &p.view) {
Some(View::Terminal(t)) => t.size(),
_ => (80, 24),
};
if let Err(e) = app.s.svc.sessions.restart_local(id, size.0, size.1) {
app.s.error(e.to_string());
} else if let Some(p) = app.s.pane_mut(id) {
p.local_exited = false;
}
}
} else if !r.input.is_empty() {
app.send_input(id, &r.input);
}
if r.bell {
app.s.ctx.request_repaint();
}
if let Some(a) = r.menu {
app.terminal_menu(id, a);
}
}
#[allow(clippy::too_many_arguments)]
fn overlay(app: &mut App, ui: &mut Ui, id: &str, rect: Rect, state: SessionState, error: &str, local_exited: bool, proto: Protocol) {
let t = Tokens::get(ui.ctx());
let show = match state {
SessionState::Disconnected => !local_exited && proto != Protocol::Local,
SessionState::Error => true,
SessionState::Reconnecting => true,
SessionState::Connecting => proto != Protocol::Local && !matches!(app.s.pane(id).map(|p| &p.view), Some(View::Terminal(_))),
SessionState::Connected => false,
};
if !show {
return;
}
let card = Rect::from_center_size(rect.center(), vec2(360.0_f32.min(rect.width() - 20.0), 150.0));
ui.painter().rect_filled(rect, 0.0, t.canvas.gamma_multiply(0.55));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(card));
egui::Frame::popup(c.style()).corner_radius(10).inner_margin(14).show(&mut c, |ui| {
ui.set_width(card.width() - 28.0);
ui.vertical_centered(|ui| {
let (g, title, col) = match state {
SessionState::Reconnecting => (icon::ARROW_CLOCKWISE, "Reconnecting…", t.warning),
SessionState::Connecting => (icon::PLUGS, "Connecting…", t.warning),
SessionState::Error => (icon::WARNING_CIRCLE, "Connection error", t.danger),
_ => (icon::PLUGS, "Disconnected", t.text_dim),
};
ui.label(RichText::new(format!("{g} {title}")).font(theme::semibold(15.0)).color(col));
if !error.is_empty() && state != SessionState::Reconnecting {
ui.add(egui::Label::new(RichText::new(error).color(t.text_dim).size(12.0)).wrap());
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 200.0).max(0.0) / 2.0);
if state == SessionState::Reconnecting {
if ui.button("Cancel").clicked() {
app.s.svc.sessions.cancel_reconnect(id);
}
} else if state != SessionState::Connecting {
if widgets::primary(ui, icon::ARROW_CLOCKWISE, "Reconnect").clicked() {
app.reconnect(id);
}
if ui.button("Close").clicked() {
app.close_session(id);
}
}
});
});
});
}
fn find_bar(app: &mut App, ui: &mut Ui, term: &mut crate::terminal::TermView) {
let t = Tokens::get(ui.ctx());
let (cur, total) = term.find_counts();
let mut close = false;
let mut step = None;
egui::Frame::NONE.fill(t.card).inner_margin(egui::Margin::symmetric(8, 4)).show(ui, |ui| {
ui.horizontal(|ui| {
let f = term.find.as_mut().unwrap();
ui.label(RichText::new(icon::MAGNIFYING_GLASS).color(t.text_faint));
let r = ui.add(egui::TextEdit::singleline(&mut f.query).hint_text("Find").desired_width(220.0));
if app.tabs_ui.find_focus {
r.request_focus();
app.tabs_ui.find_focus = false;
}
if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
step = Some(!ui.input(|i| i.modifiers.shift));
r.request_focus();
}
if r.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) {
close = true;
}
ui.toggle_value(&mut f.case_sensitive, "Aa").on_hover_text("Match case");
ui.toggle_value(&mut f.regex, ".*").on_hover_text("Regular expression");
match &f.error {
Some(e) => ui.label(RichText::new(e.lines().last().unwrap_or("bad regex")).color(t.danger).size(11.0)),
None => ui.label(RichText::new(if f.query.is_empty() { String::new() } else { format!("{cur} of {total}") }).size(11.5).color(t.text_dim)),
};
if widgets::icon_button(ui, icon::ARROW_UP, "Previous (Shift+Enter)").clicked() {
step = Some(false);
}
if widgets::icon_button(ui, icon::ARROW_DOWN, "Next (Enter)").clicked() {
step = Some(true);
}
if widgets::icon_button(ui, icon::X, "Close (Esc)").clicked() {
close = true;
}
});
});
if let Some(fwd) = step {
term.find_step(fwd);
}
if close {
term.find = None;
term.request_focus();
}
}
+9 -5
View File
@@ -11,7 +11,7 @@ use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::test::TermSize;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler};
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2};
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
@@ -290,7 +290,7 @@ impl TermView {
self.keyboard(ui, &mut out);
let cur = self.term.grid().cursor.point;
let cursor_rect = Rect::from_min_size(origin + vec2(cur.column.0 as f32 * cw, cur.line.0 as f32 * ch), vec2(cw, ch));
ui.output_mut(|o| o.ime = Some(egui::output::IMEOutput { rect, cursor_rect }));
ui.output_mut(|o| o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false }));
}
self.mouse(ui, &resp, origin, cw, ch, &mut out);
@@ -429,7 +429,7 @@ impl TermView {
};
for e in &events {
match e {
Event::MouseWheel { delta, unit, modifiers } if hover.is_some() => {
Event::MouseWheel { delta, unit, modifiers, .. } if hover.is_some() => {
if modifiers.command {
continue; // left for zoom
}
@@ -669,8 +669,12 @@ impl TermView {
painter.text(r.left_top(), egui::Align2::LEFT_TOP, cell.c, font.clone(), rgb(theme.cursor_accent));
}
}
CursorStyle::Underline => painter.rect_filled(Rect::from_min_max(pos2(r.left(), r.bottom() - 2.0), r.max), 0.0, c),
CursorStyle::Bar => painter.rect_filled(Rect::from_min_size(r.min, vec2(2.0, ch)), 0.0, c),
CursorStyle::Underline => {
painter.rect_filled(Rect::from_min_max(pos2(r.left(), r.bottom() - 2.0), r.max), 0.0, c);
}
CursorStyle::Bar => {
painter.rect_filled(Rect::from_min_size(r.min, vec2(2.0, ch)), 0.0, c);
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Profile export/import: encrypted `.nexus` (also reads `.link`) and plain JSON.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct Transfer {}
impl Transfer {
/// Encrypted export of these profiles (all when empty): pick profiles, password, file.
pub fn export_encrypted(&mut self, _ids: Vec<String>) {}
pub fn import_encrypted(&mut self) {}
pub fn export_json(&mut self) {}
pub fn import_json(&mut self) {}
pub fn ui(&mut self, _ctx: &egui::Context, _s: &mut Shared) {}
}
+25
View File
@@ -0,0 +1,25 @@
//! Trigger rules: match terminal output lines and notify / highlight / beep.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
#[derive(Default)]
pub struct Triggers {
open: bool,
}
impl Triggers {
pub fn open(&mut self) {
self.open = true;
}
/// Feeds session output; fires matching rules (toasts, highlight request, bell).
/// Returns true if the tab should be highlighted.
pub fn check(&mut self, _s: &mut Shared, _session: &str, _data: &[u8]) -> bool {
false
}
pub fn cleanup(&mut self, _session: &str) {}
pub fn ui(&mut self, _ctx: &egui::Context, _s: &mut Shared) {}
}
+55
View File
@@ -0,0 +1,55 @@
//! How a session is drawn. Terminal-style protocols share `TermView`; the rest have their own.
use crate::github_view::GithubView;
use crate::irc_view::IrcView;
use crate::raw_view::RawView;
use crate::terminal::TermView;
use crate::vnc_view::VncView;
pub enum View {
Terminal(Box<TermView>),
Raw(Box<RawView>),
Irc(Box<IrcView>),
Github(Box<GithubView>),
Vnc(Box<VncView>),
}
impl View {
pub fn terminal(&mut self) -> Option<&mut TermView> {
match self {
View::Terminal(t) => Some(t),
_ => None,
}
}
}
/// Protocol badge colour (the web build's --protocol-* tokens).
pub fn protocol_color(p: nexus_core::models::Protocol) -> egui::Color32 {
use nexus_core::models::Protocol::*;
let rgb = egui::Color32::from_rgb;
match p {
Ssh => rgb(0x4e, 0xcd, 0xc4),
Telnet => rgb(0xff, 0xa7, 0x26),
Raw => rgb(0xab, 0x47, 0xbc),
Github => rgb(0x7c, 0x8a, 0xff),
Vnc => rgb(0xe8, 0x5d, 0x4a),
Irc => rgb(0xd8, 0xb2, 0x4c),
Local => rgb(0x8b, 0xc3, 0x4a),
Serial => rgb(0x4f, 0xa3, 0xe0),
}
}
/// Short badge text ("SSH", "TELNET"…).
pub fn protocol_badge(p: nexus_core::models::Protocol) -> &'static str {
use nexus_core::models::Protocol::*;
match p {
Ssh => "SSH",
Telnet => "TELNET",
Raw => "RAW",
Github => "GitHub",
Vnc => "VNC",
Irc => "IRC",
Local => "LOCAL",
Serial => "SERIAL",
}
}
+22
View File
@@ -0,0 +1,22 @@
//! VNC viewer: the remote framebuffer as a texture, with mouse and keyboard input.
//! STUB — interface fixed; implementation pending.
use crate::state::Shared;
use nexus_core::models::Profile;
pub struct VncView {
pub session: String,
}
impl VncView {
pub fn new(session: &str, _profile: &Profile) -> VncView {
VncView { session: session.into() }
}
/// Called on `Event::VncReady` (the client is connected; fetch it via `s.svc.sessions.vnc(id)`).
pub fn on_ready(&mut self) {}
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
ui.label("VNC");
}
}
-1
View File
@@ -3,7 +3,6 @@
use crate::theme::{self, Tokens};
use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
/// Small uppercase section header with a hairline.
pub fn section(ui: &mut Ui, title: &str) {