Fetch older emails on scroll with lazy loading
Introduce Inbox struct to track the oldest fetched sequence number. When scrolling past the last email, automatically fetch the next batch of 50 older messages and append them to the list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fd1b087f76
commit
df112755ec
2 changed files with 101 additions and 38 deletions
112
src/inbox.rs
112
src/inbox.rs
|
|
@ -3,61 +3,121 @@ use crate::config::Config;
|
|||
use crate::connect::ImapSession;
|
||||
use crate::{connect, Email};
|
||||
|
||||
const MAX_FETCH: u32 = 50;
|
||||
const BATCH_SIZE: u32 = 50;
|
||||
|
||||
/// Refresh inbox using NOOP + fetch. Reconnects on error.
|
||||
pub(crate) struct Inbox {
|
||||
pub emails: Vec<Email>,
|
||||
/// The lowest IMAP sequence number we've fetched so far (1-based).
|
||||
/// None means we haven't fetched yet or inbox was empty.
|
||||
oldest_seq: Option<u32>,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
emails: Vec::new(),
|
||||
oldest_seq: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_older(&self) -> bool {
|
||||
self.oldest_seq.map_or(false, |s| s > 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh inbox (full reload). Reconnects on error.
|
||||
pub(crate) fn refresh(
|
||||
session: &mut Option<ImapSession>,
|
||||
config: &Config,
|
||||
) -> Result<Vec<Email>, String> {
|
||||
) -> Result<Inbox, String> {
|
||||
// If we have a session, try NOOP to keep alive / detect changes
|
||||
if let Some(s) = session.as_mut() {
|
||||
if s.noop().is_ok() {
|
||||
return fetch_inbox(s);
|
||||
return fetch_latest(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Session is dead or missing — reconnect
|
||||
*session = None;
|
||||
let mut new_session = connect::connect(config)?;
|
||||
let result = fetch_inbox(&mut new_session);
|
||||
let result = fetch_latest(&mut new_session);
|
||||
*session = Some(new_session);
|
||||
result
|
||||
}
|
||||
|
||||
/// Fetch older emails and append them to the existing inbox.
|
||||
pub(crate) fn fetch_more(
|
||||
session: &mut Option<ImapSession>,
|
||||
inbox: &mut Inbox,
|
||||
config: &Config,
|
||||
) -> Result<(), String> {
|
||||
let oldest = match inbox.oldest_seq {
|
||||
Some(s) if s > 1 => s,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
fn fetch_range(exists: u32) -> String {
|
||||
let start = exists.saturating_sub(MAX_FETCH - 1).max(1);
|
||||
format!("{}:{}", start, exists)
|
||||
let ensure_session = |session: &mut Option<ImapSession>| -> Result<(), String> {
|
||||
if session.as_mut().map_or(true, |s| s.noop().is_err()) {
|
||||
*session = None;
|
||||
*session = Some(connect::connect(config)?);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
ensure_session(session)?;
|
||||
|
||||
let s = session.as_mut().unwrap();
|
||||
let end = oldest - 1;
|
||||
let start = end.saturating_sub(BATCH_SIZE - 1).max(1);
|
||||
let range = format!("{}:{}", start, end);
|
||||
|
||||
let mut older = fetch_range_emails(s, &range)?;
|
||||
older.reverse();
|
||||
inbox.oldest_seq = Some(start);
|
||||
inbox.emails.append(&mut older);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_inbox(session: &mut ImapSession) -> Result<Vec<Email>, String> {
|
||||
fn fetch_latest(session: &mut ImapSession) -> Result<Inbox, String> {
|
||||
let exists = select_inbox(session)?;
|
||||
if exists == 0 {
|
||||
return Ok(Inbox::new());
|
||||
}
|
||||
let start = exists.saturating_sub(BATCH_SIZE - 1).max(1);
|
||||
let range = format!("{}:{}", start, exists);
|
||||
let mut emails = fetch_range_emails(session, &range)?;
|
||||
emails.reverse();
|
||||
Ok(Inbox {
|
||||
emails,
|
||||
oldest_seq: Some(start),
|
||||
})
|
||||
}
|
||||
|
||||
fn select_inbox(session: &mut ImapSession) -> Result<u32, String> {
|
||||
match session {
|
||||
ImapSession::Plain(s) => {
|
||||
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
|
||||
if mailbox.exists == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let range = fetch_range(mailbox.exists);
|
||||
let messages = s
|
||||
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut emails = parse_emails(&messages);
|
||||
emails.reverse();
|
||||
Ok(emails)
|
||||
Ok(mailbox.exists)
|
||||
}
|
||||
ImapSession::Tls(s) => {
|
||||
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
|
||||
if mailbox.exists == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let range = fetch_range(mailbox.exists);
|
||||
Ok(mailbox.exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result<Vec<Email>, String> {
|
||||
match session {
|
||||
ImapSession::Plain(s) => {
|
||||
let messages = s
|
||||
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut emails = parse_emails(&messages);
|
||||
emails.reverse();
|
||||
Ok(emails)
|
||||
Ok(parse_emails(&messages))
|
||||
}
|
||||
ImapSession::Tls(s) => {
|
||||
let messages = s
|
||||
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(parse_emails(&messages))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
src/lib.rs
27
src/lib.rs
|
|
@ -26,8 +26,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
let mut inbox = inbox::refresh(&mut session, config);
|
||||
let mut last_fetch = Instant::now();
|
||||
let mut list_state = ListState::default();
|
||||
if let Ok(emails) = &inbox {
|
||||
if !emails.is_empty() {
|
||||
if let Ok(ib) = &inbox {
|
||||
if !ib.emails.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
|
@ -47,8 +47,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
.split(area);
|
||||
|
||||
match &inbox {
|
||||
Ok(emails) if !emails.is_empty() => {
|
||||
let items: Vec<ListItem> = emails
|
||||
Ok(ib) if !ib.emails.is_empty() => {
|
||||
let items: Vec<ListItem> = ib.emails
|
||||
.iter()
|
||||
.map(|e| {
|
||||
ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject))
|
||||
|
|
@ -58,7 +58,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(format!("Inbox ({} messages)", emails.len()))
|
||||
.title(format!("Inbox ({} messages)", ib.emails.len()))
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.style(Style::default().fg(Color::White))
|
||||
|
|
@ -98,8 +98,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
KeyCode::Char('r') => {
|
||||
inbox = inbox::refresh(&mut session, config);
|
||||
last_fetch = Instant::now();
|
||||
if let Ok(emails) = &inbox {
|
||||
if !emails.is_empty() {
|
||||
if let Ok(ib) = &inbox {
|
||||
if !ib.emails.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
} else {
|
||||
list_state.select(None);
|
||||
|
|
@ -107,11 +107,14 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if let Ok(emails) = &inbox {
|
||||
let i = list_state.selected().map_or(0, |i| {
|
||||
if i + 1 < emails.len() { i + 1 } else { i }
|
||||
});
|
||||
list_state.select(Some(i));
|
||||
if let Ok(ib) = &mut inbox {
|
||||
let len = ib.emails.len();
|
||||
let i = list_state.selected().map_or(0, |i| i + 1);
|
||||
if i >= len && ib.has_older() {
|
||||
let _ = inbox::fetch_more(&mut session, ib, config);
|
||||
}
|
||||
let new_len = ib.emails.len();
|
||||
list_state.select(Some(i.min(new_len.saturating_sub(1))));
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue