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"));
#[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 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.release_notes = notes;
return true;
}
self.displayed = None;
}
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()
&& !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_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_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_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());
}
}