diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 178 | ||||
| -rw-r--r-- | src/app/mod.rs | 44 | ||||
| -rw-r--r-- | src/app/state.rs | 13 | ||||
| -rw-r--r-- | src/main.rs | 19 | ||||
| -rw-r--r-- | src/pane.rs | 66 | ||||
| -rw-r--r-- | src/release_notes.rs | 154 | ||||
| -rw-r--r-- | src/ui.rs | 197 | ||||
| -rw-r--r-- | src/update.rs | 15 |
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" + ); + } +} @@ -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; |
