aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 21:34:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 21:34:20 +0300
commitecf8b08595580e8a07fc4cf85e4800b1c1213cf3 (patch)
treeb915a50458dbfda9c60c28b32cca27f03abbd3b7 /src
parent05d6c6c2cd44c7db80c708c0e521ac4ac7e78e13 (diff)
feat: improve update-ready and release notes ux
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs1
-rw-r--r--src/app/input.rs122
-rw-r--r--src/app/mod.rs7
-rw-r--r--src/app/state.rs2
-rw-r--r--src/release_notes.rs125
-rw-r--r--src/ui.rs62
-rw-r--r--src/update.rs19
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();
+ }
}
diff --git a/src/ui.rs b/src/ui.rs
index 210b221e..435e3832 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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();