feat: poll for updates and show toast notifications
This commit is contained in:
parent
b2293687af
commit
7c8f12e8f2
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ impl ContextMenuState {
|
|||
pub enum ToastKind {
|
||||
NeedsAttention,
|
||||
Finished,
|
||||
UpdateInstalled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
|
|||
31
src/ui.rs
31
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue