diff options
| -rw-r--r-- | src/app/actions.rs | 1 | ||||
| -rw-r--r-- | src/app/input.rs | 122 | ||||
| -rw-r--r-- | src/app/mod.rs | 7 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/release_notes.rs | 125 | ||||
| -rw-r--r-- | src/ui.rs | 62 | ||||
| -rw-r--r-- | src/update.rs | 19 |
7 files changed, 295 insertions, 43 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index dc52b174..88366ce8 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -321,6 +321,7 @@ impl AppState { } AppEvent::UpdateReady { version } => { self.update_available = Some(version.clone()); + self.latest_release_notes_available = true; self.update_dismissed = true; self.toast = Some(ToastNotification { kind: ToastKind::UpdateInstalled, diff --git a/src/app/input.rs b/src/app/input.rs index 73366576..e04c9245 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -484,12 +484,21 @@ fn modal_action_from_buttons<A: Copy>(col: u16, row: u16, buttons: &[(Rect, A)]) #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GlobalMenuAction { + ApplyUpdate, + WhatsNew, Keybinds, Settings, } -impl GlobalMenuAction { - const ALL: [Self; 2] = [Self::Keybinds, Self::Settings]; +fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { + let mut actions = vec![GlobalMenuAction::Settings, GlobalMenuAction::Keybinds]; + if state.latest_release_notes_available || state.update_available.is_some() { + actions.push(GlobalMenuAction::WhatsNew); + } + if state.update_available.is_some() { + actions.push(GlobalMenuAction::ApplyUpdate); + } + actions } fn normalize_theme_name(name: &str) -> String { @@ -633,23 +642,39 @@ fn open_keybind_help(state: &mut AppState) { state.mode = Mode::KeybindHelp; } +fn open_update_release_notes(state: &mut AppState) { + let Some(notes) = crate::release_notes::load_latest() else { + return; + }; + + state.release_notes = Some(crate::app::state::ReleaseNotesState { + version: notes.version, + body: notes.body, + scroll: 0, + preview: notes.preview, + }); + state.mode = Mode::ReleaseNotes; +} + fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { match action { + GlobalMenuAction::ApplyUpdate => state.should_quit = true, + GlobalMenuAction::WhatsNew => open_update_release_notes(state), GlobalMenuAction::Keybinds => open_keybind_help(state), GlobalMenuAction::Settings => open_settings(state), } } fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { + let actions = global_menu_actions(state); match key.code { KeyCode::Esc | KeyCode::Char('q') => leave_modal(state), KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), - KeyCode::Down | KeyCode::Char('j') => { - state.global_menu.move_next(GlobalMenuAction::ALL.len()) - } + KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), KeyCode::Enter => { - let action = GlobalMenuAction::ALL[state.global_menu.highlighted]; - apply_global_menu_action(state, action); + if let Some(action) = actions.get(state.global_menu.highlighted).copied() { + apply_global_menu_action(state, action); + } } _ => {} } @@ -1411,16 +1436,39 @@ impl AppState { pub(crate) fn global_launcher_rect(&self) -> Rect { let footer = self.sidebar_footer_rect(); - let width = 6u16.min(footer.width.max(1)); + let width = if self.update_available.is_some() { + 8 + } else { + 6 + } + .min(footer.width.max(1)); let x = footer.x + footer.width.saturating_sub(width); Rect::new(x, footer.y, width, footer.height) } + pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { + let mut labels = vec!["settings", "keybinds"]; + if self.latest_release_notes_available || self.update_available.is_some() { + labels.push("what's new"); + } + if self.update_available.is_some() { + labels.push("quit to apply update"); + } + labels + } + pub(crate) fn global_menu_rect(&self) -> Rect { let screen = self.screen_rect(); let launcher = self.global_launcher_rect(); - let menu_w = 14u16.min(screen.width.max(1)); - let menu_h = (GlobalMenuAction::ALL.len() as u16 + 2).min(screen.height.max(1)); + let labels = self.global_menu_labels(); + let content_width = labels + .iter() + .map(|label| label.chars().count() as u16) + .max() + .unwrap_or(8) + .saturating_add(2); + let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); + let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); let max_x = screen.x + screen.width.saturating_sub(menu_w); let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); let x = desired_x.min(max_x); @@ -1438,7 +1486,7 @@ impl AppState { return None; } let idx = (row - rect.y - 1) as usize; - GlobalMenuAction::ALL.get(idx).copied() + global_menu_actions(self).get(idx).copied() } pub(crate) fn keybind_help_popup_rect(&self) -> Rect { @@ -1689,13 +1737,10 @@ impl AppState { && mouse.row < launcher.y + launcher.height; if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { + let actions = global_menu_actions(self); let hovered = self .global_menu_item_at(mouse.column, mouse.row) - .and_then(|action| { - GlobalMenuAction::ALL - .iter() - .position(|item| *item == action) - }); + .and_then(|action| actions.iter().position(|item| *item == action)); self.global_menu.hover(hovered); return None; } @@ -2496,6 +2541,8 @@ mod tests { crate::api::EventHub::default(), ); app.state.mode = Mode::Terminal; + app.state.update_available = None; + app.state.latest_release_notes_available = false; app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); app.state.view.terminal_area = Rect::new(26, 0, 80, 20); app @@ -2867,7 +2914,7 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, - menu.y + 1, + menu.y + 2, )); assert_eq!(app.state.mode, Mode::KeybindHelp); @@ -2887,13 +2934,52 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, - menu.y + 2, + menu.y + 1, )); assert_eq!(app.state.mode, Mode::Settings); } #[test] + fn update_pending_menu_surfaces_apply_update_action() { + let mut app = app_for_mouse_test(); + app.state.update_available = Some("0.3.2".into()); + app.state.latest_release_notes_available = true; + + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + assert_eq!( + app.state.global_menu_labels(), + vec!["settings", "keybinds", "what's new", "quit to apply update"] + ); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 4, + )); + + assert!(app.state.should_quit); + } + + #[test] + fn whats_new_remains_in_menu_for_latest_installed_release_notes() { + let mut app = app_for_mouse_test(); + app.state.latest_release_notes_available = true; + + assert_eq!( + app.state.global_menu_labels(), + vec!["settings", "keybinds", "what's new"] + ); + } + + #[test] fn clicking_keybind_help_close_button_closes_overlay() { let mut app = app_for_mouse_test(); app.state.mode = Mode::KeybindHelp; diff --git a/src/app/mod.rs b/src/app/mod.rs index 17fc1b5d..1ef52e1f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -173,6 +173,8 @@ impl App { (Vec::new(), None, 0) }; + let latest_release_notes_available = crate::release_notes::load_latest().is_some(); + let mode = if config.should_show_onboarding() { state::Mode::Onboarding } else if startup_release_notes.is_some() { @@ -216,6 +218,7 @@ impl App { selection: None, context_menu: None, update_available: None, + latest_release_notes_available, update_dismissed: false, config_diagnostic, toast: None, @@ -1692,9 +1695,9 @@ impl App { self.state.release_notes = None; if !preview { - if let Err(err) = crate::release_notes::clear_pending() { + if let Err(err) = crate::release_notes::mark_current_version_seen() { self.state.config_diagnostic = - Some(format!("failed to clear release notes: {err}")); + Some(format!("failed to update release notes status: {err}")); self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); } } diff --git a/src/app/state.rs b/src/app/state.rs index 43174081..bb1f5bfe 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -551,6 +551,7 @@ pub struct AppState { pub context_menu: Option<ContextMenuState>, // Notifications pub update_available: Option<String>, + pub latest_release_notes_available: bool, pub update_dismissed: bool, pub config_diagnostic: Option<String>, pub toast: Option<ToastNotification>, @@ -651,6 +652,7 @@ impl AppState { selection: None, context_menu: None, update_available: None, + latest_release_notes_available: false, update_dismissed: false, config_diagnostic: None, toast: None, diff --git a/src/release_notes.rs b/src/release_notes.rs index fcee9a41..7d83dd15 100644 --- a/src/release_notes.rs +++ b/src/release_notes.rs @@ -16,6 +16,12 @@ pub struct ReleaseNotes { struct StoredReleaseNotes { version: String, body: String, + #[serde(default = "default_show_on_startup")] + show_on_startup: bool, +} + +fn default_show_on_startup() -> bool { + true } pub fn pending_path() -> PathBuf { @@ -34,15 +40,22 @@ fn save_pending_to_path(path: &Path, version: &str, body: &str) -> std::io::Resu return clear_pending_at(path); } + write_stored_to_path( + path, + &StoredReleaseNotes { + version: version.to_string(), + body, + show_on_startup: true, + }, + ) +} + +fn write_stored_to_path(path: &Path, stored: &StoredReleaseNotes) -> std::io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let stored = StoredReleaseNotes { - version: version.to_string(), - body, - }; - let json = serde_json::to_string_pretty(&stored).map_err(std::io::Error::other)?; + let json = serde_json::to_string_pretty(stored).map_err(std::io::Error::other)?; let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id())); fs::write(&tmp_path, json)?; if let Err(err) = fs::rename(&tmp_path, path) { @@ -52,31 +65,61 @@ fn save_pending_to_path(path: &Path, version: &str, body: &str) -> std::io::Resu Ok(()) } +fn load_stored_from_path(path: &Path) -> Option<StoredReleaseNotes> { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + pub fn load_pending_for_current_version() -> Option<ReleaseNotes> { load_pending_from_path(&pending_path(), env!("CARGO_PKG_VERSION")) } +pub fn load_latest() -> Option<ReleaseNotes> { + load_latest_from_path(&pending_path(), env!("CARGO_PKG_VERSION")) +} + fn load_pending_from_path(path: &Path, current_version: &str) -> Option<ReleaseNotes> { - let content = fs::read_to_string(path).ok()?; - let stored: StoredReleaseNotes = serde_json::from_str(&content).ok()?; - if stored.version != current_version { + let stored = load_stored_from_path(path)?; + if stored.version != current_version || !stored.show_on_startup { return None; } + release_notes_from_stored(stored, current_version) +} +fn load_latest_from_path(path: &Path, current_version: &str) -> Option<ReleaseNotes> { + let stored = load_stored_from_path(path)?; + release_notes_from_stored(stored, current_version) +} + +fn release_notes_from_stored( + stored: StoredReleaseNotes, + current_version: &str, +) -> Option<ReleaseNotes> { let body = normalize_body(&stored.body); if body.is_empty() { return None; } Some(ReleaseNotes { + preview: stored.version != current_version, version: stored.version, body, - preview: false, }) } -pub fn clear_pending() -> std::io::Result<()> { - clear_pending_at(&pending_path()) +pub fn mark_current_version_seen() -> std::io::Result<()> { + mark_current_version_seen_at(&pending_path(), env!("CARGO_PKG_VERSION")) +} + +fn mark_current_version_seen_at(path: &Path, current_version: &str) -> std::io::Result<()> { + let Some(mut stored) = load_stored_from_path(path) else { + return Ok(()); + }; + if stored.version != current_version || !stored.show_on_startup { + return Ok(()); + } + stored.show_on_startup = false; + write_stored_to_path(path, &stored) } fn clear_pending_at(path: &Path) -> std::io::Result<()> { @@ -151,4 +194,64 @@ mod tests { "### Changed\n- One\n\n### Fixed\n- Two" ); } + + #[test] + fn load_latest_keeps_future_version_previewable_before_restart() { + let path = std::env::temp_dir().join(format!( + "herdr-release-notes-{}-{}.json", + std::process::id(), + "preview" + )); + let _ = clear_pending_at(&path); + save_pending_to_path(&path, "0.3.2", "### Changed\n- One").unwrap(); + + let notes = load_latest_from_path(&path, "0.3.1").expect("latest notes"); + assert_eq!(notes.version, "0.3.2"); + assert_eq!(notes.body, "### Changed\n- One"); + assert!(notes.preview); + + clear_pending_at(&path).unwrap(); + } + + #[test] + fn marking_current_version_seen_preserves_latest_notes_without_startup_modal() { + let path = std::env::temp_dir().join(format!( + "herdr-release-notes-{}-{}.json", + std::process::id(), + "seen" + )); + let _ = clear_pending_at(&path); + save_pending_to_path(&path, "0.3.1", "### Changed\n- One").unwrap(); + + let startup = load_pending_from_path(&path, "0.3.1").expect("startup notes"); + assert!(!startup.preview); + + mark_current_version_seen_at(&path, "0.3.1").unwrap(); + + assert!(load_pending_from_path(&path, "0.3.1").is_none()); + let latest = load_latest_from_path(&path, "0.3.1").expect("latest notes"); + assert_eq!(latest.version, "0.3.1"); + assert!(!latest.preview); + + clear_pending_at(&path).unwrap(); + } + + #[test] + fn legacy_notes_without_show_on_startup_default_to_unseen() { + let path = std::env::temp_dir().join(format!( + "herdr-release-notes-{}-{}.json", + std::process::id(), + "legacy" + )); + let _ = clear_pending_at(&path); + fs::write( + &path, + "{\n \"version\": \"0.3.1\",\n \"body\": \"### Changed\\n- One\"\n}", + ) + .unwrap(); + + assert!(load_pending_from_path(&path, "0.3.1").is_some()); + + clear_pending_at(&path).unwrap(); + } } @@ -379,7 +379,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { ); } - render_sidebar_toggle(frame, area, true, p); + render_sidebar_toggle(app, frame, area, true, p); } fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { @@ -431,7 +431,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { } } - render_sidebar_toggle(frame, area, false, p); + render_sidebar_toggle(app, frame, area, false, p); } /// Render the workspace list in the top section of the sidebar. @@ -578,9 +578,19 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig ); let menu_rect = app.global_launcher_rect(); + let menu_line = if app.update_available.is_some() { + Line::from(vec![ + Span::styled("menu", Style::default().fg(p.overlay0)), + Span::styled( + " ●", + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))]) + }; frame.render_widget( - Paragraph::new(Span::styled("menu", Style::default().fg(p.overlay0))) - .alignment(Alignment::Right), + Paragraph::new(menu_line).alignment(Alignment::Right), menu_rect, ); } @@ -668,7 +678,13 @@ fn render_agent_detail( } } -fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool, p: &Palette) { +fn render_sidebar_toggle( + app: &AppState, + frame: &mut Frame, + area: Rect, + collapsed: bool, + p: &Palette, +) { // Toggle button not needed when sidebar has content — skip for now // to avoid conflicting with the agent detail panel at the bottom. if !collapsed { @@ -679,13 +695,14 @@ fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool, p: &Pal if content_w == 0 || area.height == 0 { return; } - let icon = "»"; + let icon_style = if app.update_available.is_some() { + Style::default().fg(p.accent).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.overlay0) + }; let x = area.x + content_w / 2; let toggle_area = Rect::new(x, bottom_y, 1, 1); - frame.render_widget( - Paragraph::new(Span::styled(icon, Style::default().fg(p.overlay0))), - toggle_area, - ); + frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), toggle_area); } fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { @@ -1579,6 +1596,27 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { buf[(x, overlay_y)].set_style(bg); } frame.render_widget(Paragraph::new(line), overlay_area); + + if app.update_available.is_some() { + let status = Line::from(vec![Span::styled( + " update ready", + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), + )]); + let width = 13u16.min(overlay_area.width); + let status_area = Rect::new( + overlay_area.x + overlay_area.width.saturating_sub(width), + overlay_area.y, + width, + overlay_area.height, + ); + frame.render_widget(Clear, status_area); + frame.render_widget( + Paragraph::new(status).alignment(Alignment::Right), + status_area, + ); + } } fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { @@ -1588,7 +1626,7 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { return; }; - let items = ["keybinds", "settings"]; + let items = app.global_menu_labels(); for (idx, item) in items.iter().enumerate() { let y = inner.y + idx as u16; if y >= inner.y + inner.height { @@ -1604,7 +1642,7 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { Style::default().fg(app.palette.text) }; frame.render_widget( - Paragraph::new(format!(" {item}")) + Paragraph::new(format!(" {item} ")) .style(style) .alignment(Alignment::Left), Rect::new(inner.x, y, inner.width, 1), diff --git a/src/update.rs b/src/update.rs index d616ace8..27e180a1 100644 --- a/src/update.rs +++ b/src/update.rs @@ -14,6 +14,12 @@ const GITHUB_REPO: &str = "ogulcancelik/herdr"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION"; +fn fake_release_notes_body(version: &str) -> String { + format!( + "### Changed\n- Test update v{version} for local UI validation.\n\n### Notes\n- This is stub release-notes content generated by {FAKE_UPDATE_VERSION_ENV}." + ) +} + // --------------------------------------------------------------------------- // Version // --------------------------------------------------------------------------- @@ -231,6 +237,11 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { version, "using fake update version for local testing" ); + if let Err(e) = + crate::release_notes::save_pending(version, &fake_release_notes_body(version)) + { + tracing::warn!("failed to save fake pending release notes: {e}"); + } let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: version.to_string(), }); @@ -335,6 +346,14 @@ mod tests { } #[test] + fn fake_release_notes_include_version_and_context() { + let body = fake_release_notes_body("9.4.9"); + assert!(body.contains("v9.4.9")); + assert!(body.contains("local UI validation")); + assert!(body.contains(FAKE_UPDATE_VERSION_ENV)); + } + + #[test] fn version_ordering() { let v010 = Version::parse("0.1.0").unwrap(); let v011 = Version::parse("0.1.1").unwrap(); |
