aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs22
-rw-r--r--src/app/input.rs33
-rw-r--r--src/app/mod.rs26
-rw-r--r--src/ui.rs211
-rw-r--r--src/update.rs42
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");
diff --git a/src/ui.rs b/src/ui.rs
index add1eb31..29af2e17 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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, &notes.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(&notes_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]