From c2b8ac07d80d6cf4c518c86934e12dc3e231085b Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:10:54 +0300 Subject: [PATCH 01/20] [Fixed] Restore the terminal on panic --- src/main.rs | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index 402867e..df55eb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use color_eyre::Result; use crossterm::{ + cursor::Show, event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, @@ -14,24 +15,44 @@ mod views; use dashboard::Dashboard; +/// Restore the terminal to its normal state. Safe to call more than once. +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture, Show); +} + +/// Restores the terminal when dropped, so a normal return, an error, or a +/// panic all leave the shell usable instead of stuck in raw mode. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore_terminal(); + } +} + fn main() -> Result<()> { color_eyre::install()?; + + // Chain a terminal restore in front of color_eyre's panic hook so a panic + // never leaves the user in the alternate screen with raw mode enabled. + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + previous_hook(info); + })); + enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + let _guard = TerminalGuard; - let term_backend = CrosstermBackend::new(stdout); + let term_backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(term_backend)?; let outcome = Dashboard::new().and_then(|mut app| app.run(&mut terminal)); - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; + // Restore before printing any error so the report is readable. + drop(_guard); if let Err(err) = outcome { eprintln!("{}", err); From 1c576314c7f39db99a75260fef6e2d163a8cd500 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:11:41 +0300 Subject: [PATCH 02/20] [Fixed] Panic-proof the live file watcher --- src/core/live_file.rs | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/core/live_file.rs b/src/core/live_file.rs index ba30869..f2bf1ef 100644 --- a/src/core/live_file.rs +++ b/src/core/live_file.rs @@ -73,13 +73,21 @@ impl FileObserver { fn event_loop(&mut self) -> Result<(), RecvError> { let (fs_tx, fs_rx) = unbounded(); - let mut watcher = notify::recommended_watcher(move |res: notify::Result| { - let ev = res.unwrap(); - if let notify::EventKind::Modify(ModifyKind::Data(_)) = ev.kind { - fs_tx.send(ev.paths).unwrap(); - } - }) - .unwrap(); + let mut watcher = + match notify::recommended_watcher(move |res: notify::Result| { + let Ok(ev) = res else { return }; + if let notify::EventKind::Modify(ModifyKind::Data(_)) = ev.kind { + let _ = fs_tx.send(ev.paths); + } + }) { + Ok(w) => w, + Err(e) => { + // No watcher (e.g. inotify limits on a login node): report it + // and end the observer cleanly instead of panicking the thread. + let _ = self.output.send(Err(MonitorError::Watcher(e))); + return Ok(()); + } + }; let (mut content_tx, mut content_rx) = unbounded::>(); let (mut notify_tx, mut notify_rx) = unbounded::<()>(); @@ -94,9 +102,7 @@ impl FileObserver { (notify_tx, notify_rx) = unbounded::<()>(); if let Some(old) = &self.watched { - watcher - .unwatch(old) - .unwrap_or_else(|_| panic!("Failed to unwatch {:?}", old)); + let _ = watcher.unwatch(old); self.watched = None; } @@ -129,22 +135,20 @@ impl FileObserver { }); } Err(e) => { - self.output - .send(Err(MonitorError::Watcher(e))) - .unwrap(); + let _ = self.output.send(Err(MonitorError::Watcher(e))); } } } else { - content_tx.send(Ok(String::new())).unwrap(); + let _ = content_tx.send(Ok(String::new())); } } } } - recv(fs_rx) -> _ => { notify_tx.send(()).unwrap(); } + recv(fs_rx) -> _ => { let _ = notify_tx.send(()); } recv(content_rx) -> msg => { - self.output - .send(msg.unwrap().map_err(MonitorError::File)) - .unwrap(); + if let Ok(inner) = msg { + let _ = self.output.send(inner.map_err(MonitorError::File)); + } } } } @@ -205,7 +209,7 @@ impl LiveFileMonitor { pub fn set_file_path(&mut self, path: Option) { if self.tracked_path != path { self.tracked_path = path.clone(); - self.channel.send(MonitorMsg::WatchPath(path)).unwrap(); + let _ = self.channel.send(MonitorMsg::WatchPath(path)); } } } From 7102d673013045f66e7a620638b89c7b24dbaf1c Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:12:50 +0300 Subject: [PATCH 03/20] [Fixed] Report background worker failures instead of freezing --- src/core/input.rs | 13 +++++++++++-- src/core/job_fetcher.rs | 18 ++++++++++++++---- src/dashboard.rs | 13 +++++++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/core/input.rs b/src/core/input.rs index 9a07eb3..f105e6f 100644 --- a/src/core/input.rs +++ b/src/core/input.rs @@ -51,8 +51,17 @@ impl InputLoop { .checked_sub(prev_tick.elapsed()) .unwrap_or(Duration::ZERO); - if event::poll(remaining).expect("event poll failed") { - let ev = event::read().expect("event read failed"); + let ready = match event::poll(remaining) { + Ok(ready) => ready, + // Terminal input closed (e.g. SSH disconnect): stop the + // worker so its dropped sender signals the main loop. + Err(_) => break, + }; + if ready { + let ev = match event::read() { + Ok(ev) => ev, + Err(_) => break, + }; let signal = match ev { TermEvent::Key(k) => Some(Signal::Keyboard(k)), TermEvent::Mouse(m) if cfg.capture_mouse => Some(Signal::Mouse(m)), diff --git a/src/core/job_fetcher.rs b/src/core/job_fetcher.rs index 02faf0c..0775d92 100644 --- a/src/core/job_fetcher.rs +++ b/src/core/job_fetcher.rs @@ -1,6 +1,6 @@ use std::thread; -use crossbeam::channel::{Receiver, Sender, unbounded}; +use crossbeam::channel::{Receiver, Sender, TryRecvError, unbounded}; use crate::backend::Job; use crate::backend::query::{QueryParams, fetch_jobs}; @@ -21,10 +21,16 @@ impl JobFetcher { let (res_tx, res_rx) = unbounded::, String>>(); thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() + let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .expect("tokio runtime for job fetcher"); + { + Ok(rt) => rt, + Err(e) => { + let _ = res_tx.send(Err(format!("job fetch worker failed to start: {}", e))); + return; + } + }; while let Ok(mut params) = req_rx.recv() { // Drain queued requests, keep only the latest @@ -56,7 +62,11 @@ impl JobFetcher { self.in_flight = false; Some(result) } - Err(_) => None, + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => { + self.in_flight = false; + Some(Err("job fetch worker stopped".to_string())) + } } } } diff --git a/src/dashboard.rs b/src/dashboard.rs index b7075cf..1ed29b4 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -499,8 +499,17 @@ impl Dashboard { // ── Input handling ─────────────────────────────────────── fn process_input(&mut self) -> Result<()> { - // Block until the first event arrives. - let first = self.input.rx.recv()?; + use std::sync::mpsc::RecvTimeoutError; + + // Wait for the next event, but time out so a dead input thread (a + // dropped sender) is detected instead of blocking the UI forever. + let first = match self.input.rx.recv_timeout(Duration::from_millis(500)) { + Ok(sig) => sig, + Err(RecvTimeoutError::Timeout) => return Ok(()), + Err(RecvTimeoutError::Disconnected) => { + color_eyre::eyre::bail!("input worker thread stopped unexpectedly"); + } + }; // Drain every additional pending event so stale timers that // accumulated while the terminal was unfocused are collapsed From 6d7ffc63ac464423e3c87f5fa84c4ad5d934df08 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:13:25 +0300 Subject: [PATCH 04/20] [Fixed] Surface squeue failures instead of an empty table --- src/backend/query.rs | 24 ++++++++++++++++-------- src/dashboard.rs | 8 +++++++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/backend/query.rs b/src/backend/query.rs index 5fbf4aa..e42bfa2 100644 --- a/src/backend/query.rs +++ b/src/backend/query.rs @@ -1,6 +1,5 @@ use async_process::{Command, Output}; use color_eyre::Result; -use color_eyre::eyre::Error; use std::str::FromStr; use super::Job; @@ -106,16 +105,25 @@ impl QueryParams { } pub async fn fetch_jobs(params: &QueryParams) -> Result> { - let args = params.build_args(); - if !params.is_valid_format() { - return Ok(Vec::new()); + color_eyre::eyre::bail!("internal error: invalid squeue format string"); } - let output = match Command::new("squeue").args(&args).output().await { - Ok(o) => o, - Err(e) => return Err(Error::new(e)), - }; + let args = params.build_args(); + let output = Command::new("squeue") + .args(&args) + .output() + .await + .map_err(|e| color_eyre::eyre::eyre!("failed to run squeue: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + if detail.is_empty() { + color_eyre::eyre::bail!("squeue exited with {}", output.status); + } + color_eyre::eyre::bail!("squeue: {}", detail); + } decode_output(&output, ¶ms.fmt) } diff --git a/src/dashboard.rs b/src/dashboard.rs index 1ed29b4..00bb03e 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -171,7 +171,13 @@ impl Dashboard { self.rebuild_format(); let p = self.params.clone(); - let mut jobs = self.rt.block_on(fetch_jobs(&p))?; + let mut jobs = match self.rt.block_on(fetch_jobs(&p)) { + Ok(jobs) => jobs, + Err(e) => { + self.flash(format!("squeue failed: {}", e), 10); + Vec::new() + } + }; let mut stats = Vec::new(); let total = jobs.len(); From 4cd99d8b8e20ac6486044dbcae8b4993abad2994 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:15:36 +0300 Subject: [PATCH 05/20] [Added] Follow mode for log and custom widgets --- src/views/custom_widget.rs | 34 ++++++++++++++++++++++++++++------ src/views/output_widget.rs | 35 +++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/views/custom_widget.rs b/src/views/custom_widget.rs index 4bfab88..291f67d 100644 --- a/src/views/custom_widget.rs +++ b/src/views/custom_widget.rs @@ -35,6 +35,8 @@ pub struct CustomOutputWidget { data_rx: Option>>, fstate: FileState, display_content: String, + /// When true, the view stays pinned to the tail as new content arrives. + follow: bool, } impl CustomOutputWidget { @@ -52,6 +54,7 @@ impl CustomOutputWidget { data_rx: None, fstate: FileState::Missing, display_content: String::new(), + follow: true, } } @@ -61,6 +64,7 @@ impl CustomOutputWidget { self.content.clear(); self.scroll_pos = 0; self.fstate = FileState::Missing; + self.follow = true; if self.monitor.is_none() { let (tx, rx) = unbounded(); @@ -131,12 +135,14 @@ impl CustomOutputWidget { self.content.clear(); self.scroll_pos = 0; self.fstate = FileState::Missing; + self.follow = true; if let Some(m) = &mut self.monitor { m.set_file_path(None); } } pub fn scroll_up(&mut self) { + self.follow = false; self.scroll_pos = self.scroll_pos.saturating_sub(1); } @@ -144,21 +150,29 @@ impl CustomOutputWidget { if self.scroll_pos < self.max_scroll { self.scroll_pos += 1; } + self.follow = self.scroll_pos >= self.max_scroll; } pub fn page_up(&mut self) { + self.follow = false; self.scroll_pos = self.scroll_pos.saturating_sub(10); } pub fn page_down(&mut self) { self.scroll_pos = (self.scroll_pos + 10).min(self.max_scroll); + self.follow = self.scroll_pos >= self.max_scroll; } pub fn render_inline(&mut self, frame: &mut Frame, area: Rect, focused: bool) { let border_color = if focused { ACCENT_CUSTOM } else { DIM_BORDER }; + let title = if self.job_id.is_some() && self.follow { + format!(" {} [follow] ", self.title) + } else { + format!(" {} ", self.title) + }; let block = Block::default() - .title(format!(" {} ", self.title)) + .title(title) .borders(Borders::ALL) .border_type(if focused { BorderType::Double @@ -185,18 +199,20 @@ impl CustomOutputWidget { _ => self.display_content.clone(), }; - let widget = Paragraph::new(display_text) + let para = Paragraph::new(display_text) .style(Style::default().fg(Color::Rgb(200, 200, 210))) .block(block) - .wrap(Wrap { trim: false }) - .scroll((self.scroll_pos as u16, 0)); + .wrap(Wrap { trim: false }); let inner_width = area.width.saturating_sub(2); let inner_height = area.height.saturating_sub(2) as usize; - let total_lines = widget.line_count(inner_width); + let total_lines = para.line_count(inner_width); self.max_scroll = total_lines.saturating_sub(inner_height); + if self.follow { + self.scroll_pos = self.max_scroll; + } - frame.render_widget(widget, area); + frame.render_widget(para.scroll((self.scroll_pos as u16, 0)), area); } pub fn handle_key(&mut self, key: KeyEvent) { @@ -207,6 +223,12 @@ impl CustomOutputWidget { (_, KeyCode::PageDown) | (KeyModifiers::CONTROL, KeyCode::Char('d')) => { self.page_down() } + (_, KeyCode::Home) => { + self.follow = false; + self.scroll_pos = 0; + } + (_, KeyCode::End) => self.follow = true, + (_, KeyCode::Char('f')) => self.follow = !self.follow, _ => {} } } diff --git a/src/views/output_widget.rs b/src/views/output_widget.rs index c452f6f..39befaf 100644 --- a/src/views/output_widget.rs +++ b/src/views/output_widget.rs @@ -49,6 +49,8 @@ pub struct OutputWidget { data_rx: Option>>, fstate: FileState, detail_applied: bool, + /// When true, the view stays pinned to the tail as new content arrives. + follow: bool, } impl OutputWidget { @@ -65,6 +67,7 @@ impl OutputWidget { data_rx: None, fstate: FileState::Missing, detail_applied: false, + follow: true, } } @@ -76,6 +79,7 @@ impl OutputWidget { self.scroll_pos = 0; self.fstate = FileState::Loading; self.detail_applied = false; + self.follow = true; if self.monitor.is_none() { let (tx, rx) = unbounded(); @@ -151,6 +155,7 @@ impl OutputWidget { } pub fn scroll_up(&mut self) { + self.follow = false; self.scroll_pos = self.scroll_pos.saturating_sub(1); } @@ -158,14 +163,18 @@ impl OutputWidget { if self.scroll_pos < self.max_scroll { self.scroll_pos += 1; } + // Re-arm following once the user scrolls back to the bottom. + self.follow = self.scroll_pos >= self.max_scroll; } pub fn page_up(&mut self) { + self.follow = false; self.scroll_pos = self.scroll_pos.saturating_sub(10); } pub fn page_down(&mut self) { self.scroll_pos = (self.scroll_pos + 10).min(self.max_scroll); + self.follow = self.scroll_pos >= self.max_scroll; } pub fn ensure_job(&mut self, job_id: &str) { @@ -181,6 +190,7 @@ impl OutputWidget { self.scroll_pos = 0; self.fstate = FileState::Missing; self.detail_applied = false; + self.follow = true; if let Some(m) = &mut self.monitor { m.set_file_path(None); } @@ -193,8 +203,13 @@ impl OutputWidget { }; let border_color = if focused { focused_color } else { DIM_BORDER }; + let title = if self.job_id.is_some() && self.follow { + format!(" {} [follow] ", self.stream.label()) + } else { + format!(" {} ", self.stream.label()) + }; let block = Block::default() - .title(format!(" {} ", self.stream.label())) + .title(title) .borders(Borders::ALL) .border_type(if focused { BorderType::Double @@ -228,18 +243,20 @@ impl OutputWidget { _ => self.content.clone(), }; - let widget = Paragraph::new(display_text) + let para = Paragraph::new(display_text) .style(Style::default().fg(Color::Rgb(200, 200, 210))) .block(block) - .wrap(Wrap { trim: false }) - .scroll((self.scroll_pos as u16, 0)); + .wrap(Wrap { trim: false }); let inner_width = area.width.saturating_sub(2); let inner_height = area.height.saturating_sub(2) as usize; - let total_lines = widget.line_count(inner_width); + let total_lines = para.line_count(inner_width); self.max_scroll = total_lines.saturating_sub(inner_height); + if self.follow { + self.scroll_pos = self.max_scroll; + } - frame.render_widget(widget, area); + frame.render_widget(para.scroll((self.scroll_pos as u16, 0)), area); } pub fn handle_key(&mut self, key: KeyEvent) { @@ -250,6 +267,12 @@ impl OutputWidget { (_, KeyCode::PageDown) | (KeyModifiers::CONTROL, KeyCode::Char('d')) => { self.page_down() } + (_, KeyCode::Home) => { + self.follow = false; + self.scroll_pos = 0; + } + (_, KeyCode::End) => self.follow = true, + (_, KeyCode::Char('f')) => self.follow = !self.follow, _ => {} } } From c428a8178f407d8fae675c3c0bbe13aa58a4362d Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:15:51 +0300 Subject: [PATCH 06/20] [Fixed] Decode the %R reason column --- src/backend/query.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/query.rs b/src/backend/query.rs index e42bfa2..94f86f5 100644 --- a/src/backend/query.rs +++ b/src/backend/query.rs @@ -182,6 +182,7 @@ fn decode_output(output: &Output, fmt: &str) -> Result> { "%V" => job.submit_time = Some(val), "%S" => job.start_time = Some(val), "%e" => job.end_time = Some(val), + "%R" => job.reason = Some(val), _ => {} } } From 574a2804cfb6b871ff3cff2b8425839eb47088da Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:16:40 +0300 Subject: [PATCH 07/20] [Changed] Use a control character as the squeue field separator --- src/backend/query.rs | 13 +++++++++---- src/dashboard.rs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/backend/query.rs b/src/backend/query.rs index 94f86f5..61b3372 100644 --- a/src/backend/query.rs +++ b/src/backend/query.rs @@ -5,6 +5,11 @@ use std::str::FromStr; use super::Job; use super::JobState; +/// Field separator embedded in the `squeue --format` string. A control +/// character (ASCII Unit Separator) is used instead of `|` so job names +/// that contain `|` cannot corrupt column parsing. +pub const FIELD_SEP: &str = "\u{1f}"; + #[derive(Debug, Clone)] pub struct QueryParams { pub user: Option, @@ -27,7 +32,7 @@ impl Default for QueryParams { qos: Vec::new(), name_pattern: None, nodes: Vec::new(), - fmt: "%i|%j|%u|%T|%M|%N|%C|%m|%P|%q".to_string(), + fmt: ["%i", "%j", "%u", "%T", "%M", "%N", "%C", "%m", "%P", "%q"].join(FIELD_SEP), ordering: vec![("i".to_string(), true)], } } @@ -35,7 +40,7 @@ impl Default for QueryParams { impl QueryParams { pub fn columns(&self) -> Vec<&str> { - self.fmt.split('|').collect() + self.fmt.split(FIELD_SEP).collect() } pub fn is_valid_format(&self) -> bool { @@ -135,7 +140,7 @@ fn decode_output(output: &Output, fmt: &str) -> Result> { return Ok(Vec::new()); } - let col_codes: Vec<&str> = fmt.split('|').collect(); + let col_codes: Vec<&str> = fmt.split(FIELD_SEP).collect(); if col_codes.is_empty() { return Ok(Vec::new()); } @@ -147,7 +152,7 @@ fn decode_output(output: &Output, fmt: &str) -> Result> { continue; } - let fields: Vec<&str> = line.split('|').collect(); + let fields: Vec<&str> = line.split(FIELD_SEP).collect(); if fields.is_empty() || fields.len() < col_codes.len() / 2 { continue; } diff --git a/src/dashboard.rs b/src/dashboard.rs index 00bb03e..9a95455 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -15,7 +15,7 @@ use crate::{ backend::{ Job, JobState, commands::{cancel_jobs, check_slurm_available, list_nodes, list_partitions, list_qos}, - query::{QueryParams, fetch_jobs}, + query::{FIELD_SEP, QueryParams, fetch_jobs}, }, core::{ config::{ @@ -948,7 +948,7 @@ impl Dashboard { codes.push("%Z"); } - self.params.fmt = codes.join("|"); + self.params.fmt = codes.join(FIELD_SEP); self.params.ordering.clear(); if !self.sort_fields.is_empty() { From ea26c0a95f4ecf52f4146a21e041ae48411dba56 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:16:59 +0300 Subject: [PATCH 08/20] [Fixed] Drop the hardcoded QoS fallback list --- src/backend/commands.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/backend/commands.rs b/src/backend/commands.rs index 362f55d..baeb0f1 100644 --- a/src/backend/commands.rs +++ b/src/backend/commands.rs @@ -140,19 +140,15 @@ pub async fn list_qos() -> Vec { ) .await { - Ok(o) => o, - Err(_) => return vec!["normal".into(), "huge".into()], + Ok(o) if o.status.success() => o, + // No accounting DB / QoS on this cluster: show an empty list rather + // than inventing site-specific names that don't exist here. + _ => return Vec::new(), }; - let items: Vec = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stdout) .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) - .collect(); - - if items.is_empty() { - vec!["normal".into(), "huge".into()] - } else { - items - } + .collect() } From 6a9c5a49fe83283925820be8264d67a533168ab6 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:18:33 +0300 Subject: [PATCH 09/20] [Changed] Consolidate the regex filter pipeline and focus cycling --- src/dashboard.rs | 91 +++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/src/dashboard.rs b/src/dashboard.rs index 9a95455..8a0ec15 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -93,6 +93,19 @@ impl Dashboard { saved.apply_to(&mut params); } + // Drop any saved regex that no longer compiles so the runtime filter + // path only ever sees valid patterns (validated once, here). + if let Some(p) = ¶ms.user + && regex::Regex::new(p).is_err() + { + params.user = None; + } + if let Some(p) = ¶ms.name_pattern + && regex::Regex::new(p).is_err() + { + params.name_pattern = None; + } + let known_partitions = rt.block_on(list_partitions()); let known_qos = rt.block_on(list_qos()); let known_nodes = rt.block_on(list_nodes()); @@ -182,21 +195,7 @@ impl Dashboard { let mut stats = Vec::new(); let total = jobs.len(); - if let Some(ref pat) = self.params.user { - match Self::apply_regex_filter(&mut jobs, pat, |j| &j.user) { - Ok(Some(stat)) => stats.push(format!("user: {}", stat)), - Ok(None) => {} - Err(e) => self.flash(format!("Invalid user regex pattern: {}", e), 3), - } - } - - if let Some(ref pat) = self.params.name_pattern { - match Self::apply_regex_filter(&mut jobs, pat, |j| &j.name) { - Ok(Some(stat)) => stats.push(format!("name: {}", stat)), - Ok(None) => {} - Err(e) => self.flash(format!("Invalid name regex pattern: {}", e), 3), - } - } + stats.extend(self.run_regex_filters(&mut jobs)); if !stats.is_empty() { let remaining = jobs.len(); @@ -234,21 +233,7 @@ impl Dashboard { let mut stats = Vec::new(); let total = jobs.len(); - if let Some(ref pat) = self.params.user { - match Self::apply_regex_filter(&mut jobs, pat, |j| &j.user) { - Ok(Some(stat)) => stats.push(format!("user: {}", stat)), - Ok(None) => {} - Err(e) => self.flash(format!("Invalid user regex pattern: {}", e), 3), - } - } - - if let Some(ref pat) = self.params.name_pattern { - match Self::apply_regex_filter(&mut jobs, pat, |j| &j.name) { - Ok(Some(stat)) => stats.push(format!("name: {}", stat)), - Ok(None) => {} - Err(e) => self.flash(format!("Invalid name regex pattern: {}", e), 3), - } - } + stats.extend(self.run_regex_filters(&mut jobs)); if self.pending_filter_apply { self.pending_filter_apply = false; @@ -309,6 +294,28 @@ impl Dashboard { } } + /// Apply the user and job-name regex filters to `jobs`, returning a + /// stat string per filter that removed rows. Shared by the synchronous + /// startup reload and the async fetch path. + fn run_regex_filters(&mut self, jobs: &mut Vec) -> Vec { + let mut stats = Vec::new(); + if let Some(pat) = self.params.user.clone() { + match Self::apply_regex_filter(jobs, &pat, |j| &j.user) { + Ok(Some(stat)) => stats.push(format!("user: {}", stat)), + Ok(None) => {} + Err(e) => self.flash(format!("Invalid user regex pattern: {}", e), 3), + } + } + if let Some(pat) = self.params.name_pattern.clone() { + match Self::apply_regex_filter(jobs, &pat, |j| &j.name) { + Ok(Some(stat)) => stats.push(format!("name: {}", stat)), + Ok(None) => {} + Err(e) => self.flash(format!("Invalid name regex pattern: {}", e), 3), + } + } + stats + } + // ── Drawing ────────────────────────────────────────────── pub fn draw(&mut self, frame: &mut Frame) { @@ -652,11 +659,11 @@ impl Dashboard { return; } (_, KeyCode::Tab) => { - self.cycle_focus(); + self.cycle_focus(true); return; } - (KeyModifiers::SHIFT, KeyCode::BackTab) => { - self.cycle_focus_reverse(); + (_, KeyCode::BackTab) => { + self.cycle_focus(false); return; } (KeyModifiers::CONTROL, KeyCode::Char('w')) => { @@ -771,26 +778,16 @@ impl Dashboard { items } - fn cycle_focus(&mut self) { - let items = self.focusable_widgets(); - if items.len() <= 1 { - return; - } - let current = items.iter().position(|p| *p == self.focus).unwrap_or(0); - let next = (current + 1) % items.len(); - self.focus = items[next].clone(); - } - - fn cycle_focus_reverse(&mut self) { + fn cycle_focus(&mut self, forward: bool) { let items = self.focusable_widgets(); if items.len() <= 1 { return; } let current = items.iter().position(|p| *p == self.focus).unwrap_or(0); - let next = if current == 0 { - items.len() - 1 + let next = if forward { + (current + 1) % items.len() } else { - current - 1 + (current + items.len() - 1) % items.len() }; self.focus = items[next].clone(); } From 4e5bca67327f0a99349442b012f0610086340e09 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:20:05 +0300 Subject: [PATCH 10/20] [Fixed] Honest clipboard feedback, sidebar underflow, and LRU detail cache --- src/core/job_detail.rs | 50 +++++++++++++++++++++++++++++++++------- src/dashboard.rs | 32 ++++++++++++++++--------- src/views/filter_tree.rs | 2 +- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/core/job_detail.rs b/src/core/job_detail.rs index 00d35a3..87a2b79 100644 --- a/src/core/job_detail.rs +++ b/src/core/job_detail.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::thread; use crossbeam::channel::{Receiver, Sender, unbounded}; @@ -9,11 +9,16 @@ use crate::backend::commands::{JobDetail, scontrol_show_job}; /// /// Only one lookup is in-flight at a time. Rapid requests are deduplicated /// by draining the channel and keeping only the latest job ID. +/// Maximum number of job details retained in the LRU cache. +const CACHE_CAP: usize = 64; + pub struct JobDetailResolver { request_tx: Sender, result_rx: Receiver<(String, Option)>, pending: Option, cache: HashMap, + /// Job IDs in least-recently-used order (front = LRU, back = MRU). + order: VecDeque, } impl JobDetailResolver { @@ -37,6 +42,7 @@ impl JobDetailResolver { result_rx: res_rx, pending: None, cache: HashMap::new(), + order: VecDeque::new(), } } @@ -59,16 +65,44 @@ impl JobDetailResolver { self.pending = None; } if let Some(d) = detail { - if self.cache.len() >= 64 { - self.cache.clear(); - } - self.cache.insert(job_id, d); + self.cache_put(job_id, d); } } } - /// Get a cached detail, if available. - pub fn get_cached(&self, job_id: &str) -> Option<&JobDetail> { - self.cache.get(job_id) + /// Get a cached detail, marking it most-recently-used. + pub fn get_cached(&mut self, job_id: &str) -> Option<&JobDetail> { + if self.cache.contains_key(job_id) { + self.touch(job_id); + self.cache.get(job_id) + } else { + None + } + } + + /// Insert or refresh a cache entry, evicting the least-recently-used + /// entry when the cache is full. + fn cache_put(&mut self, job_id: String, detail: JobDetail) { + if self.cache.contains_key(&job_id) { + self.cache.insert(job_id.clone(), detail); + self.touch(&job_id); + return; + } + if self.cache.len() >= CACHE_CAP + && let Some(evicted) = self.order.pop_front() + { + self.cache.remove(&evicted); + } + self.order.push_back(job_id.clone()); + self.cache.insert(job_id, detail); + } + + /// Move `job_id` to the most-recently-used end of the order queue. + fn touch(&mut self, job_id: &str) { + if let Some(pos) = self.order.iter().position(|k| k == job_id) + && let Some(k) = self.order.remove(pos) + { + self.order.push_back(k); + } } } diff --git a/src/dashboard.rs b/src/dashboard.rs index 8a0ec15..5532b8f 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -630,22 +630,19 @@ impl Dashboard { (KeyModifiers::CONTROL, KeyCode::Char('c')) => { match &self.focus { FocusWidget::Script => { - self.copy_to_clipboard(&self.script.body.clone()); - self.flash("Script contents copied".into(), 3); + self.copy_and_flash(&self.script.body.clone(), "Script contents"); } FocusWidget::Stdout => { - self.copy_to_clipboard(&self.stdout_widget.content.clone()); - self.flash("Stdout contents copied".into(), 3); + self.copy_and_flash(&self.stdout_widget.content.clone(), "Stdout contents"); } FocusWidget::Stderr => { - self.copy_to_clipboard(&self.stderr_widget.content.clone()); - self.flash("Stderr contents copied".into(), 3); + self.copy_and_flash(&self.stderr_widget.content.clone(), "Stderr contents"); } FocusWidget::Custom(i) => { if let Some(cw) = self.custom_widgets.get(*i) { let title = cw.title.clone(); - self.copy_to_clipboard(&cw.content.clone()); - self.flash(format!("{} contents copied", title), 3); + let content = cw.content.clone(); + self.copy_and_flash(&content, &format!("{} contents", title)); } } FocusWidget::Sidebar | FocusWidget::Table => { @@ -879,11 +876,24 @@ impl Dashboard { /// Copy text to the system clipboard via the OSC 52 escape sequence. /// Works over SSH and inside tmux without requiring X11/Wayland. - fn copy_to_clipboard(&self, text: &str) { + /// Returns whether the escape sequence was actually written. + fn copy_to_clipboard(&self, text: &str) -> bool { + use std::io::Write; let encoded = BASE64.encode(text); let seq = format!("\x1b]52;c;{}\x07", encoded); - let _ = std::io::Write::write_all(&mut std::io::stdout(), seq.as_bytes()); - let _ = std::io::Write::flush(&mut std::io::stdout()); + let mut out = std::io::stdout(); + out.write_all(seq.as_bytes()) + .and_then(|_| out.flush()) + .is_ok() + } + + /// Copy `text` and flash success or failure honestly. + fn copy_and_flash(&mut self, text: &str, label: &str) { + if self.copy_to_clipboard(text) { + self.flash(format!("{} copied", label), 3); + } else { + self.flash("Clipboard copy failed".into(), 3); + } } fn flash(&mut self, msg: String, secs: u64) { diff --git a/src/views/filter_tree.rs b/src/views/filter_tree.rs index 544e707..2a1c090 100644 --- a/src/views/filter_tree.rs +++ b/src/views/filter_tree.rs @@ -472,7 +472,7 @@ impl FilterTree { UNCHECKED_COLOR }; - let item_label = truncate(label, list_area.width as usize - 5); + let item_label = truncate(label, (list_area.width as usize).saturating_sub(5)); let text = format!(" {} {}", mark, item_label); let mut style = Style::default().fg(color); From 0a01a5dbbb9bcfa66ea7935f750d7ad17ddde2db Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:21:04 +0300 Subject: [PATCH 11/20] [Added] Configurable auto-refresh interval --- src/core/config.rs | 33 +++++++++++++++++++++++++++++++++ src/dashboard.rs | 30 ++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index da04c20..481d797 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -210,3 +210,36 @@ pub fn save_layout(widgets: &VisibleWidgets) -> Result<(), String> { serde_json::to_string_pretty(&saved).map_err(|e| format!("Failed to serialize: {}", e))?; fs::write(&path, json).map_err(|e| format!("Failed to write {}: {}", path.display(), e)) } + +// --- General settings persistence --- + +#[derive(Debug, Serialize, Deserialize)] +pub struct SavedSettings { + pub refresh_secs: u64, +} + +impl Default for SavedSettings { + fn default() -> Self { + Self { refresh_secs: 3 } + } +} + +fn settings_path() -> PathBuf { + sqwatch_config_dir().join("settings.json") +} + +pub fn load_settings() -> Option { + let path = settings_path(); + let data = fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() +} + +pub fn save_settings(settings: &SavedSettings) -> Result<(), String> { + let path = settings_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {}", e))?; + } + let json = serde_json::to_string_pretty(settings) + .map_err(|e| format!("Failed to serialize: {}", e))?; + fs::write(&path, json).map_err(|e| format!("Failed to write {}: {}", path.display(), e)) +} diff --git a/src/dashboard.rs b/src/dashboard.rs index 5532b8f..3029c0b 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -19,7 +19,8 @@ use crate::{ }, core::{ config::{ - load_columns, load_filters, load_layout, save_columns, save_filters, save_layout, + SavedSettings, load_columns, load_filters, load_layout, load_settings, save_columns, + save_filters, save_layout, save_settings, }, input::{InputConfig, InputLoop, Signal}, job_detail::JobDetailResolver, @@ -121,6 +122,10 @@ impl Dashboard { ) }); + let refresh_secs = load_settings() + .map(|s| s.refresh_secs.clamp(1, 60)) + .unwrap_or(3); + let visible_widgets = load_layout().unwrap_or_default(); let custom_widgets = visible_widgets .custom @@ -147,7 +152,7 @@ impl Dashboard { focus: FocusWidget::Table, notice: String::new(), notice_expires: None, - refresh_secs: 1, + refresh_secs, known_partitions, known_qos, known_nodes, @@ -667,6 +672,14 @@ impl Dashboard { self.widget_sel.visible = true; return; } + (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => { + self.adjust_refresh(1); + return; + } + (_, KeyCode::Char('-')) | (_, KeyCode::Char('_')) => { + self.adjust_refresh(-1); + return; + } _ => {} } @@ -896,6 +909,19 @@ impl Dashboard { } } + /// Adjust the auto-refresh interval (clamped to 1–60s) and persist it. + fn adjust_refresh(&mut self, delta: i64) { + let new = (self.refresh_secs as i64 + delta).clamp(1, 60) as u64; + if new == self.refresh_secs { + return; + } + self.refresh_secs = new; + match save_settings(&SavedSettings { refresh_secs: new }) { + Ok(_) => self.flash(format!("Refresh interval: {}s", new), 3), + Err(e) => self.flash(format!("Refresh interval: {}s (save failed: {})", new, e), 3), + } + } + fn flash(&mut self, msg: String, secs: u64) { self.notice = msg; self.notice_expires = Some(Instant::now() + Duration::from_secs(secs)); From 2a9f4cc43fb84d6833e2d65bd94abe3f9f39ad3e Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:23:03 +0300 Subject: [PATCH 12/20] [Added] In-app help overlay --- src/dashboard.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++- src/views/chrome.rs | 1 + 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/dashboard.rs b/src/dashboard.rs index 3029c0b..96958a3 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -4,8 +4,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent use ratatui::{ Frame, layout::Rect, - style::{Color, Style}, - text::Line, + style::{Color, Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, }; use std::time::{Duration, Instant}; @@ -75,6 +75,7 @@ pub struct Dashboard { pub sort_fields: Vec, pub login_user: String, confirming_cancel: bool, + show_help: bool, job_detail_resolver: JobDetailResolver, job_fetcher: JobFetcher, pending_filter_apply: bool, @@ -161,6 +162,7 @@ impl Dashboard { sort_fields, login_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()), confirming_cancel: false, + show_help: false, job_detail_resolver: JobDetailResolver::new(), job_fetcher: JobFetcher::new(), pending_filter_apply: false, @@ -382,6 +384,11 @@ impl Dashboard { let r = popup_rect(frame.area(), 45, 25); self.draw_cancel_confirm(frame, r); } + + if self.show_help { + let r = popup_rect(frame.area(), 60, 85); + self.draw_help(frame, r); + } } fn render_widget_by_kind(&mut self, frame: &mut Frame, kind: &WidgetKind, area: Rect) { @@ -514,6 +521,20 @@ impl Dashboard { frame.render_widget(widget, area); } + fn draw_help(&self, frame: &mut Frame, area: Rect) { + frame.render_widget(Clear, area); + + let block = Block::default() + .title(Line::from(" \u{25c6} Keybindings \u{25c6} ").centered()) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Color::Rgb(200, 120, 255))) + .style(Style::default().bg(Color::Rgb(15, 15, 30))); + + let widget = Paragraph::new(help_lines()).block(block); + frame.render_widget(widget, area); + } + // ── Input handling ─────────────────────────────────────── fn process_input(&mut self) -> Result<()> { @@ -556,6 +577,12 @@ impl Dashboard { } fn on_keypress(&mut self, key: KeyEvent) { + // ── Help overlay (modal, any key dismisses) ── + if self.show_help { + self.show_help = false; + return; + } + // ── Popup-level dispatch (highest priority) ── if self.confirming_cancel { match key.code { @@ -680,6 +707,10 @@ impl Dashboard { self.adjust_refresh(-1); return; } + (_, KeyCode::Char('?')) => { + self.show_help = true; + return; + } _ => {} } @@ -1021,3 +1052,58 @@ impl Dashboard { } } } + +/// Build the keybinding reference shown in the help overlay. +fn help_lines() -> Vec> { + let header = |t: &'static str| { + Line::from(Span::styled( + t, + Style::default() + .fg(Color::Rgb(200, 170, 240)) + .add_modifier(Modifier::BOLD), + )) + }; + let row = |k: &'static str, d: &'static str| { + Line::from(vec![ + Span::styled( + format!(" {:<18}", k), + Style::default().fg(Color::Rgb(120, 200, 255)), + ), + Span::styled(d, Style::default().fg(Color::Rgb(200, 200, 210))), + ]) + }; + + vec![ + Line::raw(""), + header(" Global"), + row("Tab / Shift+Tab", "Cycle focus between panels"), + row("Ctrl+W", "Widget layout"), + row("+ / -", "Refresh interval"), + row("?", "Toggle this help"), + row("Esc", "Back to table, or quit"), + Line::raw(""), + header(" Job table"), + row("Up / Down", "Navigate jobs"), + row("Space", "Mark / unmark job"), + row("Ctrl+A", "Select / deselect all"), + row("Ctrl+X", "Cancel selected jobs"), + row("Ctrl+C", "Column configuration"), + Line::raw(""), + header(" Log / script panels"), + row("Up/Dn PgUp/PgDn", "Scroll"), + row("Ctrl+U / Ctrl+D", "Page up / down"), + row("f / End / Home", "Follow / jump to bottom / top"), + row("Shift+Up/Down", "Switch to prev / next job"), + row("Ctrl+C", "Copy panel contents"), + Line::raw(""), + header(" Filter sidebar"), + row("Up / Down", "Navigate"), + row("Enter", "Edit field / toggle item"), + row("Ctrl+S", "Save filters"), + Line::raw(""), + Line::from(Span::styled( + " Press any key to close", + Style::default().fg(Color::DarkGray), + )), + ] +} diff --git a/src/views/chrome.rs b/src/views/chrome.rs index c7eb0e8..25b4e01 100644 --- a/src/views/chrome.rs +++ b/src/views/chrome.rs @@ -233,6 +233,7 @@ pub fn render_statusbar( ("Tab", "Focus"), ("\u{2191}\u{2193}", "Navigation"), ("Ctrl+W", "Widgets"), + ("?", "Help"), ]; // Context-specific bindings per focused widget From f9daf7b97ef417a291465d2e4ded32ce7ce7278d Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:24:57 +0300 Subject: [PATCH 13/20] [Changed] Add a library target so the crate can be tested --- src/backend/commands.rs | 4 ---- src/core/config.rs | 2 +- src/core/job_detail.rs | 6 ++++++ src/core/job_fetcher.rs | 6 ++++++ src/lib.rs | 9 +++++++++ src/main.rs | 7 +------ src/views/filter_tree.rs | 6 ++++++ src/views/job_table.rs | 6 ++++++ src/views/script_widget.rs | 6 ++++++ src/views/widget_selector.rs | 6 ++++++ 10 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/lib.rs diff --git a/src/backend/commands.rs b/src/backend/commands.rs index baeb0f1..8684fbe 100644 --- a/src/backend/commands.rs +++ b/src/backend/commands.rs @@ -26,10 +26,6 @@ pub async fn run_cmd(program: &str, args: Vec) -> Result { /// Parsed result of `scontrol show job -o`. #[derive(Clone)] -#[expect( - dead_code, - reason = "work_dir is parsed for completeness and cached by JobDetailResolver" -)] pub struct JobDetail { pub stdout_file: Option, pub stderr_file: Option, diff --git a/src/core/config.rs b/src/core/config.rs index 481d797..0228ca8 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -82,7 +82,7 @@ pub fn save_filters(params: &QueryParams) -> Result<(), String> { // --- Column settings persistence --- #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct SavedSort { +pub struct SavedSort { field: String, direction: String, } diff --git a/src/core/job_detail.rs b/src/core/job_detail.rs index 87a2b79..7ec6bdd 100644 --- a/src/core/job_detail.rs +++ b/src/core/job_detail.rs @@ -21,6 +21,12 @@ pub struct JobDetailResolver { order: VecDeque, } +impl Default for JobDetailResolver { + fn default() -> Self { + Self::new() + } +} + impl JobDetailResolver { pub fn new() -> Self { let (req_tx, req_rx) = unbounded::(); diff --git a/src/core/job_fetcher.rs b/src/core/job_fetcher.rs index 0775d92..632ce7b 100644 --- a/src/core/job_fetcher.rs +++ b/src/core/job_fetcher.rs @@ -15,6 +15,12 @@ pub struct JobFetcher { pub in_flight: bool, } +impl Default for JobFetcher { + fn default() -> Self { + Self::new() + } +} + impl JobFetcher { pub fn new() -> Self { let (req_tx, req_rx) = unbounded::(); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6a700ba --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +//! sqwatch — a terminal UI for watching and managing SLURM job queues. +//! +//! The binary in `main.rs` is a thin wrapper around [`dashboard::Dashboard`]; +//! the modules are re-exported here so the logic can be exercised by tests. + +pub mod backend; +pub mod core; +pub mod dashboard; +pub mod views; diff --git a/src/main.rs b/src/main.rs index df55eb9..35af532 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,12 +8,7 @@ use crossterm::{ use ratatui::{Terminal, backend::CrosstermBackend}; use std::io; -mod backend; -mod core; -mod dashboard; -mod views; - -use dashboard::Dashboard; +use sqwatch::dashboard::Dashboard; /// Restore the terminal to its normal state. Safe to call more than once. fn restore_terminal() { diff --git a/src/views/filter_tree.rs b/src/views/filter_tree.rs index 2a1c090..96e286a 100644 --- a/src/views/filter_tree.rs +++ b/src/views/filter_tree.rs @@ -63,6 +63,12 @@ pub struct FilterTree { name_ok: Option, } +impl Default for FilterTree { + fn default() -> Self { + Self::new() + } +} + impl FilterTree { pub fn new() -> Self { Self { diff --git a/src/views/job_table.rs b/src/views/job_table.rs index 09317b0..8833505 100644 --- a/src/views/job_table.rs +++ b/src/views/job_table.rs @@ -17,6 +17,12 @@ pub struct JobTable { pub sort_asc: bool, } +impl Default for JobTable { + fn default() -> Self { + Self::new() + } +} + impl JobTable { pub fn new() -> Self { Self { diff --git a/src/views/script_widget.rs b/src/views/script_widget.rs index f9c98e8..3689236 100644 --- a/src/views/script_widget.rs +++ b/src/views/script_widget.rs @@ -28,6 +28,12 @@ pub struct ScriptWidget { loading: bool, } +impl Default for ScriptWidget { + fn default() -> Self { + Self::new() + } +} + impl ScriptWidget { pub fn new() -> Self { Self { diff --git a/src/views/widget_selector.rs b/src/views/widget_selector.rs index de9c412..c90f23c 100644 --- a/src/views/widget_selector.rs +++ b/src/views/widget_selector.rs @@ -171,6 +171,12 @@ pub struct WidgetSelector { add_filename_buf: String, } +impl Default for WidgetSelector { + fn default() -> Self { + Self::new() + } +} + impl WidgetSelector { pub fn new() -> Self { Self { From 1132a66f905b53a0ee2cfaf37a3840bef691a4f1 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:26:44 +0300 Subject: [PATCH 14/20] [Added] Unit tests for query, state, and filter logic --- src/backend/mod.rs | 30 ++++++++++++++++++++++++++ src/backend/query.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++ src/dashboard.rs | 47 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d8a05f2..794adf0 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -125,3 +125,33 @@ impl Default for Job { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_long_and_short_state_codes() { + assert_eq!(JobState::from_str("RUNNING").unwrap(), JobState::Running); + assert_eq!(JobState::from_str("R").unwrap(), JobState::Running); + assert_eq!(JobState::from_str("pd").unwrap(), JobState::Pending); + assert_eq!( + JobState::from_str("OUT_OF_MEMORY").unwrap(), + JobState::OutOfMemory + ); + assert_eq!(JobState::from_str("OOM").unwrap(), JobState::OutOfMemory); + } + + #[test] + fn unknown_state_falls_back_to_unknown() { + assert_eq!(JobState::from_str("NONSENSE").unwrap(), JobState::Unknown); + } + + #[test] + fn display_round_trips_through_from_str() { + for st in JobState::all_known() { + let shown = st.to_string(); + assert_eq!(JobState::from_str(&shown).unwrap(), st, "state {:?}", st); + } + } +} diff --git a/src/backend/query.rs b/src/backend/query.rs index 61b3372..477308e 100644 --- a/src/backend/query.rs +++ b/src/backend/query.rs @@ -197,3 +197,53 @@ fn decode_output(output: &Output, fmt: &str) -> Result> { Ok(jobs) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::JobState; + + #[test] + fn build_args_defaults_to_all_states() { + let args = QueryParams::default().build_args(); + assert!(args.contains(&"--all".to_string())); + assert!(args.contains(&"--noheader".to_string())); + let i = args.iter().position(|a| a == "--states").unwrap(); + assert_eq!(args[i + 1], "all"); + } + + #[test] + fn build_args_joins_states_and_partitions() { + let p = QueryParams { + statuses: vec![JobState::Pending, JobState::Running], + partitions: vec!["gpu".into(), "cpu".into()], + ..QueryParams::default() + }; + let args = p.build_args(); + let si = args.iter().position(|a| a == "--states").unwrap(); + assert_eq!(args[si + 1], "PENDING,RUNNING"); + let pi = args.iter().position(|a| a == "--partition").unwrap(); + assert_eq!(args[pi + 1], "gpu,cpu"); + } + + #[test] + fn build_args_prefixes_descending_sort_with_dash() { + let p = QueryParams { + ordering: vec![("P".into(), false), ("i".into(), true)], + ..QueryParams::default() + }; + let args = p.build_args(); + let si = args.iter().position(|a| a == "--sort").unwrap(); + assert_eq!(args[si + 1], "-P,i"); + } + + #[test] + fn is_valid_format_requires_percent_prefixed_columns() { + let mut p = QueryParams::default(); + assert!(p.is_valid_format()); + p.fmt = ["%i", "bad"].join(FIELD_SEP); + assert!(!p.is_valid_format()); + p.fmt = String::new(); + assert!(!p.is_valid_format()); + } +} diff --git a/src/dashboard.rs b/src/dashboard.rs index 96958a3..63d4d8a 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -1107,3 +1107,50 @@ fn help_lines() -> Vec> { )), ] } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::Job; + + fn job_with_user(user: &str) -> Job { + Job { + user: user.to_string(), + ..Job::default() + } + } + + #[test] + fn regex_filter_retains_matching_rows() { + let mut jobs = vec![ + job_with_user("alice"), + job_with_user("bob"), + job_with_user("alba"), + ]; + let stat = Dashboard::apply_regex_filter(&mut jobs, "^al", |j| &j.user).unwrap(); + assert_eq!(jobs.len(), 2); + assert!(stat.is_some()); + } + + #[test] + fn regex_filter_reports_none_when_nothing_removed() { + let mut jobs = vec![job_with_user("alice"), job_with_user("alba")]; + let stat = Dashboard::apply_regex_filter(&mut jobs, "^al", |j| &j.user).unwrap(); + assert_eq!(jobs.len(), 2); + assert!(stat.is_none()); + } + + #[test] + fn regex_filter_empty_pattern_is_a_noop() { + let mut jobs = vec![job_with_user("alice")]; + let stat = Dashboard::apply_regex_filter(&mut jobs, "", |j| &j.user).unwrap(); + assert!(stat.is_none()); + assert_eq!(jobs.len(), 1); + } + + #[test] + fn regex_filter_errors_on_invalid_pattern() { + let mut jobs = vec![job_with_user("alice")]; + assert!(Dashboard::apply_regex_filter(&mut jobs, "[", |j| &j.user).is_err()); + } +} From a36147770b7bd084e14185978acaf8ee7b696abb Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:28:58 +0300 Subject: [PATCH 15/20] [Added] Fixture-based parsing tests with per-field round-trip guard --- src/backend/mod.rs | 2 +- src/backend/query.rs | 11 +++-- tests/fixtures/squeue_default.txt | 3 ++ tests/parsing.rs | 79 +++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/squeue_default.txt create mode 100644 tests/parsing.rs diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 794adf0..9afca24 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -79,7 +79,7 @@ impl FromStr for JobState { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Job { pub job_id: String, pub name: String, diff --git a/src/backend/query.rs b/src/backend/query.rs index 477308e..6267d92 100644 --- a/src/backend/query.rs +++ b/src/backend/query.rs @@ -135,14 +135,19 @@ pub async fn fetch_jobs(params: &QueryParams) -> Result> { fn decode_output(output: &Output, fmt: &str) -> Result> { let raw = String::from_utf8_lossy(&output.stdout); + Ok(decode_squeue_output(&raw, fmt)) +} +/// Parse `FIELD_SEP`-delimited `squeue` output into jobs. Split out from +/// [`fetch_jobs`] so parsing can be tested without spawning `squeue`. +pub fn decode_squeue_output(raw: &str, fmt: &str) -> Vec { if raw.trim().is_empty() { - return Ok(Vec::new()); + return Vec::new(); } let col_codes: Vec<&str> = fmt.split(FIELD_SEP).collect(); if col_codes.is_empty() { - return Ok(Vec::new()); + return Vec::new(); } let mut jobs = Vec::new(); @@ -195,7 +200,7 @@ fn decode_output(output: &Output, fmt: &str) -> Result> { jobs.push(job); } - Ok(jobs) + jobs } #[cfg(test)] diff --git a/tests/fixtures/squeue_default.txt b/tests/fixtures/squeue_default.txt new file mode 100644 index 0000000..29974b0 --- /dev/null +++ b/tests/fixtures/squeue_default.txt @@ -0,0 +1,3 @@ +1001|train_model|alice|RUNNING|1:23:45|node01|8|16G|gpu|normal +1002|preprocess|bob|PENDING|0:00|N/A|4|8G|cpu|normal +1003|eval|carol|COMPLETED|10:00|node[02-03]|16|32G|gpu|high diff --git a/tests/parsing.rs b/tests/parsing.rs new file mode 100644 index 0000000..705001f --- /dev/null +++ b/tests/parsing.rs @@ -0,0 +1,79 @@ +//! Fixture-driven tests for `squeue` output parsing. +//! +//! The fixtures use `|` as a visible separator; the tests swap it for the +//! real `FIELD_SEP` control character before parsing. + +use sqwatch::backend::Job; +use sqwatch::backend::JobState; +use sqwatch::backend::query::{FIELD_SEP, decode_squeue_output}; +use sqwatch::views::fields::JobField; + +fn default_fmt() -> String { + ["%i", "%j", "%u", "%T", "%M", "%N", "%C", "%m", "%P", "%q"].join(FIELD_SEP) +} + +fn line(cells: &[&str]) -> String { + cells.join(FIELD_SEP) +} + +#[test] +fn decodes_fixture_rows() { + let raw = include_str!("fixtures/squeue_default.txt").replace('|', FIELD_SEP); + let jobs = decode_squeue_output(&raw, &default_fmt()); + + assert_eq!(jobs.len(), 3); + assert_eq!(jobs[0].job_id, "1001"); + assert_eq!(jobs[0].name, "train_model"); + assert_eq!(jobs[0].state, JobState::Running); + assert_eq!(jobs[0].num_cpus, 8); + assert_eq!(jobs[1].state, JobState::Pending); + assert_eq!(jobs[1].nodelist, None); // "N/A" is treated as unset + assert_eq!(jobs[2].state, JobState::Completed); + assert_eq!(jobs[2].nodelist.as_deref(), Some("node[02-03]")); + assert_eq!(jobs[2].num_cpus, 16); +} + +#[test] +fn empty_and_na_cells_are_unset() { + let raw = line(&["7", "N/A", ""]); + let jobs = decode_squeue_output(&raw, &["%i", "%N", "%a"].join(FIELD_SEP)); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].job_id, "7"); + assert!(jobs[0].nodelist.is_none()); + assert!(jobs[0].account.is_none()); +} + +#[test] +fn job_name_containing_pipe_is_not_split() { + let raw = line(&["9", "step|one|two", "alice"]); + let jobs = decode_squeue_output(&raw, &["%i", "%j", "%u"].join(FIELD_SEP)); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].name, "step|one|two"); + assert_eq!(jobs[0].user, "alice"); +} + +#[test] +fn blank_lines_are_skipped() { + let jobs = decode_squeue_output("\n \n1\n\n2\n", "%i"); + assert_eq!(jobs.len(), 2); +} + +/// Every column the UI can request must decode into a field; otherwise the +/// column silently renders empty (the bug the `%R` column once had). +#[test] +fn every_field_code_is_decoded() { + for field in JobField::enumerate() { + let code = field.format_code(); + // A value that is non-default for every field's type. + let value = if code == "%T" { "RUNNING" } else { "1" }; + let jobs = decode_squeue_output(value, code); + assert_eq!(jobs.len(), 1, "no row decoded for {}", code); + assert_ne!( + jobs[0], + Job::default(), + "format code {} ({:?}) is requested but never decoded", + code, + field + ); + } +} From 73a9c859b603ae7dd984fb56ba11aa17ea3b10cc Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 3 Aug 2026 17:32:19 +0300 Subject: [PATCH 16/20] [Changed] Harden the CI and release pipeline and fix the MSRV changelog --- .github/workflows/ci.yml | 17 +++++++---------- .github/workflows/release.yml | 14 ++++++++++++-- CHANGELOG.md | 24 +++++++++++++++++++++++- src/dashboard.rs | 5 ++++- src/main.rs | 7 ++++++- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c1f61..d59d51f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@nightly + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - run: cargo fmt --all --check @@ -48,7 +48,7 @@ jobs: with: toolchain: "1.90.0" - uses: Swatinem/rust-cache@v2 - - run: cargo check + - run: cargo check --all-targets deny: name: Cargo Deny @@ -63,15 +63,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Check CHANGELOG.md was updated + - name: Check CHANGELOG has an Unreleased entry run: | - BASE_SHA=${{ github.event.pull_request.base.sha }} - HEAD_SHA=${{ github.event.pull_request.head.sha }} - if git diff --name-only "$BASE_SHA"..."$HEAD_SHA" | grep -q '^CHANGELOG.md$'; then - echo "CHANGELOG.md was updated." + section=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md) + if echo "$section" | grep -q '^- '; then + echo "Found an Unreleased changelog entry." else - echo "::error::CHANGELOG.md was not updated. Every PR must include a changelog entry." + echo "::error::Add a bullet under '## [Unreleased]' in CHANGELOG.md." exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57c046c..84ff27b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,13 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - name: Ensure release runs from main + run: | + if [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "::error::Releases must be dispatched from the main branch." + exit 1 + fi + - name: Validate version format run: | if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then @@ -50,10 +57,13 @@ jobs: fi - name: Run tests - run: cargo test + run: cargo test --all-targets --locked + + - name: Package dry run + run: cargo publish --locked --dry-run - name: Publish to crates.io - run: cargo publish + run: cargo publish --locked env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d4fcd1..f4edb4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Follow/tail mode for the stdout, stderr, and custom file widgets: the view snaps to the bottom as new content arrives, with `f`, `End`, and `Home` to control following and a `[follow]` indicator in the panel title. +- Configurable auto-refresh interval, adjustable at runtime with `+`/`-` (1–60s, default 3s) and persisted to `settings.json`. +- In-app help overlay listing all global and per-focus keybindings, opened with `?`. +- A library target and an initial test suite covering `squeue` argument building, job-state parsing, regex filtering, and fixture-based output decoding, including a round-trip guard that every displayable column is actually decoded. + +### Changed + +- The `squeue --format` field separator changed from `|` to an ASCII control character so job names containing `|` no longer corrupt column parsing. +- The regex filter pipeline and focus cycling were consolidated, saved filter patterns are validated once on load, and `Shift+Tab` is now accepted regardless of the reported modifier. + +### Fixed + +- The terminal is restored on panic via an RAII guard and a chained panic hook, so a crash no longer leaves the shell stuck in raw mode on the alternate screen. +- The live file watcher no longer panics on inotify limits or channel errors; failures are reported through the widget instead of taking down the app. +- Background worker failures (the input thread and the job fetcher) are surfaced instead of silently freezing the UI. +- `squeue` failures — a non-zero exit, an unreachable controller, or a bad sort key — are shown in the flash bar instead of being rendered as a normal empty table. +- The `Reason` (`%R`) column is now decoded and populated. +- The hardcoded `normal`/`huge` QoS fallback was removed so the QoS filter reflects the actual cluster. +- Clipboard copies report real success or failure, a debug-build width underflow in the filter sidebar was fixed, and the job-detail cache now evicts least-recently-used entries instead of clearing wholesale. + ## [0.1.1] - 2026-03-26 ### Added - Three operations that previously blocked the main thread and froze the UI — `scontrol show job` lookups, periodic `squeue` refreshes, and script file loading with optional `bat` highlighting — were moved into dedicated background threads. A new `JobDetailResolver` runs a single `scontrol` call per job and caches up to 64 results, replacing the duplicate per-widget calls that each blocked for 100–500 ms; widgets now show a "Loading…" placeholder until the detail arrives, and the resolver deduplicates rapid requests by draining the channel and keeping only the latest job ID. A new `JobFetcher` runs `squeue` in its own lightweight tokio runtime so the 1-second auto-refresh and filter-apply no longer stall rendering; the old synchronous `reload_jobs` was split into `reload_jobs_sync` (used once at startup) and a non-blocking `submit_reload` path whose results are picked up on the next timer tick. The script widget's `load_content` was similarly offloaded to a background thread so that file reads and `bat` invocations never touch the render path, with a new `poll_updates` method that mirrors the pattern already used by the output and custom widgets. The input processing loop now drains all pending signals on each iteration and collapses consecutive `Timer` events into a single tick, which eliminates the multi-second freeze that occurred when switching back to the terminal after the window had been unfocused and hundreds of stale timers had piled up in the channel. `Ctrl+C` while any content widget (script, stdout, stderr, or custom) is focused now copies the widget's content to the system clipboard via the OSC 52 escape sequence and flashes a confirmation in the titlebar; the binding works over SSH and inside tmux without requiring X11 or Wayland, and `Esc` remains the key for returning focus to the table. The script widget gained `PageUp`/`PageDown` and `Ctrl+U`/`Ctrl+D` scrolling to match the other widgets, and all four content widget types now show `PgUp/Dn Scroll` and `Ctrl+C Copy` hints in the statusbar. [PR #5](https://github.com/fedonman/sqwatch/pull/5) -- Added CI/CD infrastructure so that every pull request and push to main is automatically checked for formatting, linting, test correctness, minimum supported Rust version compatibility (1.85.0), and dependency license and vulnerability audits via `cargo-deny`. Pull requests now require a changelog entry before merging. A separate manually-triggered release workflow handles version validation, publishing to crates.io, and creating GitHub Releases with the relevant changelog section as release notes. [PR #1](https://github.com/fedonman/sqwatch/pull/1) +- Added CI/CD infrastructure so that every pull request and push to main is automatically checked for formatting, linting, test correctness, minimum supported Rust version compatibility (1.90), and dependency license and vulnerability audits via `cargo-deny`. Pull requests now require a changelog entry before merging. A separate manually-triggered release workflow handles version validation, publishing to crates.io, and creating GitHub Releases with the relevant changelog section as release notes. [PR #1](https://github.com/fedonman/sqwatch/pull/1) ### Changed diff --git a/src/dashboard.rs b/src/dashboard.rs index 63d4d8a..1a0c275 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -949,7 +949,10 @@ impl Dashboard { self.refresh_secs = new; match save_settings(&SavedSettings { refresh_secs: new }) { Ok(_) => self.flash(format!("Refresh interval: {}s", new), 3), - Err(e) => self.flash(format!("Refresh interval: {}s (save failed: {})", new, e), 3), + Err(e) => self.flash( + format!("Refresh interval: {}s (save failed: {})", new, e), + 3, + ), } } diff --git a/src/main.rs b/src/main.rs index 35af532..673ecbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,12 @@ use sqwatch::dashboard::Dashboard; /// Restore the terminal to its normal state. Safe to call more than once. fn restore_terminal() { let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture, Show); + let _ = execute!( + io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture, + Show + ); } /// Restores the terminal when dropped, so a normal return, an error, or a From 92c5c26209550c9b4b617a413dc7ca2880b6b4c2 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 28 Aug 2026 21:12:19 +0300 Subject: [PATCH 17/20] [Changed] Cut the 0.2.0 release Bump the version to 0.2.0 and promote the [Unreleased] changelog section to [0.2.0] - 2026-08-28. --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4edb4a..75c7d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-08-28 + ### Added - Follow/tail mode for the stdout, stderr, and custom file widgets: the view snaps to the bottom as new content arrives, with `f`, `End`, and `Home` to control following and a `[follow]` indicator in the panel title. diff --git a/Cargo.lock b/Cargo.lock index 235aa32..0146872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1641,7 +1641,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "sqwatch" -version = "0.1.1" +version = "0.2.0" dependencies = [ "async-process", "base64", diff --git a/Cargo.toml b/Cargo.toml index 5768fbb..9b8decc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqwatch" -version = "0.1.1" +version = "0.2.0" edition = "2024" description = "A terminal UI for watching and managing SLURM job queues." authors = ["Vyron Vasileiadis "] From 7ca8c629942bbc9189fcaf1f0da32ce66dc9fb7d Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 28 Aug 2026 21:25:18 +0300 Subject: [PATCH 18/20] [Changed] Upgrade dependencies to latest versions Refresh the lockfile to the latest compatible versions and bump base64 from 0.22 to 0.23 (Engine/STANDARD API unchanged). notify stays on 8.x since 9.0 is only a pre-release. --- CHANGELOG.md | 1 + Cargo.lock | 10 ++++++++-- Cargo.toml | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c7d3e..b2692a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `squeue --format` field separator changed from `|` to an ASCII control character so job names containing `|` no longer corrupt column parsing. - The regex filter pipeline and focus cycling were consolidated, saved filter patterns are validated once on load, and `Shift+Tab` is now accepted regardless of the reported modifier. +- Dependencies were refreshed to their latest compatible versions (including `ratatui` 0.30.2, `tokio` 1.53, and `regex` 1.13), and `base64` was upgraded to 0.23. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 0146872..be38a67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.5.3" @@ -1644,7 +1650,7 @@ name = "sqwatch" version = "0.2.0" dependencies = [ "async-process", - "base64", + "base64 0.23.1", "color-eyre", "crossbeam", "crossterm", @@ -1740,7 +1746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.11.0", "fancy-regex", "filedescriptor", diff --git a/Cargo.toml b/Cargo.toml index 9b8decc..671fd2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,4 @@ regex = "1.12.3" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_norway = "0.9" -base64 = "0.22" +base64 = "0.23" From 024090f72cf367da597621cccef3c98171e4b4e8 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 28 Aug 2026 21:35:48 +0300 Subject: [PATCH 19/20] [Fixed] Bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 The 0.9.18 in the lockfile is flagged by cargo-deny (invalid pointer dereference in the fmt::Pointer impl). 0.9.20 is the patched release. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be38a67..97a6261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,9 +353,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From 3be57ae6aef7542d4ef1143ffe4235d824289f6f Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Fri, 28 Aug 2026 21:35:48 +0300 Subject: [PATCH 20/20] [Fixed] Let the changelog CI gate pass on release PRs Cutting a release promotes [Unreleased] into a dated version section, which correctly leaves [Unreleased] empty. The gate now also accepts a PR that adds a new '## [x.y.z]' section instead of demanding an [Unreleased] bullet. --- .github/workflows/ci.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d59d51f..3f2bc2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,12 +63,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: Check CHANGELOG has an Unreleased entry + with: + fetch-depth: 0 + - name: Check CHANGELOG documents this PR's changes run: | section=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md) if echo "$section" | grep -q '^- '; then echo "Found an Unreleased changelog entry." - else - echo "::error::Add a bullet under '## [Unreleased]' in CHANGELOG.md." - exit 1 + exit 0 + fi + # A release PR promotes [Unreleased] into a dated version section, which + # legitimately leaves [Unreleased] empty. Accept it if this PR adds one. + git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + if git diff FETCH_HEAD -- CHANGELOG.md | grep -qE '^\+## \[[0-9]'; then + echo "Release PR: a new version section was added; empty [Unreleased] is expected." + exit 0 fi + echo "::error::Add a bullet under '## [Unreleased]' in CHANGELOG.md." + exit 1