diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-19 21:32:40 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-19 23:06:48 +0300 |
| commit | d2074b12f1b2a2a97e188f0241863d6cb858efaf (patch) | |
| tree | 99de2372409ac78a2ccf5cd16fc10ddfbf55174b | |
| parent | aa89f536a28ad11d5931649e7a178863346b6d69 (diff) | |
feat: add hosted product announcements
| -rw-r--r-- | src/app/input/mod.rs | 27 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 1 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 165 | ||||
| -rw-r--r-- | src/app/mod.rs | 53 | ||||
| -rw-r--r-- | src/app/state.rs | 15 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/product_announcements.rs | 352 | ||||
| -rw-r--r-- | src/server/headless.rs | 2 | ||||
| -rw-r--r-- | src/ui.rs | 6 | ||||
| -rw-r--r-- | src/ui/release_notes.rs | 121 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 1 | ||||
| -rw-r--r-- | src/update.rs | 45 |
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, ); @@ -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") ); |
