rust: Nexus Terminal app shell (title bar, sidebar, tabs, split panes, terminal view, palette, prompts, dialogs, toasts, status bar)
This commit is contained in:
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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+")
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user