Move IMAP operations to background thread for responsive UI
Spawn a worker thread that owns the IMAP session and processes fetch commands via channels. The UI thread polls for results non-blockingly, keeping the app responsive during network operations. Shows loading indicator while fetching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2f23ea7686
commit
df9f9f0b2f
2 changed files with 177 additions and 107 deletions
48
src/inbox.rs
48
src/inbox.rs
|
|
@ -7,19 +7,10 @@ const BATCH_SIZE: u32 = 50;
|
||||||
|
|
||||||
pub(crate) struct Inbox {
|
pub(crate) struct Inbox {
|
||||||
pub emails: Vec<Email>,
|
pub emails: Vec<Email>,
|
||||||
/// The lowest IMAP sequence number we've fetched so far (1-based).
|
pub oldest_seq: Option<u32>,
|
||||||
/// None means we haven't fetched yet or inbox was empty.
|
|
||||||
oldest_seq: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Inbox {
|
impl Inbox {
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
emails: Vec::new(),
|
|
||||||
oldest_seq: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_older(&self) -> bool {
|
pub fn has_older(&self) -> bool {
|
||||||
self.oldest_seq.map_or(false, |s| s > 1)
|
self.oldest_seq.map_or(false, |s| s > 1)
|
||||||
}
|
}
|
||||||
|
|
@ -30,14 +21,12 @@ pub(crate) fn refresh(
|
||||||
session: &mut Option<ImapSession>,
|
session: &mut Option<ImapSession>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) -> Result<Inbox, String> {
|
) -> Result<Inbox, String> {
|
||||||
// If we have a session, try NOOP to keep alive / detect changes
|
|
||||||
if let Some(s) = session.as_mut() {
|
if let Some(s) = session.as_mut() {
|
||||||
if s.noop().is_ok() {
|
if s.noop().is_ok() {
|
||||||
return fetch_latest(s);
|
return fetch_latest(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is dead or missing — reconnect
|
|
||||||
*session = None;
|
*session = None;
|
||||||
let mut new_session = connect::connect(config)?;
|
let mut new_session = connect::connect(config)?;
|
||||||
let result = fetch_latest(&mut new_session);
|
let result = fetch_latest(&mut new_session);
|
||||||
|
|
@ -45,42 +34,39 @@ pub(crate) fn refresh(
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch older emails and append them to the existing inbox.
|
/// Fetch a batch of older emails. Returns (emails, new_oldest_seq).
|
||||||
pub(crate) fn fetch_more(
|
pub(crate) fn fetch_older_batch(
|
||||||
session: &mut Option<ImapSession>,
|
session: &mut Option<ImapSession>,
|
||||||
inbox: &mut Inbox,
|
oldest_seq: u32,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) -> Result<(), String> {
|
) -> Result<(Vec<Email>, u32), String> {
|
||||||
let oldest = match inbox.oldest_seq {
|
if oldest_seq <= 1 {
|
||||||
Some(s) if s > 1 => s,
|
return Ok((Vec::new(), 1));
|
||||||
_ => return Ok(()),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let ensure_session = |session: &mut Option<ImapSession>| -> Result<(), String> {
|
// Ensure session is alive
|
||||||
if session.as_mut().map_or(true, |s| s.noop().is_err()) {
|
if session.as_mut().map_or(true, |s| s.noop().is_err()) {
|
||||||
*session = None;
|
*session = None;
|
||||||
*session = Some(connect::connect(config)?);
|
*session = Some(connect::connect(config)?);
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
};
|
|
||||||
ensure_session(session)?;
|
|
||||||
|
|
||||||
let s = session.as_mut().unwrap();
|
let s = session.as_mut().unwrap();
|
||||||
let end = oldest - 1;
|
let end = oldest_seq - 1;
|
||||||
let start = end.saturating_sub(BATCH_SIZE - 1).max(1);
|
let start = end.saturating_sub(BATCH_SIZE - 1).max(1);
|
||||||
let range = format!("{}:{}", start, end);
|
let range = format!("{}:{}", start, end);
|
||||||
|
|
||||||
let mut older = fetch_range_emails(s, &range)?;
|
let mut emails = fetch_range_emails(s, &range)?;
|
||||||
older.reverse();
|
emails.reverse();
|
||||||
inbox.oldest_seq = Some(start);
|
Ok((emails, start))
|
||||||
inbox.emails.append(&mut older);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_latest(session: &mut ImapSession) -> Result<Inbox, String> {
|
fn fetch_latest(session: &mut ImapSession) -> Result<Inbox, String> {
|
||||||
let exists = select_inbox(session)?;
|
let exists = select_inbox(session)?;
|
||||||
if exists == 0 {
|
if exists == 0 {
|
||||||
return Ok(Inbox::new());
|
return Ok(Inbox {
|
||||||
|
emails: Vec::new(),
|
||||||
|
oldest_seq: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let start = exists.saturating_sub(BATCH_SIZE - 1).max(1);
|
let start = exists.saturating_sub(BATCH_SIZE - 1).max(1);
|
||||||
let range = format!("{}:{}", start, exists);
|
let range = format!("{}:{}", start, exists);
|
||||||
|
|
|
||||||
184
src/lib.rs
184
src/lib.rs
|
|
@ -1,4 +1,6 @@
|
||||||
use std::io::{Error, Stdout};
|
use std::io::{Error, Stdout};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use crossterm::event;
|
use crossterm::event;
|
||||||
use crossterm::event::{Event, KeyCode};
|
use crossterm::event::{Event, KeyCode};
|
||||||
|
|
@ -15,27 +17,107 @@ mod inbox;
|
||||||
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
struct Email {
|
pub(crate) struct Email {
|
||||||
subject: String,
|
pub subject: String,
|
||||||
from: String,
|
pub from: String,
|
||||||
date: String,
|
pub date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkerCmd {
|
||||||
|
Refresh,
|
||||||
|
FetchMore { oldest_seq: u32 },
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkerResult {
|
||||||
|
Refreshed(Result<inbox::Inbox, String>),
|
||||||
|
FetchedMore(Result<(Vec<Email>, u32), String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_loop(
|
||||||
|
config: Config,
|
||||||
|
cmd_rx: mpsc::Receiver<WorkerCmd>,
|
||||||
|
result_tx: mpsc::Sender<WorkerResult>,
|
||||||
|
) {
|
||||||
|
let mut session = connect::connect(&config).ok();
|
||||||
|
|
||||||
|
for cmd in &cmd_rx {
|
||||||
|
match cmd {
|
||||||
|
WorkerCmd::Refresh => {
|
||||||
|
let result = inbox::refresh(&mut session, &config);
|
||||||
|
let _ = result_tx.send(WorkerResult::Refreshed(result));
|
||||||
|
}
|
||||||
|
WorkerCmd::FetchMore { oldest_seq } => {
|
||||||
|
let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config);
|
||||||
|
let _ = result_tx.send(WorkerResult::FetchedMore(result));
|
||||||
|
}
|
||||||
|
WorkerCmd::Quit => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut s) = session.take() {
|
||||||
|
let _ = s.logout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> {
|
pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> {
|
||||||
let mut session = connect::connect(config).ok();
|
let (cmd_tx, cmd_rx) = mpsc::channel();
|
||||||
let mut inbox = inbox::refresh(&mut session, config);
|
let (result_tx, result_rx) = mpsc::channel();
|
||||||
let mut last_fetch = Instant::now();
|
|
||||||
|
let worker_config = config.clone();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
worker_loop(worker_config, cmd_rx, result_tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial refresh
|
||||||
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
||||||
|
|
||||||
|
let mut emails: Vec<Email> = Vec::new();
|
||||||
|
let mut oldest_seq: Option<u32> = None;
|
||||||
|
let mut has_older = false;
|
||||||
|
let mut error: Option<String> = None;
|
||||||
|
let mut loading = true;
|
||||||
let mut list_state = ListState::default();
|
let mut list_state = ListState::default();
|
||||||
if let Ok(ib) = &inbox {
|
let mut last_fetch = Instant::now();
|
||||||
if !ib.emails.is_empty() {
|
|
||||||
list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main loop ---
|
// --- Main loop ---
|
||||||
loop {
|
loop {
|
||||||
if last_fetch.elapsed() >= POLL_INTERVAL {
|
// Check for worker results (non-blocking)
|
||||||
inbox = inbox::refresh(&mut session, config);
|
while let Ok(result) = result_rx.try_recv() {
|
||||||
|
match result {
|
||||||
|
WorkerResult::Refreshed(Ok(inbox)) => {
|
||||||
|
has_older = inbox.has_older();
|
||||||
|
oldest_seq = inbox.oldest_seq;
|
||||||
|
emails = inbox.emails;
|
||||||
|
error = None;
|
||||||
|
loading = false;
|
||||||
|
if !emails.is_empty() {
|
||||||
|
list_state.select(Some(0));
|
||||||
|
} else {
|
||||||
|
list_state.select(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkerResult::Refreshed(Err(e)) => {
|
||||||
|
error = Some(e);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
WorkerResult::FetchedMore(Ok((mut new_emails, new_oldest))) => {
|
||||||
|
has_older = new_oldest > 1;
|
||||||
|
oldest_seq = Some(new_oldest);
|
||||||
|
emails.append(&mut new_emails);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
WorkerResult::FetchedMore(Err(e)) => {
|
||||||
|
error = Some(e);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic refresh
|
||||||
|
if !loading && last_fetch.elapsed() >= POLL_INTERVAL {
|
||||||
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
||||||
|
loading = true;
|
||||||
last_fetch = Instant::now();
|
last_fetch = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,19 +128,39 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
.constraints([Constraint::Min(3), Constraint::Length(1)])
|
.constraints([Constraint::Min(3), Constraint::Length(1)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
match &inbox {
|
if let Some(e) = &error {
|
||||||
Ok(ib) if !ib.emails.is_empty() => {
|
let p = Paragraph::new(format!("IMAP error: {}", e))
|
||||||
let items: Vec<ListItem> = ib.emails
|
.block(Block::default().title("Inbox").borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::Red));
|
||||||
|
frame.render_widget(p, layout[0]);
|
||||||
|
} else if emails.is_empty() && loading {
|
||||||
|
let p = Paragraph::new("Loading...")
|
||||||
|
.block(Block::default().title("Inbox").borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
frame.render_widget(p, layout[0]);
|
||||||
|
} else if emails.is_empty() {
|
||||||
|
let p = Paragraph::new("No messages in inbox.")
|
||||||
|
.block(Block::default().title("Inbox").borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
frame.render_widget(p, layout[0]);
|
||||||
|
} else {
|
||||||
|
let items: Vec<ListItem> = emails
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject))
|
ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let title = if loading {
|
||||||
|
format!("Inbox ({} messages) [loading...]", emails.len())
|
||||||
|
} else {
|
||||||
|
format!("Inbox ({} messages)", emails.len())
|
||||||
|
};
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.title(format!("Inbox ({} messages)", ib.emails.len()))
|
.title(title)
|
||||||
.borders(Borders::ALL),
|
.borders(Borders::ALL),
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::White))
|
.style(Style::default().fg(Color::White))
|
||||||
|
|
@ -71,19 +173,6 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
|
|
||||||
frame.render_stateful_widget(list, layout[0], &mut list_state);
|
frame.render_stateful_widget(list, layout[0], &mut list_state);
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
|
||||||
let p = Paragraph::new("No messages in inbox.")
|
|
||||||
.block(Block::default().title("Inbox").borders(Borders::ALL))
|
|
||||||
.style(Style::default().fg(Color::Yellow));
|
|
||||||
frame.render_widget(p, layout[0]);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let p = Paragraph::new(format!("IMAP error: {}", e))
|
|
||||||
.block(Block::default().title("Inbox").borders(Borders::ALL))
|
|
||||||
.style(Style::default().fg(Color::Red));
|
|
||||||
frame.render_widget(p, layout[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate")
|
let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate")
|
||||||
.style(Style::default().fg(Color::DarkGray));
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
@ -96,43 +185,38 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
inbox = inbox::refresh(&mut session, config);
|
if !loading {
|
||||||
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
||||||
|
loading = true;
|
||||||
last_fetch = Instant::now();
|
last_fetch = Instant::now();
|
||||||
if let Ok(ib) = &inbox {
|
|
||||||
if !ib.emails.is_empty() {
|
|
||||||
list_state.select(Some(0));
|
|
||||||
} else {
|
|
||||||
list_state.select(None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
if let Ok(ib) = &mut inbox {
|
let len = emails.len();
|
||||||
let len = ib.emails.len();
|
if len > 0 {
|
||||||
let i = list_state.selected().map_or(0, |i| i + 1);
|
let i = list_state.selected().map_or(0, |i| i + 1);
|
||||||
if i >= len && ib.has_older() {
|
if i >= len && has_older && !loading {
|
||||||
let _ = inbox::fetch_more(&mut session, ib, config);
|
if let Some(seq) = oldest_seq {
|
||||||
|
let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq });
|
||||||
|
loading = true;
|
||||||
}
|
}
|
||||||
let new_len = ib.emails.len();
|
}
|
||||||
list_state.select(Some(i.min(new_len.saturating_sub(1))));
|
list_state.select(Some(i.min(len.saturating_sub(1))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
if let Ok(_) = &inbox {
|
|
||||||
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
||||||
list_state.select(Some(i));
|
list_state.select(Some(i));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the session
|
// Clean up worker
|
||||||
if let Some(mut s) = session.take() {
|
let _ = cmd_tx.send(WorkerCmd::Quit);
|
||||||
let _ = s.logout();
|
let _ = worker.join();
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue