diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 22 | ||||
| -rw-r--r-- | src/app/input.rs | 33 | ||||
| -rw-r--r-- | src/app/mod.rs | 26 | ||||
| -rw-r--r-- | src/ui.rs | 211 | ||||
| -rw-r--r-- | src/update.rs | 42 |
5 files changed, 282 insertions, 52 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index fcdeff92..ffb41ba1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -481,8 +481,8 @@ impl AppState { self.update_dismissed = true; self.toast = Some(ToastNotification { kind: ToastKind::UpdateInstalled, - title: format!("updated to v{version}"), - context: "restart to use it".to_string(), + title: format!("v{version} available"), + context: "run `herdr update`".to_string(), }); Vec::new() } @@ -1009,6 +1009,24 @@ mod tests { } #[test] + fn update_ready_sets_manual_update_toast() { + let mut state = AppState::test_new(); + + let updates = state.handle_app_event(AppEvent::UpdateReady { + version: "0.5.0".into(), + }); + + assert!(updates.is_empty()); + assert_eq!(state.update_available.as_deref(), Some("0.5.0")); + assert!(state.latest_release_notes_available); + assert!(state.update_dismissed); + let toast = state.toast.as_ref().expect("update toast"); + assert_eq!(toast.kind, ToastKind::UpdateInstalled); + assert_eq!(toast.title, "v0.5.0 available"); + assert_eq!(toast.context, "run `herdr update`"); + } + + #[test] fn toggle_fullscreen_works() { let mut state = app_with_workspaces(&["test"]); state.workspaces[0].test_split(Direction::Horizontal); diff --git a/src/app/input.rs b/src/app/input.rs index 347d8e41..dbc2547d 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1446,7 +1446,10 @@ impl AppState { } fn release_notes_modal_inner(&self) -> Option<ratatui::layout::Rect> { - self.onboarding_modal_inner(76, 20) + self.onboarding_modal_inner( + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) } fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { @@ -1473,7 +1476,12 @@ impl AppState { if inner.height < 8 || inner.width < 4 { return None; } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) + let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; + let preview = self + .release_notes + .as_ref() + .is_some_and(|notes| notes.preview); + Some(crate::ui::release_notes_sections(body, preview).notes_body) } fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { @@ -1774,14 +1782,12 @@ impl AppState { pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { let mut labels = vec!["settings", "keybinds", "reload keybinds"]; - if self.latest_release_notes_available || self.update_available.is_some() { + if self.update_available.is_some() { + labels.push("update available"); + } else if self.latest_release_notes_available { labels.push("what's new"); } - labels.push(if self.update_available.is_some() { - "quit to apply update" - } else { - "quit" - }); + labels.push("quit"); labels } @@ -1791,7 +1797,10 @@ impl AppState { let labels = self.global_menu_labels(); let content_width = labels .iter() - .map(|label| label.chars().count() as u16) + .map(|label| { + let extra = if *label == "update available" { 2 } else { 0 }; + label.chars().count() as u16 + extra + }) .max() .unwrap_or(8) .saturating_add(2); @@ -4269,7 +4278,7 @@ mod tests { } #[test] - fn update_pending_menu_surfaces_quit_to_apply_update_action() { + fn update_available_menu_surfaces_update_status_without_apply_action() { let mut app = app_for_mouse_test(); app.state.update_available = Some("0.3.2".into()); app.state.latest_release_notes_available = true; @@ -4287,8 +4296,8 @@ mod tests { "settings", "keybinds", "reload keybinds", - "what's new", - "quit to apply update" + "update available", + "quit" ] ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 165a6ff3..36b00033 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -205,7 +205,12 @@ impl App { "using pane scrollback configuration" ); - let latest_release_notes_available = crate::release_notes::load_latest().is_some(); + let latest_release_notes = crate::release_notes::load_latest(); + let update_available = latest_release_notes + .as_ref() + .filter(|notes| notes.preview) + .map(|notes| notes.version.clone()); + let latest_release_notes_available = latest_release_notes.is_some(); let mode = if config.should_show_onboarding() { state::Mode::Onboarding @@ -261,7 +266,7 @@ impl App { tab_press: None, selection: None, context_menu: None, - update_available: None, + update_available, latest_release_notes_available, update_dismissed: false, config_diagnostic, @@ -1362,6 +1367,23 @@ mod tests { } #[test] + fn startup_restores_preview_update_available_from_saved_notes() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("startup-preview-update-available"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + crate::release_notes::save_pending("0.5.0", "### Changed\n- One").unwrap(); + + let app = test_app(); + + assert_eq!(app.state.update_available.as_deref(), Some("0.5.0")); + assert!(app.state.latest_release_notes_available); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_keybinds_updates_live_state() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-keybinds-success"); @@ -1829,6 +1829,8 @@ fn panel_contrast_fg(p: &Palette) -> Color { } } +pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); + pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> { let popup_w = popup_w.min(area.width.saturating_sub(4)); let popup_h = popup_h.min(area.height.saturating_sub(2)); @@ -1853,13 +1855,15 @@ fn render_modal_shell( } fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - frame.render_widget( - Paragraph::new(Span::styled( - format!(" {title} "), + let line = Line::from(vec![ + Span::styled( + title, Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )), - area, - ); + ), + Span::raw(" "), + Span::styled("⬆", Style::default().fg(p.accent)), + ]); + frame.render_widget(Paragraph::new(line), area); } #[derive(Debug, Clone, Copy)] @@ -1943,7 +1947,13 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { dim_background(frame, area); - let Some(inner) = render_modal_shell(frame, area, 76, 20, &app.palette) else { + let Some(inner) = render_modal_shell( + frame, + area, + RELEASE_NOTES_MODAL_SIZE.0, + RELEASE_NOTES_MODAL_SIZE.1, + &app.palette, + ) else { return; }; if inner.height < 8 || inner.width < 20 { @@ -1954,16 +1964,33 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let header_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); + let header_title_area = Rect::new( + header_rows[0].x + 1, + header_rows[0].y, + header_rows[0].width.saturating_sub(2), + header_rows[0].height, + ); + let header_subtitle_area = Rect::new( + header_rows[1].x + 1, + header_rows[1].y, + header_rows[1].width.saturating_sub(2), + header_rows[1].height, + ); + render_modal_header( frame, - header_rows[0], + header_title_area, &format!("v{}", notes.version), &app.palette, ); + let subtitle = if notes.preview { + "preview before updating" + } else { + "what's new in this release" + }; frame.render_widget( - Paragraph::new(" what's new in this release") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[1], + Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)), + header_subtitle_area, ); render_action_button( frame, @@ -1976,23 +2003,27 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ); - let body_area = stack.content; + let sections = release_notes_sections(stack.content, notes.preview); let metrics = crate::pane::ScrollMetrics { offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize, max_offset_from_bottom: app.release_notes_max_scroll() as usize, - viewport_rows: body_area.height.max(1) as usize, + viewport_rows: sections.notes_body.height.max(1) as usize, }; - let track = release_notes_scrollbar_rect(body_area, metrics); - let text_area = track + let track = release_notes_scrollbar_rect(sections.notes_body, metrics); + let notes_text_area = track .map(|_| { Rect::new( - body_area.x, - body_area.y, - body_area.width.saturating_sub(1), - body_area.height, + sections.notes_body.x, + sections.notes_body.y, + sections.notes_body.width.saturating_sub(1), + sections.notes_body.height, ) }) - .unwrap_or(body_area); + .unwrap_or(sections.notes_body); + + if let Some(instructions_area) = sections.instructions { + render_release_notes_preview_panel(frame, instructions_area, ¬es.version, &app.palette); + } let body = Paragraph::new( release_notes_lines(notes.body.as_str(), &app.palette) @@ -2002,7 +2033,7 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { ) .wrap(Wrap { trim: false }) .scroll((notes.scroll, 0)); - frame.render_widget(body, text_area); + frame.render_widget(body, notes_text_area); if let Some(track) = track { render_scrollbar( frame, @@ -2109,14 +2140,18 @@ pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, } if let Some(rest) = trimmed.strip_prefix("### ") { - let text = rest.to_lowercase(); + let text = rest.trim().to_string(); + if text.is_empty() { + lines.push((0, Line::raw(""))); + continue; + } let width = 1 + text.chars().count(); lines.push(( width, Line::from(vec![ Span::raw(" "), Span::styled( - text, + text.to_uppercase(), Style::default().fg(p.accent).add_modifier(Modifier::BOLD), ), ]), @@ -2145,6 +2180,80 @@ pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, lines } +pub(crate) struct ReleaseNotesSections { + pub instructions: Option<Rect>, + pub notes_body: Rect, +} + +pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections { + if preview && area.height >= 6 { + let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area); + ReleaseNotesSections { + instructions: Some(rows[0]), + notes_body: rows[1], + } + } else { + ReleaseNotesSections { + instructions: None, + notes_body: area, + } + } +} + +fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> { + let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD); + let text_style = Style::default().fg(p.text); + let code_style = Style::default() + .fg(p.accent) + .bg(p.surface0) + .add_modifier(Modifier::BOLD); + + vec![ + Line::from(vec![ + Span::styled( + "●", + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" update available", title_style), + ]), + Line::from(vec![ + Span::styled("quit herdr and run ", text_style), + Span::styled("herdr update", code_style), + Span::styled(" to install", text_style), + ]), + ] +} + +fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) { + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas::<4>(area); + + let text_area = Rect::new( + rows[0].x + 1, + rows[0].y, + rows[0].width.saturating_sub(2), + rows[0].height, + ); + frame.render_widget( + Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }), + text_area, + ); + + let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "─".repeat(divider_area.width as usize), + Style::default().fg(p.surface1), + )])), + divider_area, + ); +} + pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { let width = action_button_width(Some("esc"), "close"); Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) @@ -2414,7 +2523,7 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { if app.update_available.is_some() { let status = Line::from(vec![Span::styled( - " update ready", + " update available", Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD), @@ -2448,6 +2557,41 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { break; } let selected = idx == app.global_menu.highlighted; + let rect = Rect::new(inner.x, y, inner.width, 1); + + if *item == "update available" { + let line = if selected { + Line::from(vec![ + Span::styled( + " update available ", + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "● ", + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(vec![ + Span::styled(" update available ", Style::default().fg(app.palette.text)), + Span::styled( + "● ", + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ), + ]) + }; + frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect); + continue; + } + let style = if selected { Style::default() .fg(panel_contrast_fg(&app.palette)) @@ -2460,7 +2604,7 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { Paragraph::new(format!(" {item} ")) .style(style) .alignment(Alignment::Left), - Rect::new(inner.x, y, inner.width, 1), + rect, ); } } @@ -3784,6 +3928,21 @@ mod tests { } #[test] + fn release_notes_preview_lines_show_update_steps() { + let palette = Palette::catppuccin(); + let lines = release_notes_preview_lines("0.5.0", &palette); + + assert_eq!(lines.len(), 2); + assert_eq!(line_text(&lines[0]), "● update available"); + assert_eq!( + line_text(&lines[1]), + "quit herdr and run herdr update to install" + ); + assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent)); + assert_eq!(lines[0].spans[1].style.fg, Some(palette.text)); + } + + #[test] fn release_notes_fenced_code_blocks_render_as_preformatted_lines() { let palette = Palette::catppuccin(); let lines = release_notes_lines( @@ -3792,7 +3951,7 @@ mod tests { ); assert_eq!(lines.len(), 4); - assert_eq!(line_text(&lines[0].1), " fixed"); + assert_eq!(line_text(&lines[0].1), " FIXED"); assert_eq!(line_text(&lines[1].1), "▏ just check"); assert_eq!(line_text(&lines[2].1), "▏ - not a bullet"); assert_eq!(line_text(&lines[3].1), " • after"); diff --git a/src/update.rs b/src/update.rs index 4be993dc..af446fed 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,6 +1,8 @@ //! Self-update mechanism. //! -//! Checks the hosted herdr.dev update manifest for newer versions and downloads the binary. +//! Checks the hosted herdr.dev update manifest for newer versions. +//! Manual `herdr update` downloads and installs the binary. +//! Background checks only surface availability and release notes. //! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies. //! JSON parsing uses serde_json (already in deps for persistence). @@ -14,8 +16,20 @@ use serde::Deserialize; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION"; +const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION"; +const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0"; fn fake_release_notes_body(version: &str) -> String { + let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_FAKE_UPDATE_NOTES_VERSION.to_string()); + + if let Some(notes) = crate::release_notes::load_preview_from_local_changelog(¬es_version) { + return notes.body; + } + 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}." ) @@ -228,7 +242,7 @@ pub fn self_update() -> Result<Version, String> { Ok(release.version) } -/// Background auto-update: check, download, install, notify TUI. +/// Background update check: only surface availability and release notes. /// 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) { @@ -257,7 +271,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { }; tracing::info!( - "new version v{} available, downloading from {}", + "new version v{} available at {}", release.version, release.download_url ); @@ -268,13 +282,8 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { tracing::warn!("failed to save pending release notes: {e}"); } - if let Err(e) = download_and_install(&release) { - tracing::warn!("auto-update failed: {e}"); - return; - } - tracing::info!( - "auto-update: v{} installed, restart to use", + "auto-update check: v{} available, waiting for manual install", release.version ); @@ -348,11 +357,24 @@ mod tests { } #[test] - fn fake_release_notes_include_version_and_context() { + fn fake_release_notes_default_to_real_large_changelog_section() { + std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); + + let body = fake_release_notes_body("9.4.9"); + assert!(body.contains("### Major Changes")); + assert!(body.contains("Added tabs within workspaces")); + } + + #[test] + fn fake_release_notes_fallback_include_version_and_context() { + std::env::set_var(FAKE_UPDATE_NOTES_VERSION_ENV, "does-not-exist"); + 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)); + + std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); } #[test] |
