aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
parentaa89f536a28ad11d5931649e7a178863346b6d69 (diff)
feat: add hosted product announcements
Diffstat (limited to 'src/app')
-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
5 files changed, 261 insertions, 0 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,