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"));
const BUNDLED_INDEX: &[(&str, usize, usize)] =
include!(concat!(env!("OUT_DIR"), "/welcome-release-notes-index.rs"));
fn select_notes(
notes: &'static str,
index: &[(&str, usize, usize)],
unread: &release_notes::UnreadStart,
current: &str,
) -> Option<&'static str> {
let mut selected = index
.iter()
.filter(|(version, _, _)| unread.contains(version, current));
let (_, start, first_end) = selected.next()?;
let end = selected.next_back().map_or(*first_end, |(_, _, end)| *end);
Some(¬es[*start..end])
}
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 {
pub(super) fn start(paths: McPaths, eligible: bool) -> Self {
Self::start_with_index(
paths,
eligible,
env!("CARGO_PKG_VERSION"),
BUNDLED_NOTES,
BUNDLED_INDEX.to_vec(),
)
}
fn start_with_index(
paths: McPaths,
eligible: bool,
version: &'static str,
notes: &'static str,
index: Vec<(&'static str, usize, usize)>,
) -> Self {
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 unread = release_notes::record_launch(&paths, version)?;
let selected = eligible
.then(|| select_notes(notes, &index, &unread, version))
.flatten()
.filter(|notes| !notes.trim().is_empty());
if loaded_tx.send(selected).is_ok()
&& selected.is_some()
&& 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.modal_covers_transcript()
|| 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()
&& state.release_notes_offset.get() <= state.release_notes_viewed.get()
{
let end = state.release_notes_page_end.get();
state
.release_notes_viewed
.set(state.release_notes_viewed.get().max(end));
if state.release_notes_viewed.get() == 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.modal_covers_transcript()
&& 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))
} else {
offset.saturating_sub(usize::from(rows))
}
.min(maximum);
state.release_notes_offset.set(next);
next != offset
}