diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c1f61..3f2bc2a 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 @@ -65,13 +65,19 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Check CHANGELOG.md was updated + - name: Check CHANGELOG documents this PR's changes 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." - else - echo "::error::CHANGELOG.md was not updated. Every PR must include a changelog entry." - exit 1 + section=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md) + if echo "$section" | grep -q '^- '; then + echo "Found an Unreleased changelog entry." + 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 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..b2692a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,38 @@ 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. +- 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. +- 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 + +- 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/Cargo.lock b/Cargo.lock index 235aa32..97a6261 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" @@ -347,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", ] @@ -1641,10 +1647,10 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "sqwatch" -version = "0.1.1" +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 5768fbb..671fd2d 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 "] @@ -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" diff --git a/src/backend/commands.rs b/src/backend/commands.rs index 362f55d..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, @@ -140,19 +136,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() } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d8a05f2..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, @@ -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 5fbf4aa..6267d92 100644 --- a/src/backend/query.rs +++ b/src/backend/query.rs @@ -1,11 +1,15 @@ use async_process::{Command, Output}; use color_eyre::Result; -use color_eyre::eyre::Error; 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, @@ -28,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)], } } @@ -36,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 { @@ -106,30 +110,44 @@ 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) } 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('|').collect(); + 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(); @@ -139,7 +157,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; } @@ -174,6 +192,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), _ => {} } } @@ -181,5 +200,55 @@ fn decode_output(output: &Output, fmt: &str) -> Result> { jobs.push(job); } - Ok(jobs) + 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/core/config.rs b/src/core/config.rs index da04c20..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, } @@ -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/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_detail.rs b/src/core/job_detail.rs index 00d35a3..7ec6bdd 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,22 @@ 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 Default for JobDetailResolver { + fn default() -> Self { + Self::new() + } } impl JobDetailResolver { @@ -37,6 +48,7 @@ impl JobDetailResolver { result_rx: res_rx, pending: None, cache: HashMap::new(), + order: VecDeque::new(), } } @@ -59,16 +71,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/core/job_fetcher.rs b/src/core/job_fetcher.rs index 02faf0c..632ce7b 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}; @@ -15,16 +15,28 @@ 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::(); 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 +68,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/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)); } } } diff --git a/src/dashboard.rs b/src/dashboard.rs index b7075cf..1a0c275 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}; @@ -15,11 +15,12 @@ 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::{ - 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, @@ -74,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, @@ -93,6 +95,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()); @@ -108,6 +123,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 @@ -134,7 +153,7 @@ impl Dashboard { focus: FocusWidget::Table, notice: String::new(), notice_expires: None, - refresh_secs: 1, + refresh_secs, known_partitions, known_qos, known_nodes, @@ -143,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, @@ -171,26 +191,18 @@ 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(); - 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(); @@ -228,21 +240,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; @@ -303,6 +301,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) { @@ -364,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) { @@ -496,11 +521,34 @@ 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<()> { - // 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 @@ -529,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 { @@ -608,22 +662,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 => { @@ -637,17 +688,29 @@ 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')) => { self.widget_sel.visible = true; return; } + (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => { + self.adjust_refresh(1); + return; + } + (_, KeyCode::Char('-')) | (_, KeyCode::Char('_')) => { + self.adjust_refresh(-1); + return; + } + (_, KeyCode::Char('?')) => { + self.show_help = true; + return; + } _ => {} } @@ -756,26 +819,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(); } @@ -867,11 +920,40 @@ 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); + } + } + + /// 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) { @@ -933,7 +1015,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() { @@ -973,3 +1055,105 @@ 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), + )), + ] +} + +#[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()); + } +} 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 402867e..673ecbc 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}, @@ -7,31 +8,51 @@ use crossterm::{ use ratatui::{Terminal, backend::CrosstermBackend}; use std::io; -mod backend; -mod core; -mod dashboard; -mod views; +use sqwatch::dashboard::Dashboard; -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); 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 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/filter_tree.rs b/src/views/filter_tree.rs index 544e707..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 { @@ -472,7 +478,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); 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/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, _ => {} } } 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 { 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 + ); + } +}