aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/app/actions.rs10
-rw-r--r--src/app/mod.rs29
-rw-r--r--src/app/state.rs1
-rw-r--r--src/ui.rs31
-rw-r--r--src/update.rs16
-rw-r--r--tests/api_ping.rs12
6 files changed, 71 insertions, 28 deletions
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<Instant>,
next_resize_poll: Instant,
next_animation_tick: Option<Instant>,
+ next_auto_update_check: Option<Instant>,
last_render_at: Option<Instant>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -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<Instant> {
(!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<Version, String> {
/// Background auto-update: check, download, install, notify TUI.
/// Runs in a background thread at startup.
pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
+ 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<dyn Child + Send + Sync>,
}
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = 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");