aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 21:32:40 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 23:06:48 +0300
commitd2074b12f1b2a2a97e188f0241863d6cb858efaf (patch)
tree99de2372409ac78a2ccf5cd16fc10ddfbf55174b /src
parentaa89f536a28ad11d5931649e7a178863346b6d69 (diff)
feat: add hosted product announcements
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs27
-rw-r--r--src/app/input/mouse.rs1
-rw-r--r--src/app/input/overlays.rs165
-rw-r--r--src/app/mod.rs53
-rw-r--r--src/app/state.rs15
-rw-r--r--src/main.rs1
-rw-r--r--src/product_announcements.rs352
-rw-r--r--src/server/headless.rs2
-rw-r--r--src/ui.rs6
-rw-r--r--src/ui/release_notes.rs121
-rw-r--r--src/ui/sidebar.rs1
-rw-r--r--src/update.rs45
12 files changed, 779 insertions, 10 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 93152e55..18dd8856 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -62,6 +62,7 @@ impl App {
match self.state.mode {
Mode::Onboarding => self.handle_onboarding_key(key),
Mode::ReleaseNotes => self.handle_release_notes_key(key),
+ Mode::ProductAnnouncement => self.handle_product_announcement_key(key),
Mode::Navigate => unreachable!(),
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
handle_rename_key(&mut self.state, key)
@@ -128,6 +129,32 @@ impl App {
}
}
+ pub(crate) fn handle_product_announcement_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => self.scroll_product_announcement(-1),
+ KeyCode::Down | KeyCode::Char('j') => self.scroll_product_announcement(1),
+ KeyCode::PageUp => self.scroll_product_announcement(-8),
+ KeyCode::PageDown => self.scroll_product_announcement(8),
+ KeyCode::Home => {
+ if let Some(announcement) = &mut self.state.product_announcement {
+ announcement.scroll = 0;
+ }
+ }
+ KeyCode::End => {
+ let max_scroll = self.state.product_announcement_max_scroll();
+ if let Some(announcement) = &mut self.state.product_announcement {
+ announcement.scroll = max_scroll;
+ }
+ }
+ _ => {
+ if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS)
+ {
+ self.dismiss_product_announcement();
+ }
+ }
+ }
+ }
+
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
if self.handle_overlay_mouse(mouse) {
return;
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 561aa3c4..e3f13d77 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -518,6 +518,7 @@ impl AppState {
self.set_sidebar_section_split(mouse.row);
}
DragTarget::ReleaseNotesScrollbar { .. }
+ | DragTarget::ProductAnnouncementScrollbar { .. }
| DragTarget::KeybindHelpScrollbar { .. } => {}
}
}
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs
index b283847e..365de6b6 100644
--- a/src/app/input/overlays.rs
+++ b/src/app/input/overlays.rs
@@ -67,6 +67,58 @@ impl App {
return true;
}
+ if self.state.mode == Mode::ProductAnnouncement {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left)
+ if self
+ .state
+ .product_announcement_close_button_at(mouse.column, mouse.row) =>
+ {
+ self.dismiss_product_announcement();
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(target) = self
+ .state
+ .product_announcement_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.state.drag = Some(DragState {
+ target: DragTarget::ProductAnnouncementScrollbar {
+ grab_row_offset,
+ },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => self
+ .state
+ .set_product_announcement_offset_from_bottom(offset_from_bottom),
+ }
+ }
+ }
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if let Some(DragState {
+ target: DragTarget::ProductAnnouncementScrollbar { grab_row_offset },
+ }) = &self.state.drag
+ {
+ if let Some(offset_from_bottom) = self
+ .state
+ .product_announcement_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.state
+ .set_product_announcement_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ self.state.drag = None;
+ }
+ MouseEventKind::ScrollUp => self.scroll_product_announcement(-3),
+ MouseEventKind::ScrollDown => self.scroll_product_announcement(3),
+ _ => {}
+ }
+ return true;
+ }
+
if self.state.mode == Mode::KeybindHelp {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left)
@@ -156,6 +208,13 @@ impl AppState {
)
}
+ fn product_announcement_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(
+ crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
+ crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1,
+ )
+ }
+
fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool {
let Some(inner) = self.release_notes_modal_inner() else {
return false;
@@ -267,6 +326,112 @@ impl AppState {
}
}
+ fn product_announcement_close_button_at(&self, col: u16, row: u16) -> bool {
+ let Some(inner) = self.product_announcement_modal_inner() else {
+ return false;
+ };
+ if inner.height < 4 || inner.width < 12 {
+ return false;
+ }
+ let button =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ col >= button.x
+ && col < button.x + button.width
+ && row >= button.y
+ && row < button.y + button.height
+ }
+
+ fn product_announcement_body_rect(&self) -> Option<Rect> {
+ let inner = self.product_announcement_modal_inner()?;
+ if inner.height < 8 || inner.width < 4 {
+ return None;
+ }
+ Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
+ }
+
+ fn product_announcement_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
+ let announcement = self.product_announcement.as_ref()?;
+ let body = self.product_announcement_body_rect()?;
+ let viewport_rows = body.height.max(1) as usize;
+ let lines = crate::ui::product_announcement_display_lines(announcement, &self.palette);
+
+ let rows_for_width = |wrap_width: usize| {
+ lines
+ .iter()
+ .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1)))
+ .sum::<usize>()
+ };
+
+ let full_width = body.width.max(1) as usize;
+ let mut total_rows = rows_for_width(full_width);
+ let wrap_width = if total_rows > viewport_rows && full_width > 1 {
+ body.width.saturating_sub(1).max(1) as usize
+ } else {
+ full_width
+ };
+ total_rows = rows_for_width(wrap_width);
+
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ Some(crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom.saturating_sub(announcement.scroll as usize),
+ max_offset_from_bottom,
+ viewport_rows,
+ })
+ }
+
+ pub(crate) fn product_announcement_max_scroll(&self) -> u16 {
+ self.product_announcement_scroll_metrics()
+ .map(|metrics| metrics.max_offset_from_bottom as u16)
+ .unwrap_or(0)
+ }
+
+ fn product_announcement_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let body = self.product_announcement_body_rect()?;
+ let metrics = self.product_announcement_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ if !(col >= track.x
+ && col < track.x + track.width
+ && row >= track.y
+ && row < track.y + track.height)
+ {
+ return None;
+ }
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some(ScrollbarClickTarget::Thumb { grab_row_offset })
+ } else {
+ Some(ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ })
+ }
+ }
+
+ fn product_announcement_offset_for_drag_row(
+ &self,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let body = self.product_announcement_body_rect()?;
+ let metrics = self.product_announcement_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ fn set_product_announcement_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let max_scroll = self.product_announcement_max_scroll() as usize;
+ if let Some(announcement) = &mut self.product_announcement {
+ announcement.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
+ }
+ }
+
pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 31ea3f6f..57a30633 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -316,11 +316,15 @@ impl App {
.map(|notes| notes.version.clone());
let latest_release_notes_available = latest_release_notes.is_some();
let update_install_command = crate::update::update_install_command().to_string();
+ let startup_product_announcement =
+ crate::product_announcements::load_unseen_for_current_version();
let mode = if config.should_show_onboarding() {
state::Mode::Onboarding
} else if startup_release_notes.is_some() {
state::Mode::ReleaseNotes
+ } else if startup_product_announcement.is_some() {
+ state::Mode::ProductAnnouncement
} else if active.is_some() {
state::Mode::Terminal
} else {
@@ -355,6 +359,16 @@ impl App {
scroll: 0,
preview: notes.preview,
}),
+ product_announcement: startup_product_announcement.map(|announcement| {
+ state::ProductAnnouncementState {
+ version: announcement.version,
+ id: announcement.id,
+ title: announcement.title,
+ body: announcement.body,
+ scroll: 0,
+ preview: announcement.preview,
+ }
+ }),
keybind_help: state::KeybindHelpState { scroll: 0 },
workspace_scroll: 0,
agent_panel_scroll: 0,
@@ -664,6 +678,30 @@ impl App {
}
}
+ if self.state.product_announcement.is_some() {
+ self.state.mode = Mode::ProductAnnouncement;
+ } else {
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ }
+
+ pub(crate) fn dismiss_product_announcement(&mut self) {
+ if let Some(announcement) = self.state.product_announcement.take() {
+ if !announcement.preview {
+ if let Err(err) =
+ crate::product_announcements::mark_seen(&announcement.version, &announcement.id)
+ {
+ self.state.config_diagnostic =
+ Some(format!("failed to update announcement status: {err}"));
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5));
+ }
+ }
+ }
+
self.state.mode = if self.state.active.is_some() {
Mode::Terminal
} else {
@@ -683,6 +721,18 @@ impl App {
}
}
+ pub(crate) fn scroll_product_announcement(&mut self, delta: i16) {
+ let max_scroll = self.state.product_announcement_max_scroll();
+ if let Some(announcement) = &mut self.state.product_announcement {
+ announcement.scroll = if delta.is_negative() {
+ announcement.scroll.saturating_sub(delta.unsigned_abs())
+ } else {
+ announcement.scroll.saturating_add(delta as u16)
+ }
+ .min(max_scroll);
+ }
+ }
+
pub(crate) fn open_settings_from_onboarding(&mut self) {
self.mark_onboarding_complete();
crate::app::input::open_settings(&mut self.state);
@@ -982,6 +1032,9 @@ impl App {
Mode::ReleaseNotes => {
self.handle_release_notes_key(key_event);
}
+ Mode::ProductAnnouncement => {
+ self.handle_product_announcement_key(key_event);
+ }
Mode::Settings => {
self.handle_settings_key(key_event);
}
diff --git a/src/app/state.rs b/src/app/state.rs
index c5734ec2..34be58d2 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -588,6 +588,7 @@ pub struct ViewState {
pub enum Mode {
Onboarding,
ReleaseNotes,
+ ProductAnnouncement,
Navigate,
Terminal,
RenameWorkspace,
@@ -747,6 +748,9 @@ pub(crate) enum DragTarget {
ReleaseNotesScrollbar {
grab_row_offset: u16,
},
+ ProductAnnouncementScrollbar {
+ grab_row_offset: u16,
+ },
KeybindHelpScrollbar {
grab_row_offset: u16,
},
@@ -853,6 +857,15 @@ pub struct ReleaseNotesState {
pub preview: bool,
}
+pub struct ProductAnnouncementState {
+ pub version: String,
+ pub id: String,
+ pub title: String,
+ pub body: String,
+ pub scroll: u16,
+ pub preview: bool,
+}
+
pub struct KeybindHelpState {
pub scroll: u16,
}
@@ -899,6 +912,7 @@ pub struct AppState {
pub name_input: String,
pub name_input_replace_on_type: bool,
pub release_notes: Option<ReleaseNotesState>,
+ pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub workspace_scroll: usize,
pub agent_panel_scroll: usize,
@@ -1133,6 +1147,7 @@ impl AppState {
name_input: String::new(),
name_input_replace_on_type: false,
release_notes: None,
+ product_announcement: None,
keybind_help: KeybindHelpState { scroll: 0 },
workspace_scroll: 0,
agent_panel_scroll: 0,
diff --git a/src/main.rs b/src/main.rs
index 744d4368..1946531a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -35,6 +35,7 @@ mod logging;
mod pane;
mod persist;
mod platform;
+mod product_announcements;
mod raw_input;
mod release_notes;
mod remote;
diff --git a/src/product_announcements.rs b/src/product_announcements.rs
new file mode 100644
index 00000000..0d62168c
--- /dev/null
+++ b/src/product_announcements.rs
@@ -0,0 +1,352 @@
+use std::collections::BTreeSet;
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+const PRODUCT_ANNOUNCEMENTS_PATH: &str = "product-announcements.json";
+const FAKE_ANNOUNCEMENT_BODY_ENV: &str = "HERDR_FAKE_PRODUCT_ANNOUNCEMENT_BODY";
+const FAKE_ANNOUNCEMENT_BODY_FILE_ENV: &str = "HERDR_FAKE_PRODUCT_ANNOUNCEMENT_BODY_FILE";
+const FAKE_ANNOUNCEMENT_ID_ENV: &str = "HERDR_FAKE_PRODUCT_ANNOUNCEMENT_ID";
+const FAKE_ANNOUNCEMENT_TITLE_ENV: &str = "HERDR_FAKE_PRODUCT_ANNOUNCEMENT_TITLE";
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ManifestAnnouncement {
+ pub id: String,
+ pub title: Option<String>,
+ pub body: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProductAnnouncement {
+ pub version: String,
+ pub id: String,
+ pub title: String,
+ pub body: String,
+ pub preview: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct StoredProductAnnouncement {
+ pub version: String,
+ pub id: String,
+ pub title: String,
+ pub body: String,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+struct AnnouncementStore {
+ #[serde(default)]
+ latest: Option<StoredProductAnnouncement>,
+ #[serde(default)]
+ seen: BTreeSet<String>,
+}
+
+impl StoredProductAnnouncement {
+ fn seen_key(&self) -> String {
+ seen_key(&self.version, &self.id)
+ }
+
+ fn into_product_announcement(self) -> ProductAnnouncement {
+ ProductAnnouncement {
+ version: self.version,
+ id: self.id,
+ title: self.title,
+ body: self.body,
+ preview: false,
+ }
+ }
+}
+
+impl From<&ProductAnnouncement> for StoredProductAnnouncement {
+ fn from(announcement: &ProductAnnouncement) -> Self {
+ Self {
+ version: announcement.version.clone(),
+ id: announcement.id.clone(),
+ title: announcement.title.clone(),
+ body: announcement.body.clone(),
+ }
+ }
+}
+
+fn seen_key(version: &str, id: &str) -> String {
+ format!("{version}/{id}")
+}
+
+pub fn store_path() -> PathBuf {
+ crate::config::state_dir().join(PRODUCT_ANNOUNCEMENTS_PATH)
+}
+
+pub fn save_manifest_announcement(
+ manifest_version: &str,
+ announcement: Option<&ManifestAnnouncement>,
+) -> io::Result<Option<ProductAnnouncement>> {
+ let Some(announcement) = announcement else {
+ clear_latest_for_version(&store_path(), manifest_version)?;
+ return Ok(None);
+ };
+
+ let Some(product_announcement) = announcement_from_manifest(manifest_version, announcement)
+ else {
+ clear_latest_for_version(&store_path(), manifest_version)?;
+ return Ok(None);
+ };
+
+ save_latest_to_path(&store_path(), product_announcement.clone())?;
+ Ok(Some(product_announcement))
+}
+
+pub fn load_unseen_for_current_version() -> Option<ProductAnnouncement> {
+ load_fake_for_current_version()
+ .or_else(|| load_unseen_from_path(&store_path(), env!("CARGO_PKG_VERSION")))
+}
+
+pub fn mark_seen(version: &str, id: &str) -> io::Result<()> {
+ mark_seen_at(&store_path(), version, id)
+}
+
+fn announcement_from_manifest(
+ manifest_version: &str,
+ announcement: &ManifestAnnouncement,
+) -> Option<ProductAnnouncement> {
+ let id = announcement.id.trim();
+ let body = normalize_body(&announcement.body);
+ if id.is_empty() || body.is_empty() {
+ return None;
+ }
+
+ let title = announcement
+ .title
+ .as_deref()
+ .map(str::trim)
+ .filter(|title| !title.is_empty())
+ .unwrap_or("announcement")
+ .to_string();
+
+ Some(ProductAnnouncement {
+ version: manifest_version.trim().to_string(),
+ id: id.to_string(),
+ title,
+ body,
+ preview: false,
+ })
+}
+
+fn load_fake_for_current_version() -> Option<ProductAnnouncement> {
+ let body = std::env::var(FAKE_ANNOUNCEMENT_BODY_FILE_ENV)
+ .ok()
+ .and_then(|path| fs::read_to_string(path).ok())
+ .or_else(|| std::env::var(FAKE_ANNOUNCEMENT_BODY_ENV).ok())?;
+ let body = normalize_body(&body);
+ if body.is_empty() {
+ return None;
+ }
+
+ let id = std::env::var(FAKE_ANNOUNCEMENT_ID_ENV)
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| "local-preview".to_string());
+ let title = std::env::var(FAKE_ANNOUNCEMENT_TITLE_ENV)
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| "product announcement preview".to_string());
+
+ Some(ProductAnnouncement {
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ id,
+ title,
+ body,
+ preview: true,
+ })
+}
+
+fn save_latest_to_path(path: &Path, announcement: ProductAnnouncement) -> io::Result<()> {
+ let mut store = load_store_from_path(path).unwrap_or_default();
+ store.latest = Some(StoredProductAnnouncement::from(&announcement));
+ write_store_to_path(path, &store)
+}
+
+fn clear_latest_for_version(path: &Path, version: &str) -> io::Result<()> {
+ let mut store = load_store_from_path(path).unwrap_or_default();
+ if store
+ .latest
+ .as_ref()
+ .is_some_and(|announcement| announcement.version == version)
+ {
+ store.latest = None;
+ write_store_to_path(path, &store)?;
+ }
+ Ok(())
+}
+
+fn mark_seen_at(path: &Path, version: &str, id: &str) -> io::Result<()> {
+ let mut store = load_store_from_path(path).unwrap_or_default();
+ store.seen.insert(seen_key(version, id));
+ write_store_to_path(path, &store)
+}
+
+fn load_unseen_from_path(path: &Path, current_version: &str) -> Option<ProductAnnouncement> {
+ let store = load_store_from_path(path)?;
+ let announcement = store.latest?;
+ if announcement.version != current_version || store.seen.contains(&announcement.seen_key()) {
+ return None;
+ }
+ Some(announcement.into_product_announcement())
+}
+
+fn load_store_from_path(path: &Path) -> Option<AnnouncementStore> {
+ let content = fs::read_to_string(path).ok()?;
+ serde_json::from_str(&content).ok()
+}
+
+fn write_store_to_path(path: &Path, store: &AnnouncementStore) -> io::Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ let json = serde_json::to_string_pretty(store).map_err(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) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(err);
+ }
+ Ok(())
+}
+
+fn normalize_body(body: &str) -> String {
+ body.lines()
+ .map(str::trim_end)
+ .collect::<Vec<_>>()
+ .join("\n")
+ .trim()
+ .to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn env_lock() -> &'static std::sync::Mutex<()> {
+ static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
+ LOCK.get_or_init(|| std::sync::Mutex::new(()))
+ }
+
+ fn temp_path(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!(
+ "herdr-product-announcements-{name}-{}-{}.json",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ))
+ }
+
+ #[test]
+ fn load_unseen_returns_current_unseen_announcement() {
+ let path = temp_path("unseen");
+ save_latest_to_path(
+ &path,
+ ProductAnnouncement {
+ version: "1.2.3".into(),
+ id: "keymap-v2".into(),
+ title: "Keymap changed".into(),
+ body: "### Changed\n- One".into(),
+ preview: false,
+ },
+ )
+ .unwrap();
+
+ let loaded = load_unseen_from_path(&path, "1.2.3").expect("announcement");
+ assert_eq!(loaded.id, "keymap-v2");
+ let _ = fs::remove_file(path);
+ }
+
+ #[test]
+ fn load_unseen_ignores_seen_announcement() {
+ let path = temp_path("seen");
+ save_latest_to_path(
+ &path,
+ ProductAnnouncement {
+ version: "1.2.3".into(),
+ id: "keymap-v2".into(),
+ title: "Keymap changed".into(),
+ body: "### Changed\n- One".into(),
+ preview: false,
+ },
+ )
+ .unwrap();
+ mark_seen_at(&path, "1.2.3", "keymap-v2").unwrap();
+
+ assert_eq!(load_unseen_from_path(&path, "1.2.3"), None);
+ let _ = fs::remove_file(path);
+ }
+
+ #[test]
+ fn clearing_manifest_announcement_removes_matching_latest() {
+ let path = temp_path("clear");
+ save_latest_to_path(
+ &path,
+ ProductAnnouncement {
+ version: "1.2.3".into(),
+ id: "keymap-v2".into(),
+ title: "Keymap changed".into(),
+ body: "### Changed\n- One".into(),
+ preview: false,
+ },
+ )
+ .unwrap();
+ clear_latest_for_version(&path, "1.2.3").unwrap();
+
+ assert_eq!(load_unseen_from_path(&path, "1.2.3"), None);
+ let _ = fs::remove_file(path);
+ }
+
+ #[test]
+ fn fake_announcement_body_env_creates_preview() {
+ let _guard = env_lock().lock().unwrap();
+ unsafe {
+ std::env::set_var(FAKE_ANNOUNCEMENT_BODY_ENV, "### Preview\n- Local body");
+ std::env::set_var(FAKE_ANNOUNCEMENT_TITLE_ENV, "Local title");
+ std::env::set_var(FAKE_ANNOUNCEMENT_ID_ENV, "local-id");
+ }
+
+ let announcement = load_fake_for_current_version().expect("fake announcement");
+ assert_eq!(announcement.id, "local-id");
+ assert_eq!(announcement.title, "Local title");
+ assert_eq!(announcement.body, "### Preview\n- Local body");
+ assert!(announcement.preview);
+
+ unsafe {
+ std::env::remove_var(FAKE_ANNOUNCEMENT_BODY_ENV);
+ std::env::remove_var(FAKE_ANNOUNCEMENT_TITLE_ENV);
+ std::env::remove_var(FAKE_ANNOUNCEMENT_ID_ENV);
+ }
+ }
+
+ #[test]
+ fn manifest_announcement_requires_id_and_body() {
+ assert!(announcement_from_manifest(
+ "1.2.3",
+ &ManifestAnnouncement {
+ id: " ".into(),
+ title: None,
+ body: "hello".into(),
+ },
+ )
+ .is_none());
+ assert!(announcement_from_manifest(
+ "1.2.3",
+ &ManifestAnnouncement {
+ id: "notice".into(),
+ title: None,
+ body: " ".into(),
+ },
+ )
+ .is_none());
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 5f9b62b9..58a2b178 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2188,7 +2188,7 @@ pub fn run_server() -> io::Result<()> {
&loaded_config.config,
no_session,
config::config_diagnostic_summary(&loaded_config.diagnostics),
- None, // startup_release_notes
+ crate::release_notes::load_pending_for_current_version(),
api_rx,
event_hub,
);
diff --git a/src/ui.rs b/src/ui.rs
index 846979da..a08956bb 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -33,11 +33,12 @@ use self::mobile::{
pub(crate) use self::onboarding::onboarding_welcome_continue_rect;
use self::onboarding::render_onboarding_overlay;
use self::panes::{compute_pane_infos, render_panes, resize_tab_panes};
-use self::release_notes::render_release_notes_overlay;
pub(crate) use self::release_notes::{
- release_notes_close_button_rect, release_notes_display_lines, release_notes_sections,
+ product_announcement_display_lines, release_notes_close_button_rect,
+ release_notes_display_lines, release_notes_sections, PRODUCT_ANNOUNCEMENT_MODAL_SIZE,
RELEASE_NOTES_MODAL_SIZE,
};
+use self::release_notes::{render_product_announcement_overlay, render_release_notes_overlay};
pub(crate) use self::scrollbar::{
pane_scrollbar_rect, release_notes_scrollbar_rect, scrollbar_offset_from_drag_row,
scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar,
@@ -305,6 +306,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
match app.mode {
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
+ Mode::ProductAnnouncement => render_product_announcement_overlay(app, frame, frame.area()),
Mode::Navigate if app.view.layout == ViewLayout::Mobile => {
render_mobile_panel(app, frame, frame.area())
}
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
index 91990695..9d069828 100644
--- a/src/ui/release_notes.rs
+++ b/src/ui/release_notes.rs
@@ -12,11 +12,12 @@ use super::widgets::{
render_modal_header, render_modal_shell,
};
use crate::app::{
- state::{Palette, ReleaseNotesState},
+ state::{Palette, ProductAnnouncementState, ReleaseNotesState},
AppState,
};
pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
+pub(crate) const PRODUCT_ANNOUNCEMENT_MODAL_SIZE: (u16, u16) = (88, 24);
pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(notes) = &app.release_notes else {
@@ -141,6 +142,117 @@ pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, ar
);
}
+pub(super) fn render_product_announcement_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(announcement) = &app.product_announcement else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(
+ frame,
+ area,
+ PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
+ PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1,
+ &app.palette,
+ ) else {
+ return;
+ };
+ if inner.height < 8 || inner.width < 20 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ 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_title_area, &announcement.title, &app.palette);
+ let subtitle = if announcement.preview {
+ "product announcement preview"
+ } else {
+ "product announcement"
+ };
+ frame.render_widget(
+ Paragraph::new(format!("{subtitle} · v{}", announcement.version))
+ .style(Style::default().fg(app.palette.overlay1)),
+ header_subtitle_area,
+ );
+ render_action_button(
+ frame,
+ release_notes_close_button_rect(header_rows[0]),
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ let body_rect = stack.content;
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app
+ .product_announcement_max_scroll()
+ .saturating_sub(announcement.scroll) as usize,
+ max_offset_from_bottom: app.product_announcement_max_scroll() as usize,
+ viewport_rows: body_rect.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(body_rect, metrics);
+ let text_area = track
+ .map(|_| {
+ Rect::new(
+ body_rect.x,
+ body_rect.y,
+ body_rect.width.saturating_sub(1),
+ body_rect.height,
+ )
+ })
+ .unwrap_or(body_rect);
+
+ let body = Paragraph::new(
+ product_announcement_display_lines(announcement, &app.palette)
+ .into_iter()
+ .map(|(_, line)| line)
+ .collect::<Vec<_>>(),
+ )
+ .wrap(Wrap { trim: false })
+ .scroll((announcement.scroll, 0));
+ frame.render_widget(body, text_area);
+ if let Some(track) = track {
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ app.palette.overlay0,
+ app.palette.overlay1,
+ "▐",
+ );
+ }
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("close", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ stack.footer.unwrap_or_default(),
+ );
+}
+
fn release_notes_inline_spans<'a>(
text: &str,
base_style: Style,
@@ -356,6 +468,13 @@ pub(crate) fn release_notes_display_lines<'a>(
release_notes_lines(notes.body.as_str(), p)
}
+pub(crate) fn product_announcement_display_lines<'a>(
+ announcement: &'a ProductAnnouncementState,
+ p: &Palette,
+) -> Vec<(usize, Line<'a>)> {
+ release_notes_lines(announcement.body.as_str(), p)
+}
+
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)
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 116010d1..b6b5bc9e 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -78,6 +78,7 @@ fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
| Mode::Settings
| Mode::GlobalMenu
| Mode::KeybindHelp
+ | Mode::ProductAnnouncement
) {
Some(app.selected)
} else {
diff --git a/src/update.rs b/src/update.rs
index 458a2323..6e148f5b 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -98,6 +98,7 @@ struct UpdateManifest {
protocol: Option<u32>,
notes: String,
assets: BTreeMap<String, String>,
+ announcement: Option<crate::product_announcements::ManifestAnnouncement>,
}
#[derive(Deserialize)]
@@ -133,10 +134,7 @@ struct ReleaseInfo {
notes_body: String,
}
-/// Check the hosted update manifest for the latest release. Returns release info if newer.
-fn check_latest() -> Result<Option<ReleaseInfo>, String> {
- let current = Version::current();
-
+fn fetch_update_manifest() -> Result<UpdateManifest, String> {
let output = Command::new("curl")
.args([
"-sfL",
@@ -155,9 +153,21 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> {
return Err("failed to fetch update manifest".into());
}
- let manifest: UpdateManifest = serde_json::from_slice(&output.stdout)
- .map_err(|e| format!("failed to parse update manifest JSON: {e}"))?;
+ serde_json::from_slice(&output.stdout)
+ .map_err(|e| format!("failed to parse update manifest JSON: {e}"))
+}
+fn handle_manifest_announcement(manifest: &UpdateManifest) {
+ if let Err(err) = crate::product_announcements::save_manifest_announcement(
+ &manifest.version,
+ manifest.announcement.as_ref(),
+ ) {
+ tracing::warn!("failed to save product announcement: {err}");
+ }
+}
+
+fn release_info_from_manifest(manifest: UpdateManifest) -> Result<Option<ReleaseInfo>, String> {
+ let current = Version::current();
let latest = Version::parse(&manifest.version)
.ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?;
@@ -183,6 +193,13 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> {
}))
}
+/// Check the hosted update manifest for the latest release. Returns release info if newer.
+fn check_latest() -> Result<Option<ReleaseInfo>, String> {
+ let manifest = fetch_update_manifest()?;
+ handle_manifest_announcement(&manifest);
+ release_info_from_manifest(manifest)
+}
+
fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result<Version, String> {
let formula: HomebrewFormula = serde_json::from_slice(input)
.map_err(|e| format!("failed to parse Homebrew formula JSON: {e}"))?;
@@ -1029,6 +1046,10 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
fn auto_update_homebrew(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
+ if let Ok(manifest) = fetch_update_manifest() {
+ handle_manifest_announcement(&manifest);
+ }
+
let version = match check_homebrew_latest() {
Ok(Some(version)) => version,
Ok(None) => return,
@@ -1488,6 +1509,11 @@ mod tests {
\"version\": \"0.2.0\",\n\
\"protocol\": 4,\n\
\"notes\": \"### Changed\\n- One\",\n\
+ \"announcement\": {\n\
+ \"id\": \"keymap-v2\",\n\
+ \"title\": \"Keymap changes\",\n\
+ \"body\": \"### Heads up\\n- Defaults changed\"\n\
+ },\n\
\"assets\": {\n\
\"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\
\"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\
@@ -1499,6 +1525,13 @@ mod tests {
assert_eq!(manifest.assets.len(), 2);
assert_eq!(manifest.notes_body(), "### Changed\n- One");
assert_eq!(
+ manifest
+ .announcement
+ .as_ref()
+ .map(|announcement| announcement.id.as_str()),
+ Some("keymap-v2")
+ );
+ assert_eq!(
manifest.download_url_for("linux", "x86_64").as_deref(),
Some("https://example.com/herdr-linux-x86_64")
);