feat: poll for updates and show toast notifications

This commit is contained in:
Ogulcan Celik 2026-03-31 14:23:14 +03:00
parent b2293687af
commit 7c8f12e8f2
6 changed files with 71 additions and 28 deletions

View File

@ -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,

View File

@ -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
});

View File

@ -438,6 +438,7 @@ impl ContextMenuState {
pub enum ToastKind {
NeedsAttention,
Finished,
UpdateInstalled,
}
#[derive(Debug, Clone, PartialEq, Eq)]

View File

@ -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);

View File

@ -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

View File

@ -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");