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
110
src/inbox.rs
110
src/inbox.rs
|
|
@ -3,61 +3,121 @@ use crate::config::Config;
|
||||||
use crate::connect::ImapSession;
|
use crate::connect::ImapSession;
|
||||||
use crate::{connect, Email};
|
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(
|
pub(crate) fn refresh(
|
||||||
session: &mut Option<ImapSession>,
|
session: &mut Option<ImapSession>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) -> Result<Vec<Email>, String> {
|
) -> Result<Inbox, String> {
|
||||||
// If we have a session, try NOOP to keep alive / detect changes
|
// 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_inbox(s);
|
return fetch_latest(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is dead or missing — reconnect
|
// 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_inbox(&mut new_session);
|
let result = fetch_latest(&mut new_session);
|
||||||
*session = Some(new_session);
|
*session = Some(new_session);
|
||||||
result
|
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 ensure_session = |session: &mut Option<ImapSession>| -> Result<(), String> {
|
||||||
let start = exists.saturating_sub(MAX_FETCH - 1).max(1);
|
if session.as_mut().map_or(true, |s| s.noop().is_err()) {
|
||||||
format!("{}:{}", start, exists)
|
*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 {
|
match session {
|
||||||
ImapSession::Plain(s) => {
|
ImapSession::Plain(s) => {
|
||||||
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
|
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
|
||||||
if mailbox.exists == 0 {
|
Ok(mailbox.exists)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
ImapSession::Tls(s) => {
|
ImapSession::Tls(s) => {
|
||||||
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
|
let mailbox = s.select("INBOX").map_err(|e| e.to_string())?;
|
||||||
if mailbox.exists == 0 {
|
Ok(mailbox.exists)
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
}
|
||||||
let range = fetch_range(mailbox.exists);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result<Vec<Email>, String> {
|
||||||
|
match session {
|
||||||
|
ImapSession::Plain(s) => {
|
||||||
let messages = s
|
let messages = s
|
||||||
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
|
.fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let mut emails = parse_emails(&messages);
|
Ok(parse_emails(&messages))
|
||||||
emails.reverse();
|
}
|
||||||
Ok(emails)
|
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 inbox = inbox::refresh(&mut session, config);
|
||||||
let mut last_fetch = Instant::now();
|
let mut last_fetch = Instant::now();
|
||||||
let mut list_state = ListState::default();
|
let mut list_state = ListState::default();
|
||||||
if let Ok(emails) = &inbox {
|
if let Ok(ib) = &inbox {
|
||||||
if !emails.is_empty() {
|
if !ib.emails.is_empty() {
|
||||||
list_state.select(Some(0));
|
list_state.select(Some(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,8 +47,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
match &inbox {
|
match &inbox {
|
||||||
Ok(emails) if !emails.is_empty() => {
|
Ok(ib) if !ib.emails.is_empty() => {
|
||||||
let items: Vec<ListItem> = emails
|
let items: Vec<ListItem> = ib.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))
|
||||||
|
|
@ -58,7 +58,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.title(format!("Inbox ({} messages)", emails.len()))
|
.title(format!("Inbox ({} messages)", ib.emails.len()))
|
||||||
.borders(Borders::ALL),
|
.borders(Borders::ALL),
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::White))
|
.style(Style::default().fg(Color::White))
|
||||||
|
|
@ -98,8 +98,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
inbox = inbox::refresh(&mut session, config);
|
inbox = inbox::refresh(&mut session, config);
|
||||||
last_fetch = Instant::now();
|
last_fetch = Instant::now();
|
||||||
if let Ok(emails) = &inbox {
|
if let Ok(ib) = &inbox {
|
||||||
if !emails.is_empty() {
|
if !ib.emails.is_empty() {
|
||||||
list_state.select(Some(0));
|
list_state.select(Some(0));
|
||||||
} else {
|
} else {
|
||||||
list_state.select(None);
|
list_state.select(None);
|
||||||
|
|
@ -107,11 +107,14 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
if let Ok(emails) = &inbox {
|
if let Ok(ib) = &mut inbox {
|
||||||
let i = list_state.selected().map_or(0, |i| {
|
let len = ib.emails.len();
|
||||||
if i + 1 < emails.len() { i + 1 } else { i }
|
let i = list_state.selected().map_or(0, |i| i + 1);
|
||||||
});
|
if i >= len && ib.has_older() {
|
||||||
list_state.select(Some(i));
|
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') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue