From 52770923df7ad6f0df670bca1a6d3b74217bd62d Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 11 Oct 2026 17:23:54 +0100 Subject: [PATCH] rust: snippets, triggers, recordings and playback --- rust/app/src/chrome.rs | 2 +- rust/app/src/recordings.rs | 321 ++++++++++++++++++++++++++++++++- rust/app/src/snippets.rs | 358 +++++++++++++++++++++++++++++++++++-- rust/app/src/triggers.rs | 310 +++++++++++++++++++++++++++++++- 4 files changed, 965 insertions(+), 26 deletions(-) diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index 22d8e2e..0317ea7 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -611,7 +611,7 @@ pub fn palette(app: &mut App, ctx: &egui::Context) { // --- prompts -------------------------------------------------------------------------------- -fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut Ui)) { +pub fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut Ui)) { modal_backdrop(ctx); egui::Area::new(egui::Id::new(id)).order(egui::Order::Foreground).anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)).show(ctx, |ui| { egui::Frame::popup(ui.style()).corner_radius(10).inner_margin(16).show(ui, |ui| { diff --git a/rust/app/src/recordings.rs b/rust/app/src/recordings.rs index d57d222..bbc6ba5 100644 --- a/rust/app/src/recordings.rs +++ b/rust/app/src/recordings.rs @@ -1,26 +1,333 @@ -//! Recordings browser and playback window. -//! STUB — interface fixed; implementation pending. +//! Recordings browser and playback window (port of the RecordingManager in recordings.js). use crate::state::Shared; +use crate::terminal::{CursorStyle, TermView}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Key, RichText}; +use egui_phosphor::regular as icon; +use nexus_core::recording::{CleanupResult, EVENT_OUTPUT, Event, Header, RecordingMeta, StorageInfo}; +use std::sync::mpsc::{Receiver, channel}; + +const AGES: [(u32, &str); 4] = [(30, "Older than 30 days"), (7, "Older than 7 days"), (90, "Older than 90 days"), (0, "Ignore age")]; +const SIZES: [(u64, &str); 3] = [(1 << 30, "Keep under 1 GB"), (5 << 30, "Keep under 5 GB"), (0, "Ignore size")]; +const SPEEDS: [f64; 5] = [0.5, 1.0, 2.0, 5.0, 10.0]; + +/// m:ss, as the JS `_formatSeconds`. +pub fn mmss(secs: f64) -> String { + if !(secs > 0.0) { + return "0:00".into(); + } + let s = secs.floor() as u64; + format!("{}:{:02}", s / 60, s % 60) +} + +enum Loaded { + List(Vec, StorageInfo), + Cleaned(CleanupResult), + Recording(String, Header, Vec), +} + +struct Playback { + title: String, + term: TermView, + events: Vec, + total: f64, + /// Virtual time played so far, and the next event to feed. + pos: f64, + next: usize, + playing: bool, + speed: f64, +} + +impl Playback { + fn feed_until(&mut self, t: f64) { + while let Some(e) = self.events.get(self.next).filter(|e| e.elapsed <= t) { + if e.kind == EVENT_OUTPUT { + self.term.feed(e.data.as_bytes()); + } + self.next += 1; + } + } + + /// Replays from the start up to `t`. + fn seek(&mut self, t: f64) { + self.term.reset(); + self.next = 0; + self.pos = t.clamp(0.0, self.total); + self.feed_until(self.pos); + } + + fn play(&mut self) { + if self.next >= self.events.len() { + self.seek(0.0); + } + self.playing = true; + } + + fn tick(&mut self, ctx: &egui::Context) { + if !self.playing { + return; + } + let dt = ctx.input(|i| i.stable_dt).min(0.25) as f64; + self.pos = (self.pos + dt * self.speed).min(self.total); + self.feed_until(self.pos); + if self.next >= self.events.len() { + self.playing = false; + self.pos = self.total; + } else { + let wait = (self.events[self.next].elapsed - self.pos) / self.speed; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait.clamp(0.0, 0.25))); + } + } +} -#[derive(Default)] pub struct Recordings { open: bool, + list: Vec, + info: Option, + loading: bool, + rx: Option>, + age: u32, + size: u64, + status: String, + confirm_delete: Option, + playback: Option, +} + +impl Default for Recordings { + fn default() -> Self { + Recordings { open: false, list: vec![], info: None, loading: false, rx: None, age: 30, size: 1 << 30, status: String::new(), confirm_delete: None, playback: None } + } } impl Recordings { pub fn open(&mut self) { self.open = true; + self.status.clear(); + self.info = None; } - pub fn is_open(&self) -> bool { - self.open + /// Runs `f` on a worker thread; its result is picked up by `poll`. + fn spawn(&mut self, s: &Shared, f: impl FnOnce(&nexus_core::recording::Store) -> nexus_core::Result + Send + 'static) { + let (tx, rx) = channel(); + let store = s.svc.sessions.recordings().clone(); + self.rx = Some(rx); + self.loading = true; + s.spawn(move || f(&store), move |v| drop(tx.send(v))); + } + + fn reload(&mut self, s: &Shared) { + self.spawn(s, |st| Ok(Loaded::List(st.list()?, st.storage_info()?))); + } + + fn poll(&mut self, s: &mut Shared) { + let Some(rx) = &self.rx else { return }; + let got = match rx.try_recv() { + Ok(v) => v, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(_) => { + // Worker failed (error already toasted). + self.rx = None; + self.loading = false; + return; + } + }; + self.rx = None; + self.loading = false; + match got { + Loaded::List(list, info) => { + self.list = list; + self.info = Some(info); + } + Loaded::Cleaned(r) => { + self.status = format!("Deleted {}; freed {}.", r.deleted, widgets::human_size(r.freed_bytes)); + self.reload(s); + } + Loaded::Recording(title, header, events) => { + if events.is_empty() { + return s.info("Recording is empty — nothing to play back."); + } + let total = events.last().map_or(1.0, |e| e.elapsed).max(f64::EPSILON); + let title = if header.title.is_empty() { title } else { header.title }; + let mut p = Playback { title, term: TermView::new(10_000, 13.0, CursorStyle::Block), events, total, pos: 0.0, next: 0, playing: false, speed: 1.0 }; + p.play(); // autoplay + self.playback = Some(p); + } + } } /// Called every frame; draws its window(s) while open. - pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) { + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + self.poll(s); + self.playback_ui(ctx, s); + self.delete_dialog(ctx, s); + if !self.open { + return; + } + if self.info.is_none() && !self.loading { + self.reload(s); + } let mut open = self.open; - egui::Window::new("Recordings").open(&mut open).show(ctx, |ui| ui.label("todo")); + egui::Window::new(format!("{} Session Recordings", icon::RECORD)) + .id(egui::Id::new("recordings")) + .open(&mut open) + .collapsible(false) + .default_size([640.0, 440.0]) + .show(ctx, |ui| self.browser(ui, s)); self.open = open; } + + fn browser(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (count, total) = self.info.as_ref().map_or((self.list.len(), 0), |i| (i.count, i.total_bytes)); + ui.label(RichText::new(format!("{count} recording{}", if count == 1 { "" } else { "s" })).font(theme::semibold(13.0))); + ui.label(RichText::new(widgets::human_size(total)).color(t.text_dim)); + if self.loading { + ui.spinner(); + } + }); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("rec-age").selected_text(AGES.iter().find(|a| a.0 == self.age).map_or("", |a| a.1)).show_ui(ui, |ui| { + for (v, l) in AGES { + ui.selectable_value(&mut self.age, v, l); + } + }); + egui::ComboBox::from_id_salt("rec-size").selected_text(SIZES.iter().find(|a| a.0 == self.size).map_or("", |a| a.1)).show_ui(ui, |ui| { + for (v, l) in SIZES { + ui.selectable_value(&mut self.size, v, l); + } + }); + if ui.add_enabled(!self.loading, egui::Button::new(format!("{} Clean up", icon::BROOM))).clicked() { + let (age, size) = (self.age, self.size); + self.status.clear(); + self.spawn(s, move |st| Ok(Loaded::Cleaned(st.cleanup(age, size)?))); + } + }); + if !self.status.is_empty() { + widgets::hint(ui, &self.status); + } + ui.separator(); + if self.list.is_empty() { + if !self.loading { + widgets::empty(ui, icon::RECORD, "No recordings yet", "Use the record button on a terminal tab to capture a session."); + } + return; + } + let (mut play, mut delete) = (None, None); + widgets::rows(ui, "rec-list", self.list.len(), 44.0, None, |ui, i| { + let r = &self.list[i]; + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 64.0); + ui.label(RichText::new(if r.title.is_empty() { &r.id } else { &r.title }).font(theme::semibold(13.0))); + let date = r.created_at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M"); + ui.label(RichText::new(format!("{date} · {} · {} · {}×{}", mmss(r.duration), widgets::human_size(r.size), r.width, r.height)).size(11.5).color(t.text_dim)); + }); + if widgets::icon_button(ui, icon::PLAY, "Play").clicked() { + play = Some(i); + } + if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() { + delete = Some(i); + } + }); + if let Some(i) = play { + let (id, title) = (self.list[i].id.clone(), self.list[i].title.clone()); + self.open = false; + self.spawn(s, move |st| { + let (h, ev) = st.get(&id)?; + Ok(Loaded::Recording(title, h, ev)) + }); + } + if let Some(i) = delete { + self.confirm_delete = Some(self.list[i].clone()); + } + } + + fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(r) = &self.confirm_delete else { return }; + let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + let name = if r.title.is_empty() { r.id.clone() } else { r.title.clone() }; + crate::chrome::modal(ctx, "rec-delete", 380.0, |ui| { + ui.label(RichText::new("Delete recording?").font(theme::semibold(15.0))); + ui.label(format!("\u{201c}{name}\u{201d} will be permanently deleted.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + yes = widgets::primary(ui, icon::TRASH, "Delete").clicked(); + no |= ui.button("Cancel").clicked(); + }); + }); + if yes { + let id = self.confirm_delete.take().unwrap().id; + match s.svc.sessions.recordings().delete(&id) { + Ok(()) => { + s.success("Recording deleted"); + self.list.retain(|m| m.id != id); + self.reload(s); + } + Err(e) => s.error(format!("Failed to delete recording: {e}")), + } + } else if no { + self.confirm_delete = None; + } + } + + fn playback_ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(p) = &mut self.playback else { return }; + p.tick(ctx); + let t = Tokens::get(ctx); + let theme = crate::terminal_themes::get(&s.prefs.terminal_theme); + let mut open = true; + egui::Window::new(format!("{} {}", icon::PLAY_CIRCLE, p.title)) + .id(egui::Id::new("rec-playback")) + .open(&mut open) + .collapsible(false) + .default_size([820.0, 520.0]) + .show(ctx, |ui| { + egui::Panel::bottom("rec-controls").frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 6))).show(ui, |ui| { + ui.horizontal(|ui| { + let (glyph, tip) = if p.playing { (icon::PAUSE, "Pause") } else { (icon::PLAY, "Play") }; + if widgets::icon_button(ui, glyph, tip).clicked() { + if p.playing { p.playing = false } else { p.play() } + } + if widgets::icon_button(ui, icon::ARROW_COUNTER_CLOCKWISE, "Restart").clicked() { + p.playing = false; + p.seek(0.0); + } + let label = format!("{} / {}", mmss(p.pos), mmss(p.total)); + let lw = 120.0; + let mut pos = p.pos; + ui.spacing_mut().slider_width = (ui.available_width() - lw - 90.0).max(80.0); + if ui.add(egui::Slider::new(&mut pos, 0.0..=p.total).show_value(false)).changed() { + p.seek(pos); + } + ui.add_sized([lw, 20.0], egui::Label::new(RichText::new(label).font(theme::mono(12.0)).color(t.text_dim))); + egui::ComboBox::from_id_salt("rec-speed").width(60.0).selected_text(format!("{}×", p.speed)).show_ui(ui, |ui| { + for v in SPEEDS { + ui.selectable_value(&mut p.speed, v, format!("{v}×")); + } + }); + }); + }); + egui::Frame::NONE.fill(egui::Color32::from_rgba_unmultiplied(theme.background[0], theme.background[1], theme.background[2], 255)).corner_radius(t.radius).show(ui, |ui| { + ui.set_min_size(ui.available_size()); + // Read-only: keyboard input and terminal replies are dropped. + let _ = p.term.ui(ui, theme, false); + }); + }); + if !open { + self.playback = None; + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn mmss() { + assert_eq!(super::mmss(0.0), "0:00"); + assert_eq!(super::mmss(f64::NAN), "0:00"); + assert_eq!(super::mmss(59.9), "0:59"); + assert_eq!(super::mmss(125.0), "2:05"); + } } diff --git a/rust/app/src/snippets.rs b/rust/app/src/snippets.rs index 545ef6d..44cfadf 100644 --- a/rust/app/src/snippets.rs +++ b/rust/app/src/snippets.rs @@ -1,26 +1,364 @@ -//! Snippet library window and the {{variable}} prompt. -//! STUB — interface fixed; implementation pending. +//! Snippet library window and the {{variable}} prompt (port of snippets.js). -use crate::state::Shared; +use crate::state::{Request, Shared}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Key, RichText}; +use egui_phosphor::regular as icon; +use nexus_core::models::{SessionState, Snippet}; +use regex::Regex; +use std::collections::BTreeMap; +use std::sync::LazyLock; + +static VAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); + +/// Replaces built-ins (`hostname`, `date`, `time`, `timestamp`, case-insensitive) and returns the +/// remaining user variable names, de-duplicated in order of appearance. +pub fn expand_builtins(cmd: &str, hostname: &str) -> (String, Vec) { + let now = chrono::Local::now(); + let mut vars: Vec = vec![]; + let out = VAR_RE.replace_all(cmd, |c: ®ex::Captures| { + let name = c[1].trim(); + let v = match name.to_lowercase().as_str() { + "hostname" => Some(if hostname.is_empty() { "unknown".to_owned() } else { hostname.to_owned() }), + // Same formats as JS: toISOString() date (UTC), toTimeString() HH:MM:SS (local), epoch secs. + "date" => Some(now.with_timezone(&chrono::Utc).format("%Y-%m-%d").to_string()), + "time" => Some(now.format("%H:%M:%S").to_string()), + "timestamp" => Some(now.timestamp().to_string()), + _ => None, + }; + v.unwrap_or_else(|| { + if !vars.iter().any(|x| x == name) { + vars.push(name.to_owned()); + } + c[0].to_owned() + }) + }); + (out.into_owned(), vars) +} + +/// Fills user variables (`vars[i]` ← `values[i]`) left by `expand_builtins`. +pub fn fill(resolved: &str, vars: &[String], values: &[String]) -> String { + VAR_RE + .replace_all(resolved, |c: ®ex::Captures| match vars.iter().position(|v| v == c[1].trim()) { + Some(i) => values.get(i).cloned().unwrap_or_default(), + None => c[0].to_owned(), + }) + .into_owned() +} + +enum Pane { + Empty(&'static str), + Detail(String), + Edit(Snippet), +} + +struct VarForm { + resolved: String, + vars: Vec, + values: Vec, +} -#[derive(Default)] pub struct Snippets { open: bool, + list: Vec, + search: String, + pane: Pane, + confirm_delete: Option, + vars: Option, + loaded: bool, +} + +impl Default for Snippets { + fn default() -> Self { + Snippets { open: false, list: vec![], search: String::new(), pane: Pane::Empty("Select a snippet or create a new one"), confirm_delete: None, vars: None, loaded: false } + } } impl Snippets { pub fn open(&mut self) { self.open = true; - } - - pub fn is_open(&self) -> bool { - self.open + self.loaded = false; + self.pane = Pane::Empty("Select a snippet or create a new one"); } /// Called every frame; draws its window(s) while open. - pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) { + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + self.var_form(ctx, s); + self.delete_dialog(ctx, s); + if !self.open { + return; + } + if !self.loaded { + self.list = s.svc.snippets(); + self.loaded = true; + } let mut open = self.open; - egui::Window::new("Snippets").open(&mut open).show(ctx, |ui| ui.label("todo")); + egui::Window::new(format!("{} Snippets", icon::CODE)) + .id(egui::Id::new("snippets")) + .open(&mut open) + .collapsible(false) + .default_size([720.0, 440.0]) + .show(ctx, |ui| { + ui.horizontal_top(|ui| { + let h = ui.available_height().max(400.0); + ui.allocate_ui(egui::vec2(300.0, h), |ui| { + ui.set_width(300.0); + ui.vertical(|ui| self.list_ui(ui)); + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_height(h); + self.pane_ui(ui, s); + }); + }); + }); self.open = open; } + + fn list_ui(&mut self, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.search).hint_text("Search snippets…").desired_width(200.0)); + if widgets::primary(ui, icon::PLUS, "New").clicked() { + self.pane = Pane::Edit(blank()); + } + }); + let q = self.search.trim().to_lowercase(); + let mut groups: BTreeMap<&str, Vec<&Snippet>> = BTreeMap::new(); + for sn in &self.list { + if q.is_empty() || [&sn.name, &sn.command, &sn.description].iter().any(|f| f.to_lowercase().contains(&q)) { + groups.entry(sn.folder.as_str()).or_default().push(sn); + } + } + if groups.is_empty() { + widgets::empty(ui, icon::CODE, "No snippets found", if q.is_empty() { "Create one with New" } else { "Try another search" }); + return; + } + let selected = match &self.pane { + Pane::Detail(id) => Some(id.clone()), + _ => None, + }; + let mut pick = None; + egui::ScrollArea::vertical().id_salt("snippet-list").auto_shrink([false, false]).show(ui, |ui| { + // Ungrouped ("") sorts first, folders alphabetically after. + for (folder, items) in groups { + if !folder.is_empty() { + widgets::section(ui, &format!("{} {folder}", icon::FOLDER)); + } + for sn in items { + let sel = selected.as_deref() == Some(sn.id.as_str()); + let r = egui::Frame::NONE + .fill(if sel { t.row_selected } else { egui::Color32::TRANSPARENT }) + .corner_radius(t.radius) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + ui.label(RichText::new(&sn.name).font(theme::semibold(13.0))); + ui.label(RichText::new(&sn.command).font(theme::mono(11.5)).color(t.text_dim)); + }) + .response + .interact(egui::Sense::click()); + if r.hovered() && !sel { + ui.painter().rect_filled(r.rect, t.radius, t.hover.gamma_multiply(0.4)); + } + if r.clicked() { + pick = Some(sn.id.clone()); + } + } + } + }); + if let Some(id) = pick { + self.pane = Pane::Detail(id); + } + } + + fn pane_ui(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + match &mut self.pane { + Pane::Empty(msg) => { + widgets::empty(ui, icon::TERMINAL_WINDOW, msg, "Snippets are sent to the active session"); + } + Pane::Detail(id) => { + let Some(sn) = self.list.iter().find(|x| &x.id == id).cloned() else { + self.pane = Pane::Empty("Select a snippet or create a new one"); + return; + }; + ui.label(RichText::new(&sn.name).font(theme::semibold(15.0))); + if !sn.description.is_empty() { + ui.label(RichText::new(&sn.description).color(t.text_dim)); + } + if !sn.folder.is_empty() { + widgets::chip(ui, &sn.folder, t.accent); + } + egui::Frame::NONE.fill(t.field).corner_radius(t.radius).inner_margin(10).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(&sn.command).font(theme::mono(12.5))).wrap()); + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::PAPER_PLANE_RIGHT, "Send to Session").clicked() { + self.send(s, &sn.command); + } + if widgets::button(ui, icon::PENCIL_SIMPLE, "Edit").clicked() { + self.pane = Pane::Edit(sn.clone()); + } + if widgets::button(ui, icon::TRASH, "Delete").clicked() { + self.confirm_delete = Some(sn.id.clone()); + } + }); + } + Pane::Edit(ed) => { + let is_new = ed.id.is_empty(); + ui.label(RichText::new(if is_new { "New Snippet" } else { "Edit Snippet" }).font(theme::semibold(14.0))); + egui::Grid::new("snippet-editor").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| { + ui.label("Name"); + ui.add(egui::TextEdit::singleline(&mut ed.name).desired_width(f32::INFINITY)); + ui.end_row(); + ui.label("Description"); + ui.add(egui::TextEdit::singleline(&mut ed.description).desired_width(f32::INFINITY)); + ui.end_row(); + ui.label("Command"); + ui.add(egui::TextEdit::multiline(&mut ed.command).font(theme::mono(12.5)).desired_rows(8).desired_width(f32::INFINITY)); + ui.end_row(); + ui.label("Folder"); + ui.add(egui::TextEdit::singleline(&mut ed.folder).hint_text("e.g. Networking, Debugging").desired_width(f32::INFINITY)); + ui.end_row(); + }); + widgets::hint(ui, "Variables: {{hostname}} {{date}} {{time}} {{timestamp}}, or {{anything}} to be asked when sending."); + ui.add_space(6.0); + let (mut save, mut cancel) = (false, false); + ui.horizontal(|ui| { + save = widgets::primary(ui, icon::FLOPPY_DISK, "Save").clicked(); + cancel = ui.button("Cancel").clicked(); + }); + if cancel { + self.pane = if is_new { Pane::Empty("Select a snippet or create a new one") } else { Pane::Detail(ed.id.clone()) }; + } else if save { + let mut sn = ed.clone(); + sn.name = sn.name.trim().to_owned(); + sn.description = sn.description.trim().to_owned(); + sn.folder = sn.folder.trim().to_owned(); + if sn.name.is_empty() { + s.error("Name is required"); + } else if sn.command.is_empty() { + s.error("Command is required"); + } else { + match s.svc.save_snippet(sn) { + Ok(saved) => { + s.info(if is_new { "Snippet created" } else { "Snippet updated" }); + self.list = s.svc.snippets(); + self.pane = Pane::Detail(saved.id); + } + Err(e) => s.error(format!("Failed to save snippet: {e}")), + } + } + } + } + } + } + + /// Resolves variables then types the command (plus newline) into the focused session. + fn send(&mut self, s: &mut Shared, command: &str) { + let Some(id) = s.focused_session() else { return s.error("No active session") }; + if s.session_state(&id) != Some(SessionState::Connected) { + return s.error("Session is not connected"); + } + let host = s.pane(&id).map(|p| p.info.host.clone()).unwrap_or_default(); + let (resolved, vars) = expand_builtins(command, &host); + if vars.is_empty() { + send_text(s, resolved); + } else { + let values = vec![String::new(); vars.len()]; + self.vars = Some(VarForm { resolved, vars, values }); + } + } + + fn var_form(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(f) = &mut self.vars else { return }; + let (mut send, mut cancel) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + crate::chrome::modal(ctx, "snippet-vars", 360.0, |ui| { + ui.label(RichText::new("Snippet Variables").font(theme::semibold(15.0))); + ui.add_space(4.0); + for (i, (name, value)) in f.vars.iter().zip(f.values.iter_mut()).enumerate() { + ui.label(name); + let r = ui.add(egui::TextEdit::singleline(value).hint_text(format!("Enter value for {name}")).desired_width(f32::INFINITY)); + if i == 0 && !ui.ctx().memory(|m| m.focused().is_some()) { + r.request_focus(); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + send = widgets::primary(ui, icon::PAPER_PLANE_RIGHT, "Send").clicked() || ui.input(|i| i.key_pressed(Key::Enter)); + cancel |= ui.button("Cancel").clicked(); + }); + }); + if send { + let text = fill(&f.resolved, &f.vars, &f.values); + self.vars = None; + send_text(s, text); + } else if cancel { + self.vars = None; + } + } + + fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(id) = self.confirm_delete.clone() else { return }; + let name = self.list.iter().find(|x| x.id == id).map(|x| x.name.clone()).unwrap_or_default(); + let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + crate::chrome::modal(ctx, "snippet-delete", 360.0, |ui| { + ui.label(RichText::new("Delete snippet?").font(theme::semibold(15.0))); + ui.label(format!("\u{201c}{name}\u{201d} will be permanently deleted.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + yes = widgets::primary(ui, icon::TRASH, "Delete").clicked(); + no |= ui.button("Cancel").clicked(); + }); + }); + if yes { + self.confirm_delete = None; + match s.svc.delete_snippet(&id) { + Ok(()) => { + self.list.retain(|x| x.id != id); + self.pane = Pane::Empty("Snippet deleted"); + } + Err(e) => s.error(format!("Failed to delete: {e}")), + } + } else if no { + self.confirm_delete = None; + } + } +} + +fn blank() -> Snippet { + let now = nexus_core::models::now(); + Snippet { id: String::new(), name: String::new(), command: String::new(), description: String::new(), folder: String::new(), tags: vec![], created_at: now, updated_at: now } +} + +fn send_text(s: &mut Shared, text: String) { + s.request(Request::SendToActive(text + "\n")); + s.info("Snippet sent"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_and_prompted_vars() { + let (out, vars) = expand_builtins("ssh {{ HOSTNAME }} {{user}} {{user}} {{port}} at {{timestamp}}", "box"); + assert!(out.starts_with("ssh box {{user}} {{user}} {{port}} at ")); + assert!(out.rsplit(' ').next().unwrap().parse::().is_ok()); + assert_eq!(vars, ["user", "port"]); + assert_eq!(fill(&out, &vars, &["root".into(), "22".into()]).split(" at ").next().unwrap(), "ssh box root root 22"); + let (out, _) = expand_builtins("{{hostname}} {{date}} {{time}}", ""); + let parts: Vec<&str> = out.split(' ').collect(); + assert_eq!(parts[0], "unknown"); + assert!(chrono::NaiveDate::parse_from_str(parts[1], "%Y-%m-%d").is_ok()); + assert!(chrono::NaiveTime::parse_from_str(parts[2], "%H:%M:%S").is_ok()); + assert_eq!(expand_builtins("echo {not} {{}}", "h"), ("echo {not} {{}}".into(), vec![])); + } } diff --git a/rust/app/src/triggers.rs b/rust/app/src/triggers.rs index 31faa7e..864f404 100644 --- a/rust/app/src/triggers.rs +++ b/rust/app/src/triggers.rs @@ -1,11 +1,92 @@ -//! Trigger rules: match terminal output lines and notify / highlight / beep. -//! STUB — interface fixed; implementation pending. +//! Trigger rules: match terminal output lines and notify / highlight / beep (port of triggers.js). +//! Rules live in `triggers.json` in the data directory (the web build's localStorage rules are +//! not migrated). use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::RichText; +use egui_phosphor::regular as icon; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::LazyLock; + +const ACTIONS: [(&str, &str); 4] = [("notify", "Notify"), ("highlight", "Highlight tab"), ("both", "Notify + Highlight"), ("sound", "Sound")]; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Rule { + pub name: String, + pub pattern: String, + pub is_regex: bool, + pub action: String, + pub case_sensitive: bool, + pub enabled: bool, + #[serde(skip)] + re: Option, +} + +impl Rule { + fn compile(&mut self) -> Result<(), regex::Error> { + self.re = None; + if self.is_regex { + self.re = Some(RegexBuilder::new(&self.pattern).case_insensitive(!self.case_sensitive).build()?); + } + Ok(()) + } + + /// Whether `line` (already ANSI-stripped) matches. An invalid regex never matches. + pub fn matches(&self, line: &str) -> bool { + if self.is_regex { + self.re.as_ref().is_some_and(|r| r.is_match(line)) + } else if self.case_sensitive { + line.contains(&self.pattern) + } else { + line.to_lowercase().contains(&self.pattern.to_lowercase()) + } + } +} + +/// Strips CSI sequences exactly as the JS did (`/\x1b\[[0-9;]*[a-zA-Z]/g`). +pub fn strip_ansi(line: &str) -> std::borrow::Cow<'_, str> { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap()); + RE.replace_all(line, "") +} + +/// Appends `data` to the session buffer and returns every completed line; the trailing partial +/// line stays buffered. +pub fn complete_lines(buf: &mut Vec, data: &[u8]) -> Vec { + buf.extend_from_slice(data); + let Some(last) = buf.iter().rposition(|&b| b == b'\n') else { + // ponytail: a line with no newline is capped at 64 KiB (keeps the tail) so a stream + // without newlines can't grow forever. + if buf.len() > 64 * 1024 { + buf.drain(..buf.len() - 64 * 1024); + } + return vec![]; + }; + let rest = buf.split_off(last + 1); + let done = std::mem::replace(buf, rest); + done[..done.len() - 1].split(|&b| b == b'\n').map(|l| String::from_utf8_lossy(l).into_owned()).collect() +} + +enum Form { + New(Rule), + Edit(usize, Rule), +} #[derive(Default)] pub struct Triggers { open: bool, + rules: Option>, + buffers: HashMap>, + form: Option
, +} + +fn path(s: &Shared) -> PathBuf { + s.svc.config.data_dir.join("triggers.json") } impl Triggers { @@ -13,13 +94,226 @@ impl Triggers { 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 + fn rules(&mut self, s: &Shared) -> &mut Vec { + self.rules.get_or_insert_with(|| { + let mut v: Vec = std::fs::read(path(s)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + v.iter_mut().for_each(|r| drop(r.compile())); + v + }) } - pub fn cleanup(&mut self, _session: &str) {} + fn save(&mut self, s: &mut Shared) { + let json = serde_json::to_vec_pretty(self.rules(s)).unwrap_or_default(); + if let Err(e) = std::fs::write(path(s), json) { + s.error(format!("Failed to save trigger rules: {e}")); + } + } - pub fn ui(&mut self, _ctx: &egui::Context, _s: &mut Shared) {} + /// 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 { + if self.rules(s).is_empty() { + return false; + } + let lines = complete_lines(self.buffers.entry(session.to_owned()).or_default(), data); + let mut highlight = false; + let label = s.pane(session).map(|p| if p.info.tab_name.is_empty() { p.info.profile_name.clone() } else { p.info.tab_name.clone() }).unwrap_or_else(|| session.to_owned()); + let mut fired: Vec<(String, String)> = vec![]; + for line in &lines { + let clean = strip_ansi(line); + for r in self.rules(s).iter().filter(|r| r.enabled && r.matches(&clean)) { + let name = if r.name.is_empty() { r.pattern.clone() } else { r.name.clone() }; + fired.push((r.action.clone(), name)); + } + } + for (action, name) in fired { + let msg = format!("[{label}] Trigger: {name}"); + match action.as_str() { + "notify" => s.info(msg), + "highlight" => highlight = true, + "both" => { + s.info(msg); + highlight = true; + } + "sound" => { + s.ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); + s.info(format!("{} {msg}", icon::BELL_RINGING)); + } + _ => {} + } + } + highlight + } + + pub fn cleanup(&mut self, session: &str) { + self.buffers.remove(session); + } + + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + if !self.open { + return; + } + let mut open = self.open; + egui::Window::new(format!("{} Trigger Rules", icon::LIGHTNING)) + .id(egui::Id::new("triggers")) + .open(&mut open) + .collapsible(false) + .default_size([620.0, 380.0]) + .show(ctx, |ui| { + widgets::hint(ui, "Rules match against terminal output and fire actions when a pattern is detected."); + ui.add_space(4.0); + if self.form.is_some() { + self.form_ui(ui, s); + } else { + self.list_ui(ui, s); + } + }); + self.open = open; + } + + fn list_ui(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + if widgets::primary(ui, icon::PLUS, "Add Rule").clicked() { + self.form = Some(Form::New(Rule { action: "notify".into(), enabled: true, ..Default::default() })); + } + ui.add_space(4.0); + let (mut changed, mut edit, mut delete) = (false, None, None); + let rules = self.rules(s); + if rules.is_empty() { + widgets::empty(ui, icon::LIGHTNING, "No trigger rules defined", "Add a rule to watch terminal output"); + return; + } + egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + egui::Grid::new("trigger-rules").num_columns(4).striped(true).spacing([12.0, 6.0]).show(ui, |ui| { + for (i, r) in rules.iter_mut().enumerate() { + changed |= ui.checkbox(&mut r.enabled, "").on_hover_text("Enabled").changed(); + ui.vertical(|ui| { + ui.label(RichText::new(if r.name.is_empty() { &r.pattern } else { &r.name }).font(theme::semibold(13.0))); + let action = ACTIONS.iter().find(|a| a.0 == r.action).map_or(r.action.as_str(), |a| a.1); + let meta = format!("{} · {action} · {}", if r.is_regex { "Regex" } else { "Text" }, if r.case_sensitive { "Case-sensitive" } else { "Case-insensitive" }); + ui.label(RichText::new(meta).size(11.5).color(t.text_dim)); + }); + ui.label(RichText::new(&r.pattern).font(theme::mono(12.0)).color(if r.is_regex && r.re.is_none() { t.danger } else { t.text })); + ui.horizontal(|ui| { + if widgets::icon_button(ui, icon::PENCIL_SIMPLE, "Edit").clicked() { + edit = Some(i); + } + if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() { + delete = Some(i); + } + }); + ui.end_row(); + } + }); + }); + if let Some(i) = delete { + rules.remove(i); + changed = true; + } else if let Some(i) = edit { + let rule = rules[i].clone(); + self.form = Some(Form::Edit(i, rule)); + } + if changed { + self.save(s); + } + } + + fn form_ui(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let Some(form) = &mut self.form else { return }; + let (is_new, r) = match form { + Form::New(r) => (true, r), + Form::Edit(_, r) => (false, r), + }; + egui::Grid::new("trigger-form").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + ui.label("Name (optional)"); + ui.add(egui::TextEdit::singleline(&mut r.name).hint_text("e.g. Error alert").desired_width(320.0)); + ui.end_row(); + ui.label("Pattern"); + ui.add(egui::TextEdit::singleline(&mut r.pattern).hint_text("e.g. ERROR|FATAL|panic").font(theme::mono(12.5)).desired_width(320.0)); + ui.end_row(); + ui.label("Match type"); + egui::ComboBox::from_id_salt("trigger-type").selected_text(if r.is_regex { "Regex" } else { "Plain text" }).show_ui(ui, |ui| { + ui.selectable_value(&mut r.is_regex, false, "Plain text"); + ui.selectable_value(&mut r.is_regex, true, "Regex"); + }); + ui.end_row(); + ui.label("Action"); + let cur = ACTIONS.iter().find(|a| a.0 == r.action).map_or("Notify", |a| a.1); + egui::ComboBox::from_id_salt("trigger-action").selected_text(cur).show_ui(ui, |ui| { + for (id, label) in ACTIONS { + if ui.selectable_label(r.action == id, label).clicked() { + r.action = id.into(); + } + } + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut r.case_sensitive, "Case-sensitive"); + ui.end_row(); + }); + ui.add_space(8.0); + let (mut save, mut cancel) = (false, false); + ui.horizontal(|ui| { + save = widgets::primary(ui, icon::CHECK, if is_new { "Add" } else { "Save" }).clicked(); + cancel = ui.button("Cancel").clicked(); + }); + if cancel { + self.form = None; + } else if save { + let mut rule = r.clone(); + rule.name = rule.name.trim().to_owned(); + rule.pattern = rule.pattern.trim().to_owned(); + if rule.pattern.is_empty() { + return s.error("Pattern is required"); + } + if let Err(e) = rule.compile() { + return s.error(format!("Invalid regex: {e}")); + } + let form = self.form.take(); + let rules = self.rules(s); + match form { + Some(Form::Edit(i, _)) if i < rules.len() => rules[i] = rule, + _ => rules.push(rule), + } + self.save(s); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_buffering() { + let mut b = vec![]; + assert!(complete_lines(&mut b, b"par").is_empty()); + assert_eq!(complete_lines(&mut b, b"tial\nnext\n\nlast"), ["partial", "next", ""]); + assert_eq!(b, b"last"); + assert_eq!(complete_lines(&mut b, b"\n"), ["last"]); + assert!(b.is_empty()); + // A UTF-8 char split across chunks survives. + let e = "é".as_bytes(); + complete_lines(&mut b, &e[..1]); + assert_eq!(complete_lines(&mut b, &[e[1], b'\n']), ["é"]); + } + + #[test] + fn ansi_strip_and_match() { + assert_eq!(strip_ansi("\x1b[1;31mERROR\x1b[0m: disk\x1b[K"), "ERROR: disk"); + // Only CSI with digits/semicolons is stripped, as in the JS. + assert_eq!(strip_ansi("\x1b[?25lx\x1b]0;t\x07"), "\x1b[?25lx\x1b]0;t\x07"); + let mut r = Rule { pattern: "error|fatal".into(), is_regex: true, enabled: true, ..Default::default() }; + r.compile().unwrap(); + assert!(r.matches(&strip_ansi("\x1b[31mFATAL\x1b[0m"))); + r.case_sensitive = true; + r.compile().unwrap(); + assert!(!r.matches("FATAL")); + let r = Rule { pattern: "Disk Full".into(), ..Default::default() }; + assert!(r.matches("warning: disk full")); + let mut bad = Rule { pattern: "(".into(), is_regex: true, ..Default::default() }; + assert!(bad.compile().is_err()); + assert!(!bad.matches("(")); + } }