diff --git a/src/app/actions.rs b/src/app/actions.rs index 6c42b966..02c25d64 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -197,8 +197,13 @@ impl AppState { match event { AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id), AppEvent::UpdateReady { version } => { - self.update_available = Some(version); - self.update_dismissed = false; + self.update_available = Some(version.clone()); + self.update_dismissed = true; + self.toast = Some(ToastNotification { + kind: ToastKind::UpdateInstalled, + title: format!("updated to v{version}"), + context: "restart to use it".to_string(), + }); } AppEvent::StateChanged { pane_id, @@ -240,6 +245,7 @@ impl AppState { let event_text = match kind { ToastKind::NeedsAttention => "needs attention", ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", }; self.toast = Some(ToastNotification { kind, diff --git a/src/app/mod.rs b/src/app/mod.rs index 82c3777a..928ed2aa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,7 @@ const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); +const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); use crossterm::terminal; @@ -49,6 +50,7 @@ pub struct App { last_sidebar_divider_click: Option, next_resize_poll: Instant, next_animation_tick: Option, + next_auto_update_check: Option, last_render_at: Option, render_notify: Arc, render_dirty: Arc, @@ -226,6 +228,7 @@ impl App { } // Background auto-update (skipped in --no-session / test mode) + // Check once at startup, then periodically from the main loop. if !no_session { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); @@ -251,6 +254,8 @@ impl App { last_sidebar_divider_click: None, next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, next_animation_tick: None, + next_auto_update_check: (!no_session) + .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), last_render_at: None, api_rx, event_hub, @@ -487,6 +492,13 @@ impl App { changed = true; } + if self + .next_auto_update_check + .is_some_and(|deadline| now >= deadline) + { + self.run_auto_update_check(); + } + self.sync_animation_timer(now); changed } @@ -514,6 +526,21 @@ impl App { } } + fn run_auto_update_check(&mut self) { + self.next_auto_update_check = self + .state + .update_available + .is_none() + .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL); + + if self.state.update_available.is_some() { + return; + } + + let update_tx = self.event_tx.clone(); + std::thread::spawn(move || crate::update::auto_update(update_tx)); + } + fn git_refresh_deadline(&self) -> Option { (!self.state.workspaces.is_empty()) .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) @@ -534,6 +561,7 @@ impl App { self.toast_deadline, self.next_animation_tick, self.git_refresh_deadline(), + self.next_auto_update_check, render_deadline, ] .into_iter() @@ -606,6 +634,7 @@ impl App { let duration = match toast.kind { ToastKind::NeedsAttention => Duration::from_secs(8), ToastKind::Finished => Duration::from_secs(5), + ToastKind::UpdateInstalled => Duration::from_secs(3), }; Instant::now() + duration }); diff --git a/src/app/state.rs b/src/app/state.rs index bee6c262..caedb419 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -438,6 +438,7 @@ impl ContextMenuState { pub enum ToastKind { NeedsAttention, Finished, + UpdateInstalled, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/ui.rs b/src/ui.rs index 21153e5d..f5c806a6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -92,11 +92,6 @@ pub fn render(app: &AppState, frame: &mut Frame) { } // Notifications (rendered on top of everything) - if let Some(version) = &app.update_available { - if !app.update_dismissed { - render_update_notification(frame, terminal_area, version, &app.palette); - } - } let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { render_config_diagnostic(frame, terminal_area, message, &app.palette); @@ -1565,26 +1560,6 @@ fn render_context_menu(app: &AppState, frame: &mut Frame) { frame.render_stateful_widget(list, inner, &mut state); } -fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, p: &Palette) { - let text = format!(" ✦ herdr v{version} installed — restart to update "); - let width = text.len() as u16 + 2; - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(3); - let notif_area = Rect::new(x, y, width.min(area.width), 1); - - frame.render_widget(Clear, notif_area); - frame.render_widget( - Paragraph::new(Span::styled( - text, - Style::default() - .fg(p.panel_bg) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - )), - notif_area, - ); -} - fn render_toast_notification( frame: &mut Frame, area: Rect, @@ -1595,12 +1570,16 @@ fn render_toast_notification( let dot_color = match toast.kind { ToastKind::NeedsAttention => p.red, ToastKind::Finished => p.blue, + ToastKind::UpdateInstalled => p.accent, }; let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; let width = content_width.saturating_add(2).min(area.width); let height = 4u16.min(area.height); let x = area.x + area.width.saturating_sub(width); - let y = area.y + if offset_for_warning { 1 } else { 0 }; + let y = area.y + + area + .height + .saturating_sub(height + if offset_for_warning { 1 } else { 0 }); let toast_area = Rect::new(x, y, width, height); frame.render_widget(Clear, toast_area); diff --git a/src/update.rs b/src/update.rs index e3fb0d77..4250a007 100644 --- a/src/update.rs +++ b/src/update.rs @@ -12,6 +12,7 @@ use serde::Deserialize; const GITHUB_REPO: &str = "ogulcancelik/herdr"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION"; // --------------------------------------------------------------------------- // Version @@ -213,6 +214,21 @@ pub fn self_update() -> Result { /// Background auto-update: check, download, install, notify TUI. /// Runs in a background thread at startup. pub fn auto_update(events: tokio::sync::mpsc::Sender) { + if let Ok(version) = env::var(FAKE_UPDATE_VERSION_ENV) { + let version = version.trim(); + if !version.is_empty() { + tracing::info!( + env = FAKE_UPDATE_VERSION_ENV, + version, + "using fake update version for local testing" + ); + let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { + version: version.to_string(), + }); + } + return; + } + let release = match check_latest() { Ok(Some(r)) => r, _ => return, // up to date or failed — silently do nothing diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 67f89c59..043d9931 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -2,6 +2,7 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -20,6 +21,13 @@ struct SpawnedHerdr { child: Box, } +fn test_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("api ping test lock poisoned") +} + fn wait_for_socket(path: &Path, timeout: Duration) { let deadline = Instant::now() + timeout; while Instant::now() < deadline { @@ -122,6 +130,7 @@ fn wait_for_event( #[test] fn ping_over_socket_returns_version() { + let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); @@ -145,6 +154,7 @@ fn ping_over_socket_returns_version() { #[test] fn workspace_list_and_create_round_trip() { + let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); @@ -292,6 +302,7 @@ fn workspace_list_and_create_round_trip() { #[test] fn events_subscribe_streams_lifecycle_and_agent_events() { + let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime"); @@ -425,6 +436,7 @@ fn events_subscribe_streams_lifecycle_and_agent_events() { #[test] fn events_subscribe_streams_output_and_agent_state_events() { + let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); let runtime_dir = base.join("runtime");