aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs178
-rw-r--r--src/app/mod.rs44
-rw-r--r--src/app/state.rs13
-rw-r--r--src/main.rs19
-rw-r--r--src/pane.rs66
-rw-r--r--src/release_notes.rs154
-rw-r--r--src/ui.rs197
-rw-r--r--src/update.rs15
8 files changed, 621 insertions, 65 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 4822a2e0..e0f012e9 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -40,6 +40,7 @@ impl App {
let key = key.as_key_event();
match self.state.mode {
Mode::Onboarding => self.handle_onboarding_key(key),
+ Mode::ReleaseNotes => self.handle_release_notes_key(key),
Mode::Navigate => handle_navigate_key(&mut self.state, key),
Mode::RenameSession => handle_rename_key(&mut self.state, key),
Mode::Resize => handle_resize_key(&mut self.state, key),
@@ -107,6 +108,28 @@ impl App {
}
}
+ fn handle_release_notes_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => self.dismiss_release_notes(),
+ KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
+ KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
+ KeyCode::PageUp => self.scroll_release_notes(-8),
+ KeyCode::PageDown => self.scroll_release_notes(8),
+ KeyCode::Home => {
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = 0;
+ }
+ }
+ KeyCode::End => {
+ let max_scroll = self.state.release_notes_max_scroll();
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = max_scroll;
+ }
+ }
+ _ => {}
+ }
+ }
+
fn handle_settings_key(&mut self, key: KeyEvent) {
if let Some(action) = update_settings_state(&mut self.state, key) {
match action {
@@ -118,6 +141,57 @@ impl App {
}
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
+ if self.state.mode == Mode::ReleaseNotes {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left)
+ if self
+ .state
+ .release_notes_close_button_at(mouse.column, mouse.row) =>
+ {
+ self.dismiss_release_notes();
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(target) = self
+ .state
+ .release_notes_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.state.drag = Some(DragState {
+ target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.state
+ .set_release_notes_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ }
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if let Some(DragState {
+ target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
+ }) = &self.state.drag
+ {
+ if let Some(offset_from_bottom) = self
+ .state
+ .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.state
+ .set_release_notes_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ self.state.drag = None;
+ }
+ MouseEventKind::ScrollUp => self.scroll_release_notes(-3),
+ MouseEventKind::ScrollDown => self.scroll_release_notes(3),
+ _ => {}
+ }
+ return;
+ }
+
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& self.state.on_sidebar_divider(mouse.column, mouse.row)
{
@@ -667,6 +741,108 @@ impl AppState {
Some(block.inner(popup))
}
+ fn release_notes_modal_inner(&self) -> Option<ratatui::layout::Rect> {
+ self.onboarding_modal_inner(76, 20)
+ }
+
+ fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool {
+ let Some(inner) = self.release_notes_modal_inner() else {
+ return false;
+ };
+ if inner.height < 4 || inner.width < 12 {
+ return false;
+ }
+ let button =
+ ratatui::layout::Rect::new(inner.x + inner.width.saturating_sub(11), inner.y, 9, 1);
+ col >= button.x
+ && col < button.x + button.width
+ && row >= button.y
+ && row < button.y + button.height
+ }
+
+ fn release_notes_body_rect(&self) -> Option<Rect> {
+ let inner = self.release_notes_modal_inner()?;
+ if inner.height < 8 || inner.width < 4 {
+ return None;
+ }
+ let rows = ratatui::layout::Layout::vertical([
+ ratatui::layout::Constraint::Length(1),
+ ratatui::layout::Constraint::Length(1),
+ ratatui::layout::Constraint::Length(1),
+ ratatui::layout::Constraint::Min(1),
+ ratatui::layout::Constraint::Length(1),
+ ])
+ .areas::<5>(inner);
+ Some(rows[3])
+ }
+
+ fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
+ let notes = self.release_notes.as_ref()?;
+ let body = self.release_notes_body_rect()?;
+ let viewport_rows = body.height.max(1) as usize;
+ let wrap_width = body.width.max(1) as usize;
+ let total_rows = notes
+ .body
+ .lines()
+ .map(|line| line.chars().count().max(1).div_ceil(wrap_width))
+ .sum::<usize>();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ Some(crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize),
+ max_offset_from_bottom,
+ viewport_rows,
+ })
+ }
+
+ pub(crate) fn release_notes_max_scroll(&self) -> u16 {
+ self.release_notes_scroll_metrics()
+ .map(|metrics| metrics.max_offset_from_bottom as u16)
+ .unwrap_or(0)
+ }
+
+ fn release_notes_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let body = self.release_notes_body_rect()?;
+ let metrics = self.release_notes_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 release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
+ let body = self.release_notes_body_rect()?;
+ let metrics = self.release_notes_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_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let max_scroll = self.release_notes_max_scroll() as usize;
+ if let Some(notes) = &mut self.release_notes {
+ notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
+ }
+ }
+
fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
if !matches!(
mouse.kind,
@@ -1060,6 +1236,7 @@ impl AppState {
self.sidebar_width_auto = false;
self.set_manual_sidebar_width(mouse.column);
}
+ DragTarget::ReleaseNotesScrollbar { .. } => {}
}
} else if let Some(sel) = &mut self.selection {
sel.drag(mouse.column, mouse.row);
@@ -1537,6 +1714,7 @@ mod tests {
&Config::default(),
true,
None,
+ None,
api_rx,
crate::api::EventHub::default(),
);
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 928ed2aa..7c670586 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -132,6 +132,7 @@ impl App {
config: &Config,
no_session: bool,
config_diagnostic: Option<String>,
+ startup_release_notes: Option<crate::release_notes::ReleaseNotes>,
api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
event_hub: crate::api::EventHub,
) -> Self {
@@ -167,6 +168,8 @@ impl App {
let mode = if config.should_show_onboarding() {
state::Mode::Onboarding
+ } else if startup_release_notes.is_some() {
+ state::Mode::ReleaseNotes
} else if active.is_some() {
state::Mode::Terminal
} else {
@@ -184,6 +187,12 @@ impl App {
name_input: String::new(),
onboarding_step: 0,
onboarding_selected: 1,
+ release_notes: startup_release_notes.map(|notes| state::ReleaseNotesState {
+ version: notes.version,
+ body: notes.body,
+ scroll: 0,
+ preview: notes.preview,
+ }),
view: state::ViewState {
sidebar_rect: Rect::default(),
terminal_area: Rect::default(),
@@ -1214,6 +1223,41 @@ impl App {
serde_json::to_string(&response).unwrap()
}
+ pub(crate) fn dismiss_release_notes(&mut self) {
+ let preview = self
+ .state
+ .release_notes
+ .as_ref()
+ .is_some_and(|notes| notes.preview);
+
+ self.state.release_notes = None;
+ if !preview {
+ if let Err(err) = crate::release_notes::clear_pending() {
+ self.state.config_diagnostic =
+ Some(format!("failed to clear release notes: {err}"));
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5));
+ }
+ }
+
+ self.state.mode = if self.state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+
+ pub(crate) fn scroll_release_notes(&mut self, delta: i16) {
+ let max_scroll = self.state.release_notes_max_scroll();
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = if delta.is_negative() {
+ notes.scroll.saturating_sub(delta.unsigned_abs())
+ } else {
+ notes.scroll.saturating_add(delta as u16)
+ }
+ .min(max_scroll);
+ }
+ }
+
pub(crate) fn complete_onboarding(&mut self) {
let (sound_enabled, toast_enabled) = match self.state.onboarding_selected {
0 => (false, false),
diff --git a/src/app/state.rs b/src/app/state.rs
index cd45bbb6..49c740ae 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -332,6 +332,7 @@ pub struct ViewState {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Onboarding,
+ ReleaseNotes,
Navigate,
Terminal,
RenameSession,
@@ -399,6 +400,9 @@ pub(crate) enum DragTarget {
pane_id: crate::layout::PaneId,
grab_row_offset: u16,
},
+ ReleaseNotesScrollbar {
+ grab_row_offset: u16,
+ },
SidebarDivider,
}
@@ -449,6 +453,13 @@ pub struct ToastNotification {
pub context: String,
}
+pub struct ReleaseNotesState {
+ pub version: String,
+ pub body: String,
+ pub scroll: u16,
+ pub preview: bool,
+}
+
/// All application state — pure data, no channels or async runtime.
/// Testable without PTYs or a tokio runtime.
pub struct AppState {
@@ -462,6 +473,7 @@ pub struct AppState {
pub name_input: String,
pub onboarding_step: usize,
pub onboarding_selected: usize,
+ pub release_notes: Option<ReleaseNotesState>,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
pub(crate) drag: Option<DragState>,
@@ -551,6 +563,7 @@ impl AppState {
name_input: String::new(),
onboarding_step: 0,
onboarding_selected: 1,
+ release_notes: None,
view: ViewState {
sidebar_rect: Rect::default(),
terminal_area: Rect::default(),
diff --git a/src/main.rs b/src/main.rs
index 60044981..981c4c70 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,6 +31,7 @@ mod persist;
mod platform;
mod pty_callbacks;
mod raw_input;
+mod release_notes;
mod selection;
mod sound;
mod ui;
@@ -197,6 +198,7 @@ fn main() -> io::Result<()> {
println!(" --no-session Don't restore or save sessions");
println!(" --default-config Print default configuration and exit");
println!(" --version, -V Print version and exit");
+ println!(" --show-changelog Preview the current version's release notes");
println!(" --help, -h Show this help");
println!();
println!("Config: ~/.config/herdr/config.toml");
@@ -221,6 +223,7 @@ fn main() -> io::Result<()> {
"--version",
"-V",
"--default-config",
+ "--show-changelog",
"--help",
"-h",
];
@@ -254,6 +257,7 @@ fn main() -> io::Result<()> {
let _api_server = api::start_server(api_tx, event_hub.clone())?;
let no_session = std::env::args().any(|a| a == "--no-session");
+ let show_changelog = std::env::args().any(|a| a == "--show-changelog");
let in_tmux = std::env::var("TMUX").is_ok();
let original_hook = std::panic::take_hook();
@@ -319,7 +323,20 @@ fn main() -> io::Result<()> {
std::io::stdout().flush()?;
}
- let mut app = app::App::new(config, no_session, config_diagnostic, api_rx, event_hub);
+ let startup_release_notes = if show_changelog {
+ crate::release_notes::load_preview_from_local_changelog(env!("CARGO_PKG_VERSION"))
+ } else {
+ crate::release_notes::load_pending_for_current_version()
+ };
+
+ let mut app = app::App::new(
+ config,
+ no_session,
+ config_diagnostic,
+ startup_release_notes,
+ api_rx,
+ event_hub,
+ );
let result = app.run(&mut terminal).await;
// Reset modifyOtherKeys if we enabled it
diff --git a/src/pane.rs b/src/pane.rs
index 44238a8a..01d872a6 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -416,52 +416,28 @@ impl PaneRuntime {
last_process_check = now;
let pid = child_pid.load(Ordering::Acquire);
if pid > 0 {
- match detect::foreground_job(pid) {
- Some(job) => {
- let identified = detect::identify_agent_in_job(&job);
- let new_agent = identified.as_ref().map(|(agent, _)| *agent);
-
- debug!(
- pane = pane_id.raw(),
- pid,
- previous_agent = ?agent,
- ?new_agent,
- identified_process = identified
- .as_ref()
- .map(|(_, name)| name.as_str()),
- pgid = job.process_group_id,
- processes = ?job.processes,
- "foreground job probe"
- );
-
- if new_agent != agent {
- if let Some((_, process_name)) = identified {
- info!(
- pane = pane_id.raw(),
- ?new_agent,
- process = %process_name,
- pgid = job.process_group_id,
- "agent changed"
- );
- } else {
- info!(
- pane = pane_id.raw(),
- ?new_agent,
- pgid = job.process_group_id,
- "agent changed"
- );
- }
- agent = new_agent;
- agent_changed = true;
+ if let Some(job) = detect::foreground_job(pid) {
+ let identified = detect::identify_agent_in_job(&job);
+ let new_agent = identified.as_ref().map(|(agent, _)| *agent);
+ if new_agent != agent {
+ if let Some((_, process_name)) = identified {
+ info!(
+ pane = pane_id.raw(),
+ ?new_agent,
+ process = %process_name,
+ pgid = job.process_group_id,
+ "agent changed"
+ );
+ } else {
+ info!(
+ pane = pane_id.raw(),
+ ?new_agent,
+ pgid = job.process_group_id,
+ "agent changed"
+ );
}
- }
- None => {
- debug!(
- pane = pane_id.raw(),
- pid,
- previous_agent = ?agent,
- "foreground job probe returned none"
- );
+ agent = new_agent;
+ agent_changed = true;
}
}
}
diff --git a/src/release_notes.rs b/src/release_notes.rs
new file mode 100644
index 00000000..fcee9a41
--- /dev/null
+++ b/src/release_notes.rs
@@ -0,0 +1,154 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+const PENDING_RELEASE_NOTES_PATH: &str = "release-notes.json";
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ReleaseNotes {
+ pub version: String,
+ pub body: String,
+ pub preview: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct StoredReleaseNotes {
+ version: String,
+ body: String,
+}
+
+pub fn pending_path() -> PathBuf {
+ let mut path = crate::config::config_path();
+ path.set_file_name(PENDING_RELEASE_NOTES_PATH);
+ path
+}
+
+pub fn save_pending(version: &str, body: &str) -> std::io::Result<()> {
+ save_pending_to_path(&pending_path(), version, body)
+}
+
+fn save_pending_to_path(path: &Path, version: &str, body: &str) -> std::io::Result<()> {
+ let body = normalize_body(body);
+ if body.is_empty() {
+ return clear_pending_at(path);
+ }
+
+ 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 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(())
+}
+
+pub fn load_pending_for_current_version() -> Option<ReleaseNotes> {
+ load_pending_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 {
+ return None;
+ }
+
+ let body = normalize_body(&stored.body);
+ if body.is_empty() {
+ return None;
+ }
+
+ Some(ReleaseNotes {
+ version: stored.version,
+ body,
+ preview: false,
+ })
+}
+
+pub fn clear_pending() -> std::io::Result<()> {
+ clear_pending_at(&pending_path())
+}
+
+fn clear_pending_at(path: &Path) -> std::io::Result<()> {
+ if path.exists() {
+ fs::remove_file(path)
+ } else {
+ Ok(())
+ }
+}
+
+pub fn load_preview_from_local_changelog(version: &str) -> Option<ReleaseNotes> {
+ let path = Path::new("CHANGELOG.md");
+ let content = fs::read_to_string(path).ok()?;
+ let body = extract_version_section(&content, version)?;
+ Some(ReleaseNotes {
+ version: version.to_string(),
+ body: normalize_body(&body),
+ preview: true,
+ })
+}
+
+fn extract_version_section(content: &str, version: &str) -> Option<String> {
+ let header = format!("## [{version}]");
+ let mut collecting = false;
+ let mut lines = Vec::new();
+
+ for line in content.lines() {
+ if !collecting {
+ if line.starts_with(&header) {
+ collecting = true;
+ }
+ continue;
+ }
+
+ if line.starts_with("## [") {
+ break;
+ }
+
+ lines.push(line);
+ }
+
+ let body = lines.join("\n").trim().to_string();
+ (!body.is_empty()).then_some(body)
+}
+
+pub 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::*;
+
+ #[test]
+ fn extracts_version_section() {
+ let changelog = "# Changelog\n\n## [0.2.3] - 2026-03-31\n\n### Changed\n- One\n\n## [0.2.2] - 2026-03-30\n\n### Fixed\n- Two\n";
+ assert_eq!(
+ extract_version_section(changelog, "0.2.3").as_deref(),
+ Some("### Changed\n- One")
+ );
+ }
+
+ #[test]
+ fn preserves_headings() {
+ assert_eq!(
+ normalize_body("### Changed\n- One\n\n### Fixed\n- Two"),
+ "### Changed\n- One\n\n### Fixed\n- Two"
+ );
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index b3f0152e..9e2db7c6 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,8 +1,8 @@
use ratatui::{
- layout::{Constraint, Layout, Rect},
+ layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs},
+ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
Frame,
};
use tui_term::widget::PseudoTerminal;
@@ -76,6 +76,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::Navigate => render_navigate_overlay(app, frame, terminal_area),
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
@@ -694,7 +695,19 @@ pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
info.scrollbar_rect
}
-fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
+pub(crate) fn release_notes_scrollbar_rect(
+ body: Rect,
+ metrics: crate::pane::ScrollMetrics,
+) -> Option<Rect> {
+ (should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new(
+ body.x + body.width - 1,
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
metrics.max_offset_from_bottom > 0
}
@@ -803,18 +816,14 @@ pub(crate) fn scrollbar_offset_from_drag_row(
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
-fn render_pane_scrollbar(
- app: &AppState,
+fn render_scrollbar(
frame: &mut Frame,
- info: &PaneInfo,
- rt: &crate::pane::PaneRuntime,
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ track_color: Color,
+ thumb_color: Color,
+ thumb_symbol: &str,
) {
- let Some(metrics) = rt.scroll_metrics() else {
- return;
- };
- let Some(track) = pane_scrollbar_rect(info) else {
- return;
- };
if metrics.max_offset_from_bottom == 0 {
return;
}
@@ -823,12 +832,6 @@ fn render_pane_scrollbar(
return;
};
- let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
- (app.palette.overlay0, app.palette.overlay1, "▐")
- } else {
- (app.palette.surface_dim, app.palette.overlay0, "▕")
- };
-
let buf = frame.buffer_mut();
for y in track.y..track.y + track.height {
let cell = &mut buf[(track.x, y)];
@@ -842,6 +845,35 @@ fn render_pane_scrollbar(
}
}
+fn render_pane_scrollbar(
+ app: &AppState,
+ frame: &mut Frame,
+ info: &PaneInfo,
+ rt: &crate::pane::PaneRuntime,
+) {
+ let Some(metrics) = rt.scroll_metrics() else {
+ return;
+ };
+ let Some(track) = pane_scrollbar_rect(info) else {
+ return;
+ };
+
+ let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
+ (app.palette.overlay0, app.palette.overlay1, "▐")
+ } else {
+ (app.palette.surface_dim, app.palette.overlay0, "▕")
+ };
+
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ track_color,
+ thumb_color,
+ thumb_symbol,
+ );
+}
+
fn render_selection_highlight(
selection: &Option<crate::selection::Selection>,
frame: &mut Frame,
@@ -973,6 +1005,133 @@ fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
}
}
+fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(notes) = &app.release_notes else {
+ return;
+ };
+
+ dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(frame, area, 76, 20, &app.palette) else {
+ return;
+ };
+ if inner.height < 8 || inner.width < 20 {
+ return;
+ }
+
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(1),
+ Constraint::Length(1),
+ ])
+ .areas::<5>(inner);
+
+ render_modal_header(frame, rows[0], &format!("v{}", notes.version), &app.palette);
+ frame.render_widget(
+ Paragraph::new(" what's new in this release")
+ .style(Style::default().fg(app.palette.overlay1)),
+ rows[1],
+ );
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ " [ close ] ",
+ Style::default()
+ .fg(app.palette.panel_bg)
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ))
+ .alignment(Alignment::Right),
+ rows[0],
+ );
+
+ let body_area = rows[3];
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
+ max_offset_from_bottom: app.release_notes_max_scroll() as usize,
+ viewport_rows: body_area.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(body_area, metrics);
+ let text_area = track
+ .map(|_| {
+ Rect::new(
+ body_area.x,
+ body_area.y,
+ body_area.width.saturating_sub(1),
+ body_area.height,
+ )
+ })
+ .unwrap_or(body_area);
+
+ let body = Paragraph::new(render_release_notes_lines(
+ notes.body.as_str(),
+ &app.palette,
+ ))
+ .wrap(Wrap { trim: false })
+ .scroll((notes.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(" q / esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ rows[4],
+ );
+}
+
+fn render_release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<Line<'a>> {
+ let mut lines = Vec::new();
+
+ for raw in body.lines() {
+ let trimmed = raw.trim_end();
+ if trimmed.is_empty() {
+ lines.push(Line::raw(""));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("### ") {
+ lines.push(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ rest.to_lowercase(),
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ ]));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("- ") {
+ lines.push(Line::from(vec![
+ Span::styled(" • ", Style::default().fg(p.accent)),
+ Span::styled(rest.to_string(), Style::default().fg(p.text)),
+ ]));
+ continue;
+ }
+
+ lines.push(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(trimmed.to_string(), Style::default().fg(p.text)),
+ ]));
+ }
+
+ lines
+}
+
fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else {
return;
diff --git a/src/update.rs b/src/update.rs
index 4250a007..d616ace8 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -58,6 +58,8 @@ impl std::fmt::Display for Version {
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
+ #[serde(default)]
+ body: String,
assets: Vec<GitHubAsset>,
}
@@ -75,6 +77,7 @@ struct GitHubAsset {
struct ReleaseInfo {
version: Version,
download_url: String,
+ notes_body: String,
}
/// Check GitHub for the latest release. Returns release info if newer.
@@ -124,6 +127,7 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> {
Ok(Some(ReleaseInfo {
version: latest,
download_url,
+ notes_body: release.body.trim().to_string(),
}))
}
@@ -205,6 +209,11 @@ pub fn self_update() -> Result<Version, String> {
};
eprintln!("downloading v{}...", release.version);
+ if let Err(e) =
+ crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
+ {
+ tracing::warn!("failed to save pending release notes: {e}");
+ }
download_and_install(&release)?;
eprintln!("updated to v{}", release.version);
@@ -240,6 +249,12 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
release.download_url
);
+ if let Err(e) =
+ crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
+ {
+ tracing::warn!("failed to save pending release notes: {e}");
+ }
+
if let Err(e) = download_and_install(&release) {
tracing::warn!("auto-update failed: {e}");
return;