diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d51ab0d..985c147 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2799,6 +2799,8 @@ name = "nexus" version = "0.1.0" dependencies = [ "alacritty_terminal", + "arboard", + "chrono", "eframe", "egui", "egui-phosphor", diff --git a/rust/app/Cargo.toml b/rust/app/Cargo.toml index fb60451..e1fe582 100644 --- a/rust/app/Cargo.toml +++ b/rust/app/Cargo.toml @@ -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" diff --git a/rust/app/assets/icon/icon-256.png b/rust/app/assets/icon/icon-256.png new file mode 100644 index 0000000..f3d119f Binary files /dev/null and b/rust/app/assets/icon/icon-256.png differ diff --git a/rust/app/assets/icon/icon-512.png b/rust/app/assets/icon/icon-512.png new file mode 100644 index 0000000..829caab Binary files /dev/null and b/rust/app/assets/icon/icon-512.png differ diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs new file mode 100644 index 0000000..02ba590 --- /dev/null +++ b/rust/app/src/app.rs @@ -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, + pub theme: Option, +} + +/// Errors from worker threads, drained into toasts each frame. +static POSTED: Mutex> = 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 }, + HostKey { host: String, key_type: String, fingerprint: String, reply: mpsc::Sender }, +} + +/// 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 }, + 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 }, + Input { title: String, label: String, value: String, then: InputThen }, +} + +pub enum InputThen { + Tags { profiles: Vec, replace: bool }, + MoveGroup { profiles: Vec }, +} + +pub struct App { + pub s: Shared, + rx: mpsc::Receiver, + 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, + pub prompts: VecDeque, + pub dialog: Option, + pub tabs_ui: tabs::TabsUi, + pub latency: Option<(String, Result)>, + pub update: Option, + 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, 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 = 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::>(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) { + // 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 = 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 = 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; + } +} diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs new file mode 100644 index 0000000..22d8e2e --- /dev/null +++ b/rust/app/src/chrome.rs @@ -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::(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)>> = 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 { + if query.is_empty() { + return Some(1); + } + let text: Vec = 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 = 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 = 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 = 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); + } +} diff --git a/rust/app/src/forwards.rs b/rust/app/src/forwards.rs new file mode 100644 index 0000000..ef494ea --- /dev/null +++ b/rust/app/src/forwards.rs @@ -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, +} + +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) {} +} diff --git a/rust/app/src/github_view.rs b/rust/app/src/github_view.rs new file mode 100644 index 0000000..0b9a472 --- /dev/null +++ b/rust/app/src/github_view.rs @@ -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) {} + + pub fn on_error(&mut self, _error: String) {} + + pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) { + ui.label("GitHub"); + } +} diff --git a/rust/app/src/irc_view.rs b/rust/app/src/irc_view.rs new file mode 100644 index 0000000..76dc455 --- /dev/null +++ b/rust/app/src/irc_view.rs @@ -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"); + } +} diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 660c693..76186c3 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -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() } +} diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs new file mode 100644 index 0000000..f9b7aa4 --- /dev/null +++ b/rust/app/src/profile_editor.rs @@ -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, _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; + } + } +} diff --git a/rust/app/src/quick_connect.rs b/rust/app/src/quick_connect.rs new file mode 100644 index 0000000..64d440c --- /dev/null +++ b/rust/app/src/quick_connect.rs @@ -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; + } +} diff --git a/rust/app/src/raw_view.rs b/rust/app/src/raw_view.rs new file mode 100644 index 0000000..33e8210 --- /dev/null +++ b/rust/app/src/raw_view.rs @@ -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"); + } +} diff --git a/rust/app/src/recordings.rs b/rust/app/src/recordings.rs new file mode 100644 index 0000000..d57d222 --- /dev/null +++ b/rust/app/src/recordings.rs @@ -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; + } +} diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs new file mode 100644 index 0000000..aa110f9 --- /dev/null +++ b/rust/app/src/settings.rs @@ -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; + } +} diff --git a/rust/app/src/sftp.rs b/rust/app/src/sftp.rs new file mode 100644 index 0000000..46e2b2e --- /dev/null +++ b/rust/app/src/sftp.rs @@ -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, +} + +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) {} + + /// 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) {} +} diff --git a/rust/app/src/sidebar.rs b/rust/app/src/sidebar.rs new file mode 100644 index 0000000..c6923cf --- /dev/null +++ b/rust/app/src/sidebar.rs @@ -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, + pub selected: HashSet, +} + +const RECENT: usize = 5; + +pub fn ui(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label(RichText::new(icon::MAGNIFYING_GLASS).color(t.text_faint)); + ui.add(egui::TextEdit::singleline(&mut app.sidebar.search).hint_text("Search connections").desired_width(ui.available_width() - 10.0)); + }); + ui.add_space(4.0); + + 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> = 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 = app.sidebar.selected.iter().cloned().collect(); + egui::Frame::NONE.fill(t.card).corner_radius(6).inner_margin(6).show(ui, |ui| { + ui.label(RichText::new(format!("{} selected", ids.len())).size(11.5).color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + if ui.small_button(format!("{} Connect all", icon::PLUGS_CONNECTED)).clicked() { + for id in &ids { + app.connect_profile(id, true); + } + app.sidebar.selected.clear(); + } + if ui.small_button(format!("{} Disconnect", icon::PLUGS)).clicked() { + let sessions: Vec = app.s.panes.values().filter(|p| ids.contains(&p.info.profile_id)).map(|p| p.info.id.clone()).collect(); + if sessions.is_empty() { + app.s.info("None of the selected connections are open"); + } else if app.s.prefs.confirm_on_disconnect { + app.dialog = Some(Dialog::ConfirmDisconnectMany { sessions }); + } else { + for s in sessions { + app.close_session(&s); + } + } + } + ui.menu_button(format!("{} Tags", icon::TAG), |ui| { + if ui.button("Add tags…").clicked() { + app.dialog = Some(Dialog::Input { title: "Add tags".into(), label: "Comma-separated tags to add".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: false } }); + ui.close(); + } + if ui.button("Replace tags…").clicked() { + app.dialog = Some(Dialog::Input { title: "Replace tags".into(), label: "Comma-separated tags".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: true } }); + ui.close(); + } + }); + if ui.small_button(format!("{} Move group", icon::FOLDER)).clicked() { + app.dialog = Some(Dialog::Input { title: "Move to group".into(), label: "Group name (empty for Ungrouped)".into(), value: String::new(), then: InputThen::MoveGroup { profiles: ids.clone() } }); + } + if ui.small_button(format!("{} Delete", icon::TRASH)).clicked() { + app.dialog = Some(Dialog::Delete { profiles: ids.clone() }); + } + if ui.small_button("Clear").clicked() { + app.sidebar.selected.clear(); + } + }); + }); +} diff --git a/rust/app/src/snippets.rs b/rust/app/src/snippets.rs new file mode 100644 index 0000000..545ef6d --- /dev/null +++ b/rust/app/src/snippets.rs @@ -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; + } +} diff --git a/rust/app/src/splits.rs b/rust/app/src/splits.rs new file mode 100644 index 0000000..124d921 --- /dev/null +++ b/rust/app/src/splits.rs @@ -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), + Node { dir: Dir, ratio: f32, a: Box, b: Box }, +} + +const MIN_PANE: f32 = 100.0; + +impl Split { + pub fn sessions(&self) -> Vec { + 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 { + 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, out: &mut Vec<(Vec, Option, Rect)>, dividers: &mut Vec<(Vec, 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, Option)> = 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> = 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"); + } +} diff --git a/rust/app/src/ssh_keys.rs b/rust/app/src/ssh_keys.rs new file mode 100644 index 0000000..c5c62e8 --- /dev/null +++ b/rust/app/src/ssh_keys.rs @@ -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; + } +} diff --git a/rust/app/src/state.rs b/rust/app/src/state.rs new file mode 100644 index 0000000..21af468 --- /dev/null +++ b/rust/app/src/state.rs @@ -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, 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), + 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), + 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>, + /// 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, +} + +pub struct Shared { + pub svc: Arc, + pub profiles: Vec, + pub prefs: AppPreferences, + pub tabs: Vec, + /// Every on-screen session by id (tabs and split panes). + pub panes: std::collections::HashMap, + /// Session id of the active tab. + pub active: Option, + /// Session id of the focused pane inside the active tab (the tab itself or a split child). + pub focused: Option, + pub toasts: Vec, + pub requests: Vec, + pub broadcast: bool, + /// None = every connected session. + pub broadcast_targets: Option>, + pub now: f64, + pub ctx: egui::Context, +} + +impl Shared { + pub fn new(svc: Arc, 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, 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) { + self.push(text, ToastKind::Info); + } + + pub fn success(&mut self, text: impl Into) { + self.push(text, ToastKind::Success); + } + + pub fn warn(&mut self, text: impl Into) { + self.push(text, ToastKind::Warning); + } + + pub fn error(&mut self, text: impl Into) { + 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 { + self.focused.clone().or_else(|| self.active.clone()) + } + + /// Every session id on screen (tabs and split panes). + pub fn all_sessions(&self) -> Vec { + self.panes.keys().cloned().collect() + } + + pub fn session_state(&self, id: &str) -> Option { + 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(&self, f: impl FnOnce() -> nexus_core::Result + 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+") + } +} diff --git a/rust/app/src/tabs.rs b/rust/app/src/tabs.rs new file mode 100644 index 0000000..c714461 --- /dev/null +++ b/rust/app/src/tabs.rs @@ -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, +} + +/// Tab ids in display order: pinned first, then the rest, each in insertion order. +pub fn ordered(s: &Shared) -> Vec { + let pinned = |id: &str| s.pane(id).is_some_and(|p| p.info.pinned); + let mut v: Vec = 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>, +} + +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(); + } +} diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs index 502c4f7..fd92e3b 100644 --- a/rust/app/src/terminal.rs +++ b/rust/app/src/terminal.rs @@ -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); + } } } } diff --git a/rust/app/src/transfer.rs b/rust/app/src/transfer.rs new file mode 100644 index 0000000..f61a671 --- /dev/null +++ b/rust/app/src/transfer.rs @@ -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) {} + 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) {} +} diff --git a/rust/app/src/triggers.rs b/rust/app/src/triggers.rs new file mode 100644 index 0000000..31faa7e --- /dev/null +++ b/rust/app/src/triggers.rs @@ -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) {} +} diff --git a/rust/app/src/views.rs b/rust/app/src/views.rs new file mode 100644 index 0000000..57c46b4 --- /dev/null +++ b/rust/app/src/views.rs @@ -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), + Raw(Box), + Irc(Box), + Github(Box), + Vnc(Box), +} + +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", + } +} diff --git a/rust/app/src/vnc_view.rs b/rust/app/src/vnc_view.rs new file mode 100644 index 0000000..24c7a1d --- /dev/null +++ b/rust/app/src/vnc_view.rs @@ -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"); + } +} diff --git a/rust/app/src/widgets.rs b/rust/app/src/widgets.rs index 1a7bf1e..0748565 100644 --- a/rust/app/src/widgets.rs +++ b/rust/app/src/widgets.rs @@ -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) { diff --git a/rust/packaging/macos/NexusTerminal.icns b/rust/packaging/macos/NexusTerminal.icns new file mode 100644 index 0000000..7667632 Binary files /dev/null and b/rust/packaging/macos/NexusTerminal.icns differ diff --git a/rust/packaging/nexus.svg b/rust/packaging/nexus.svg new file mode 100644 index 0000000..00c16bf --- /dev/null +++ b/rust/packaging/nexus.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nexus — ssh + + + + + + + user@nexus:~$ + + + >_ + + + + + + SSH v2 AES-256 + + + + + + diff --git a/rust/packaging/windows/nexus.ico b/rust/packaging/windows/nexus.ico new file mode 100644 index 0000000..1142719 Binary files /dev/null and b/rust/packaging/windows/nexus.ico differ