rust: snippets, triggers, recordings and playback
This commit is contained in:
@@ -611,7 +611,7 @@ pub fn palette(app: &mut App, ctx: &egui::Context) {
|
|||||||
|
|
||||||
// --- prompts --------------------------------------------------------------------------------
|
// --- 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);
|
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::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| {
|
egui::Frame::popup(ui.style()).corner_radius(10).inner_margin(16).show(ui, |ui| {
|
||||||
|
|||||||
+314
-7
@@ -1,26 +1,333 @@
|
|||||||
//! Recordings browser and playback window.
|
//! Recordings browser and playback window (port of the RecordingManager in recordings.js).
|
||||||
//! STUB — interface fixed; implementation pending.
|
|
||||||
|
|
||||||
use crate::state::Shared;
|
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<RecordingMeta>, StorageInfo),
|
||||||
|
Cleaned(CleanupResult),
|
||||||
|
Recording(String, Header, Vec<Event>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Playback {
|
||||||
|
title: String,
|
||||||
|
term: TermView,
|
||||||
|
events: Vec<Event>,
|
||||||
|
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 {
|
pub struct Recordings {
|
||||||
open: bool,
|
open: bool,
|
||||||
|
list: Vec<RecordingMeta>,
|
||||||
|
info: Option<StorageInfo>,
|
||||||
|
loading: bool,
|
||||||
|
rx: Option<Receiver<Loaded>>,
|
||||||
|
age: u32,
|
||||||
|
size: u64,
|
||||||
|
status: String,
|
||||||
|
confirm_delete: Option<RecordingMeta>,
|
||||||
|
playback: Option<Playback>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Recordings {
|
||||||
pub fn open(&mut self) {
|
pub fn open(&mut self) {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
|
self.status.clear();
|
||||||
|
self.info = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_open(&self) -> bool {
|
/// Runs `f` on a worker thread; its result is picked up by `poll`.
|
||||||
self.open
|
fn spawn(&mut self, s: &Shared, f: impl FnOnce(&nexus_core::recording::Store) -> nexus_core::Result<Loaded> + 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.
|
/// 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;
|
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;
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+348
-10
@@ -1,26 +1,364 @@
|
|||||||
//! Snippet library window and the {{variable}} prompt.
|
//! Snippet library window and the {{variable}} prompt (port of snippets.js).
|
||||||
//! STUB — interface fixed; implementation pending.
|
|
||||||
|
|
||||||
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<Regex> = 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<String>) {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let mut vars: Vec<String> = 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<String>,
|
||||||
|
values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Snippets {
|
pub struct Snippets {
|
||||||
open: bool,
|
open: bool,
|
||||||
|
list: Vec<Snippet>,
|
||||||
|
search: String,
|
||||||
|
pane: Pane,
|
||||||
|
confirm_delete: Option<String>,
|
||||||
|
vars: Option<VarForm>,
|
||||||
|
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 {
|
impl Snippets {
|
||||||
pub fn open(&mut self) {
|
pub fn open(&mut self) {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
}
|
self.loaded = false;
|
||||||
|
self.pane = Pane::Empty("Select a snippet or create a new one");
|
||||||
pub fn is_open(&self) -> bool {
|
|
||||||
self.open
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called every frame; draws its window(s) while open.
|
/// 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;
|
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;
|
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::<i64>().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![]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+302
-8
@@ -1,11 +1,92 @@
|
|||||||
//! Trigger rules: match terminal output lines and notify / highlight / beep.
|
//! Trigger rules: match terminal output lines and notify / highlight / beep (port of triggers.js).
|
||||||
//! STUB — interface fixed; implementation pending.
|
//! Rules live in `triggers.json` in the data directory (the web build's localStorage rules are
|
||||||
|
//! not migrated).
|
||||||
|
|
||||||
use crate::state::Shared;
|
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<Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Regex> = 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<u8>, data: &[u8]) -> Vec<String> {
|
||||||
|
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)]
|
#[derive(Default)]
|
||||||
pub struct Triggers {
|
pub struct Triggers {
|
||||||
open: bool,
|
open: bool,
|
||||||
|
rules: Option<Vec<Rule>>,
|
||||||
|
buffers: HashMap<String, Vec<u8>>,
|
||||||
|
form: Option<Form>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(s: &Shared) -> PathBuf {
|
||||||
|
s.svc.config.data_dir.join("triggers.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Triggers {
|
impl Triggers {
|
||||||
@@ -13,13 +94,226 @@ impl Triggers {
|
|||||||
self.open = true;
|
self.open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feeds session output; fires matching rules (toasts, highlight request, bell).
|
fn rules(&mut self, s: &Shared) -> &mut Vec<Rule> {
|
||||||
/// Returns true if the tab should be highlighted.
|
self.rules.get_or_insert_with(|| {
|
||||||
pub fn check(&mut self, _s: &mut Shared, _session: &str, _data: &[u8]) -> bool {
|
let mut v: Vec<Rule> = std::fs::read(path(s)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||||
false
|
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("("));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user