use crossbeam_channel::{Receiver, Sender, bounded};
use super::state::MissionControlState;
use crate::config::{McPaths, release_notes};
const BUNDLED_NOTES: &str = include_str!(concat!(env!("OUT_DIR"), "/welcome-release-notes.txt"));
pub(super) fn eligible_for_launch(
resumed: bool,
update_restart: bool,
prompt: Option<&str>,
) -> bool {
(!resumed || update_restart) && prompt.is_none()
}
pub(super) fn shows_restart_welcome(state: &MissionControlState) -> bool {
state.update_restart_welcome && state.release_notes.is_some() && !state.transcript_only_layout
}
#[derive(Default)]
pub(super) struct WelcomeReleaseNotes {
loaded: Option<Receiver<Option<&'static str>>>,
displayed: Option<Sender<()>>,
worker: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
}
impl WelcomeReleaseNotes {
#[cfg(test)]
pub(in crate::tui) fn with_test_worker(
worker: std::thread::JoinHandle<anyhow::Result<()>>,
) -> Self {
Self {
worker: Some(worker),
..Self::default()
}
}
pub(super) fn start(paths: McPaths, eligible: bool) -> Self {
Self::start_for_release(paths, eligible, env!("CARGO_PKG_VERSION"), BUNDLED_NOTES)
}
pub(in crate::tui) fn start_for_release(
paths: McPaths,
eligible: bool,
version: &'static str,
notes: &'static str,
) -> Self {
if !eligible || notes.trim().is_empty() {
return Self::default();
}
let (loaded_tx, loaded) = bounded(1);
let (displayed, displayed_rx) = bounded(1);
let worker = std::thread::Builder::new()
.name("welcome-release-notes".into())
.spawn(move || {
let unseen = release_notes::has_seen(&paths, version).is_ok_and(|seen| !seen);
if loaded_tx.send(unseen.then_some(notes)).is_ok()
&& unseen
&& displayed_rx.recv().is_ok()
{
release_notes::mark_seen(&paths, version)?;
}
Ok(())
});
let Ok(worker) = worker else {
return Self::default();
};
Self {
loaded: Some(loaded),
displayed: Some(displayed),
worker: Some(worker),
}
}
pub(super) fn dismiss_restart_welcome(&mut self, state: &mut MissionControlState) {
if !state.update_restart_welcome {
return;
}
if state.release_notes_displayed.get()
&& let Some(sender) = self.displayed.take()
{
let _ = sender.try_send(());
}
self.loaded = None;
self.displayed = None;
state.update_restart_welcome = false;
state.release_notes = None;
state.release_notes_drawn.set(false);
state.release_notes_area.set(Default::default());
}
pub(super) fn dismiss_key(
&mut self,
key: crossterm::event::KeyEvent,
state: &mut MissionControlState,
) -> bool {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
if key.kind == KeyEventKind::Release
|| key.code != KeyCode::Esc
|| key.modifiers != KeyModifiers::NONE
|| !shows_restart_welcome(state)
|| state.right_column_tab == super::state::RightColumnTab::Diff
|| state.conversation_modal.is_some()
|| state.top_modal().is_some()
|| state.autocomplete.is_some()
|| state.toast.is_some()
{
return false;
}
self.dismiss_restart_welcome(state);
true
}
pub(super) fn poll(&mut self, state: &mut MissionControlState) -> bool {
if state.release_notes_displayed.get()
&& let Some(sender) = self.displayed.take()
{
let _ = sender.try_send(());
}
let Some(receiver) = &self.loaded else {
return false;
};
match receiver.try_recv() {
Ok(notes) => {
self.loaded = None;
if notes.is_some() && (state.transcript.is_empty() || state.update_restart_welcome)
{
state.release_notes = notes;
return true;
}
self.displayed = None;
state.update_restart_welcome = false;
}
Err(crossbeam_channel::TryRecvError::Disconnected) => {
self.loaded = None;
self.displayed = None;
}
Err(crossbeam_channel::TryRecvError::Empty) => {}
}
false
}
pub(super) fn cleanup(&mut self) -> Vec<String> {
self.displayed = None;
self.loaded = None;
match self.worker.take().map(|worker| worker.join()) {
Some(Ok(Err(error))) => vec![format!("release notes persistence failed: {error}")],
Some(Err(_)) => vec!["release notes worker panicked".into()],
_ => Vec::new(),
}
}
}
pub(super) fn confirm_draw(state: &MissionControlState) {
if state.release_notes_drawn.get() {
let end = state.release_notes_page_end.get();
state
.release_notes_viewed
.set(state.release_notes_viewed.get().max(end));
if end == state.release_notes_total.get() {
state.release_notes_displayed.set(true);
}
}
}
fn accepts_scroll(state: &MissionControlState) -> bool {
state.release_notes.is_some()
&& (state.transcript.is_empty() || shows_restart_welcome(state))
&& !state.transcript_only_layout
&& state.right_column_tab != super::state::RightColumnTab::Diff
&& state.conversation_modal.is_none()
&& state.top_modal().is_none()
&& state.autocomplete.is_none()
&& state.toast.is_none()
&& state.release_notes_viewport.get().1 > 0
}
pub(super) fn scroll_key(key: crossterm::event::KeyEvent, state: &MissionControlState) -> bool {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
if key.kind == KeyEventKind::Release
|| key.modifiers != KeyModifiers::ALT
|| !accepts_scroll(state)
{
return false;
}
let down = match key.code {
KeyCode::Down => true,
KeyCode::Up => false,
_ => return false,
};
scroll_rows(state, down, 1);
true
}
pub(super) fn mouse_scroll_hit(
mouse: crossterm::event::MouseEvent,
state: &MissionControlState,
) -> bool {
use crossterm::event::MouseEventKind;
accepts_scroll(state)
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
&& state
.release_notes_area
.get()
.contains((mouse.column, mouse.row).into())
}
pub(super) fn scroll_rows(state: &MissionControlState, down: bool, rows: u16) -> bool {
if !accepts_scroll(state) {
return false;
}
let offset = state.release_notes_offset.get();
let height = usize::from(state.release_notes_viewport.get().1);
let maximum = state.release_notes_total.get().saturating_sub(height);
let next = if down {
offset
.saturating_add(usize::from(rows))
.min(state.release_notes_viewed.get())
} else {
offset.saturating_sub(usize::from(rows))
}
.min(maximum);
state.release_notes_offset.set(next);
next != offset
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_notes_launch_eligibility_preserves_resume_and_prompt_behavior() {
assert!(eligible_for_launch(false, false, None));
assert!(!eligible_for_launch(true, false, None));
assert!(eligible_for_launch(true, true, None));
assert!(eligible_for_launch(false, true, None));
for resumed in [false, true] {
for restart in [false, true] {
assert!(!eligible_for_launch(resumed, restart, Some("hello")));
}
}
}
#[test]
fn release_notes_scroll_keys_move_one_row_without_skipping_unconfirmed_rows() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let state = MissionControlState {
release_notes: Some("notes"),
..Default::default()
};
state.release_notes_viewport.set((40, 3));
state.release_notes_total.set(10);
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::ALT);
assert!(scroll_key(down, &state));
assert_eq!(state.release_notes_offset.get(), 0);
state.release_notes_viewed.set(3);
assert!(scroll_key(down, &state));
assert_eq!(state.release_notes_offset.get(), 1);
for _ in 0..20 {
scroll_key(down, &state);
}
assert_eq!(state.release_notes_offset.get(), 3);
state.release_notes_viewed.set(10);
scroll_rows(&state, true, u16::MAX);
assert_eq!(state.release_notes_offset.get(), 7);
assert!(scroll_key(
KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
&state
));
assert_eq!(state.release_notes_offset.get(), 6);
for code in [KeyCode::PageUp, KeyCode::PageDown, KeyCode::Down] {
assert!(!scroll_key(KeyEvent::new(code, KeyModifiers::NONE), &state));
}
for code in [KeyCode::PageUp, KeyCode::PageDown] {
assert!(!scroll_key(KeyEvent::new(code, KeyModifiers::ALT), &state));
}
}
fn wait_until(mut ready: impl FnMut() -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while !ready() {
assert!(
std::time::Instant::now() < deadline,
"release notes worker timed out"
);
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
#[test]
fn release_notes_restart_preserves_populated_transcript_and_partial_dismissal() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{Terminal, backend::TestBackend};
let temp = tempfile::tempdir().unwrap();
let paths = McPaths::from_root(temp.path().to_owned());
let mut state = MissionControlState {
update_restart_welcome: true,
..Default::default()
};
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "Restored question".into(),
});
let history: Vec<_> = state.transcript.iter().cloned().collect();
let mut runtime = WelcomeReleaseNotes::start_for_release(
paths.clone(),
true,
"1.2.3",
"Approved fixture\nline two\nline three\nline four\nline five\nline six\nline seven\nline eight\nline nine\nline ten",
);
wait_until(|| runtime.poll(&mut state));
let mut terminal = Terminal::new(TestBackend::new(80, 18)).unwrap();
let screen = |terminal: &Terminal<TestBackend>| {
terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
};
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
assert!(screen(&terminal).contains("What's new"));
assert!(!screen(&terminal).contains("Restored question"));
confirm_draw(&state);
assert!(!state.release_notes_displayed.get());
assert!(screen(&terminal).contains("Esc: conversation"));
assert!(scroll_key(
KeyEvent::new(KeyCode::Down, KeyModifiers::ALT),
&state
));
assert_eq!(state.release_notes_offset.get(), 1);
assert!(runtime.dismiss_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut state));
assert!(!shows_restart_welcome(&state));
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
assert!(screen(&terminal).contains("Restored question"));
assert_eq!(
state.transcript.iter().cloned().collect::<Vec<_>>(),
history
);
assert!(runtime.cleanup().is_empty());
assert!(!release_notes::has_seen(&paths, "1.2.3").unwrap());
}
#[test]
fn release_notes_restart_dismissal_cancels_pending_load_and_preserves_full_draw_ack() {
for fully_drawn in [false, true] {
let temp = tempfile::tempdir().unwrap();
let paths = McPaths::from_root(temp.path().to_owned());
let mut state = MissionControlState {
update_restart_welcome: true,
..Default::default()
};
let mut runtime =
WelcomeReleaseNotes::start_for_release(paths.clone(), true, "1.2.3", "Fixture");
if fully_drawn {
wait_until(|| runtime.poll(&mut state));
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 60)).unwrap();
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
confirm_draw(&state);
assert!(state.release_notes_displayed.get());
}
runtime.dismiss_restart_welcome(&mut state);
runtime.poll(&mut state);
assert!(state.release_notes.is_none());
assert!(runtime.cleanup().is_empty());
assert_eq!(
release_notes::has_seen(&paths, "1.2.3").unwrap(),
fully_drawn
);
}
}
#[test]
fn release_notes_worker_persists_only_displayed_version_and_skips_next_launch() {
let temp = tempfile::tempdir().unwrap();
let paths = McPaths::from_root(temp.path().to_owned());
let mut state = MissionControlState::default();
let mut runtime = WelcomeReleaseNotes::start_for_release(
paths.clone(),
true,
"1.2.3",
"Approved fixture",
);
wait_until(|| runtime.poll(&mut state));
assert_eq!(state.release_notes, Some("Approved fixture"));
assert!(!release_notes::has_seen(&paths, "1.2.3").unwrap());
state.release_notes_displayed.set(true);
runtime.poll(&mut state);
assert!(runtime.cleanup().is_empty());
assert!(release_notes::has_seen(&paths, "1.2.3").unwrap());
assert!(!release_notes::has_seen(&paths, "1.2.3-beta.1").unwrap());
let mut next =
WelcomeReleaseNotes::start_for_release(paths, true, "1.2.3", "Approved fixture");
let mut state = MissionControlState::default();
wait_until(|| {
next.poll(&mut state);
next.loaded.is_none()
});
assert!(state.release_notes.is_none());
}
#[test]
fn release_notes_restart_seen_or_missing_keeps_conversation_visible() {
for notes in ["", "Fixture"] {
let temp = tempfile::tempdir().unwrap();
let paths = McPaths::from_root(temp.path().to_owned());
release_notes::mark_seen(&paths, "1.2.3").unwrap();
let mut state = MissionControlState {
update_restart_welcome: true,
..Default::default()
};
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "Restored question".into(),
});
let mut runtime = WelcomeReleaseNotes::start_for_release(paths, true, "1.2.3", notes);
wait_until(|| {
runtime.poll(&mut state);
runtime.loaded.is_none()
});
assert!(!shows_restart_welcome(&state));
assert_eq!(state.transcript.len(), 1);
assert!(runtime.cleanup().is_empty());
}
}
#[test]
fn release_notes_without_approved_content_do_not_touch_storage() {
let temp = tempfile::tempdir().unwrap();
let paths = McPaths::from_root(temp.path().to_owned());
let runtime = WelcomeReleaseNotes::start_for_release(paths.clone(), true, "1.2.3", "");
assert!(runtime.loaded.is_none());
assert!(!paths.settings_file.exists());
}
#[test]
fn release_notes_acknowledgement_requires_display_and_retains_notes() {
let (sender, receiver) = bounded(1);
let mut runtime = WelcomeReleaseNotes {
loaded: None,
displayed: Some(sender),
worker: None,
};
let mut state = MissionControlState {
release_notes: Some("Approved notes"),
..Default::default()
};
runtime.poll(&mut state);
assert!(receiver.try_recv().is_err());
state.release_notes_drawn.set(true);
runtime.poll(&mut state);
assert!(
receiver.try_recv().is_err(),
"render projection is not a successful terminal draw"
);
state.release_notes_displayed.set(true);
runtime.poll(&mut state);
assert_eq!(receiver.try_recv(), Ok(()));
assert_eq!(state.release_notes, Some("Approved notes"));
}
#[test]
fn release_notes_skipped_launch_does_not_start_storage_work() {
let temp = tempfile::tempdir().unwrap();
let runtime = WelcomeReleaseNotes::start(McPaths::from_root(temp.path().to_owned()), false);
assert!(runtime.loaded.is_none());
assert!(runtime.displayed.is_none());
assert!(!temp.path().join("settings.json").exists());
}
}