Add IMAP search with / keybinding and live results pane
- Press / to enter search mode; status bar shows query input - IMAP SEARCH OR SUBJECT/FROM sent in background worker thread - Results replace inbox list with match count in title - Navigation and body preview work the same as in regular inbox - Esc clears search and returns to normal inbox view Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c3a7c62214
commit
7883b35dad
2 changed files with 306 additions and 95 deletions
39
src/inbox.rs
39
src/inbox.rs
|
|
@ -253,6 +253,45 @@ fn clean_markdown(text: &str) -> String {
|
|||
result.trim().to_string()
|
||||
}
|
||||
|
||||
/// Search emails on the server using IMAP SEARCH. Matches subject or sender.
|
||||
pub(crate) fn search(
|
||||
session: &mut Option<ImapSession>,
|
||||
query: &str,
|
||||
config: &Config,
|
||||
) -> Result<Vec<Email>, String> {
|
||||
ensure_session(session, config)?;
|
||||
|
||||
let escaped = query.replace('"', "\\\"");
|
||||
let search_query = format!("OR SUBJECT \"{}\" FROM \"{}\"", escaped, escaped);
|
||||
|
||||
let seq_set: std::collections::HashSet<u32> = {
|
||||
let s = session.as_mut().unwrap();
|
||||
match s {
|
||||
ImapSession::Plain(inner) => {
|
||||
inner.select("INBOX").map_err(|e| e.to_string())?;
|
||||
inner.search(&search_query).map_err(|e| e.to_string())?
|
||||
}
|
||||
ImapSession::Tls(inner) => {
|
||||
inner.select("INBOX").map_err(|e| e.to_string())?;
|
||||
inner.search(&search_query).map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if seq_set.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut seqs: Vec<u32> = seq_set.into_iter().collect();
|
||||
seqs.sort_unstable();
|
||||
let range = seqs.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
|
||||
|
||||
let s = session.as_mut().unwrap();
|
||||
let mut emails = fetch_range_emails(s, &range)?;
|
||||
emails.sort_by(|a, b| b.seq.cmp(&a.seq)); // newest first
|
||||
Ok(emails)
|
||||
}
|
||||
|
||||
fn find_part(mail: &mailparse::ParsedMail, mime_type: &str) -> Option<String> {
|
||||
let content_type = mail.ctype.mimetype.to_lowercase();
|
||||
if content_type == mime_type {
|
||||
|
|
|
|||
360
src/lib.rs
360
src/lib.rs
|
|
@ -25,6 +25,12 @@ enum Focus {
|
|||
Message,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Mode {
|
||||
Normal,
|
||||
Search,
|
||||
}
|
||||
|
||||
pub(crate) struct Email {
|
||||
pub seq: u32,
|
||||
pub subject: String,
|
||||
|
|
@ -37,6 +43,7 @@ enum WorkerCmd {
|
|||
FetchMore { oldest_seq: u32 },
|
||||
FetchBody { seq: u32 },
|
||||
Delete { seq: u32 },
|
||||
Search { query: String },
|
||||
Quit,
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +52,7 @@ enum WorkerResult {
|
|||
FetchedMore(Result<(Vec<Email>, u32), String>),
|
||||
Body { seq: u32, result: Result<String, String> },
|
||||
Deleted(Result<(), String>),
|
||||
Searched(Result<Vec<Email>, String>),
|
||||
}
|
||||
|
||||
fn worker_loop(
|
||||
|
|
@ -77,6 +85,10 @@ fn worker_loop(
|
|||
let result = inbox::delete_email(&mut session, seq, &config);
|
||||
let _ = result_tx.send(WorkerResult::Deleted(result));
|
||||
}
|
||||
WorkerCmd::Search { query } => {
|
||||
let result = inbox::search(&mut session, &query, &config);
|
||||
let _ = result_tx.send(WorkerResult::Searched(result));
|
||||
}
|
||||
WorkerCmd::Quit => break,
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +127,13 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
let mut message_scroll: u16 = 0;
|
||||
let mut pending_fetch_seq: Option<u32> = None;
|
||||
let mut last_nav = Instant::now();
|
||||
// Search state
|
||||
let mut mode = Mode::Normal;
|
||||
let mut search_query = String::new();
|
||||
let mut search_results: Vec<Email> = Vec::new();
|
||||
let mut search_results_state = ListState::default();
|
||||
let mut search_active = false;
|
||||
let mut search_loading = false;
|
||||
|
||||
// --- Main loop ---
|
||||
loop {
|
||||
|
|
@ -126,9 +145,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
.and_then(|i| emails.get(i))
|
||||
.map(|e| e.seq);
|
||||
|
||||
// Preserve emails older than the refresh batch (previously loaded via
|
||||
// FetchMore). Without this, scrolling into older emails and then
|
||||
// triggering a refresh would drop those emails and reset selection to 0.
|
||||
// Preserve emails older than the refresh batch (previously loaded via FetchMore)
|
||||
let refresh_oldest = inbox.emails.last().map(|e| e.seq).unwrap_or(0);
|
||||
let mut merged = inbox.emails;
|
||||
for e in emails.drain(..) {
|
||||
|
|
@ -138,7 +155,6 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
}
|
||||
emails = merged;
|
||||
|
||||
// Recalculate from the merged list
|
||||
oldest_seq = emails.last().map(|e| e.seq);
|
||||
has_older = oldest_seq.map_or(false, |s| s > 1);
|
||||
error = None;
|
||||
|
|
@ -149,8 +165,6 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
.unwrap_or(0);
|
||||
list_state.select(Some(new_idx));
|
||||
let seq = emails[new_idx].seq;
|
||||
// Fetch if the selected email changed OR a debounce fetch is pending
|
||||
// (pending means the body shown may be stale from a previous email)
|
||||
if message_seq != Some(seq) || pending_fetch_seq.is_some() {
|
||||
message_seq = Some(seq);
|
||||
message_body.clear();
|
||||
|
|
@ -199,6 +213,30 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
error = Some(format!("Delete failed: {}", e));
|
||||
}
|
||||
}
|
||||
WorkerResult::Searched(Ok(results)) => {
|
||||
search_results = results;
|
||||
search_results_state
|
||||
.select(if search_results.is_empty() { None } else { Some(0) });
|
||||
search_loading = false;
|
||||
search_active = true;
|
||||
// Fetch body of the first result
|
||||
if let Some(first) = search_results.first() {
|
||||
let seq = first.seq;
|
||||
message_seq = Some(seq);
|
||||
message_body.clear();
|
||||
message_rendered = None;
|
||||
message_scroll = 0;
|
||||
body_loading = true;
|
||||
pending_fetch_seq = None;
|
||||
wanted_body_seq.store(seq, Ordering::Relaxed);
|
||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
||||
}
|
||||
}
|
||||
WorkerResult::Searched(Err(e)) => {
|
||||
error = Some(format!("Search failed: {}", e));
|
||||
search_loading = false;
|
||||
search_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +270,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
])
|
||||
.split(area);
|
||||
|
||||
// --- Top: Inbox list ---
|
||||
// --- Top: Inbox / Search results ---
|
||||
let (inbox_title_style, inbox_prefix) = if focus == Focus::Inbox {
|
||||
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ")
|
||||
} else {
|
||||
|
|
@ -250,19 +288,81 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
Line::from(Span::styled(format!("{}{}", inbox_prefix, label), inbox_title_style))
|
||||
};
|
||||
|
||||
if let Some(e) = &error {
|
||||
if search_loading {
|
||||
let p = Paragraph::new(format!("Searching for \"{}\"...", search_query))
|
||||
.block(Block::default()
|
||||
.title(inbox_title("Search"))
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(inbox_border))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
frame.render_widget(p, layout[0]);
|
||||
} else if search_active {
|
||||
let title_str = format!(
|
||||
"Search: \"{}\" ({} result{})",
|
||||
search_query,
|
||||
search_results.len(),
|
||||
if search_results.len() == 1 { "" } else { "s" }
|
||||
);
|
||||
if search_results.is_empty() {
|
||||
let p = Paragraph::new("No results found.")
|
||||
.block(Block::default()
|
||||
.title(inbox_title(&title_str))
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(inbox_border))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
frame.render_widget(p, layout[0]);
|
||||
} else {
|
||||
let max_from = search_results.iter()
|
||||
.map(|e| e.from.chars().count())
|
||||
.max()
|
||||
.unwrap_or(20)
|
||||
.min(40);
|
||||
let items: Vec<ListItem> = search_results
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let from_len = e.from.chars().count();
|
||||
let from = if from_len > max_from {
|
||||
format!("{}…", e.from.chars().take(max_from.saturating_sub(1)).collect::<String>())
|
||||
} else {
|
||||
format!("{:<width$}", e.from, width = max_from)
|
||||
};
|
||||
ListItem::new(format!("{} | {} | {}", e.date, from, e.subject))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default()
|
||||
.title(inbox_title(&title_str))
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(inbox_border))
|
||||
.style(Style::default().fg(Color::White))
|
||||
.highlight_style(
|
||||
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
frame.render_stateful_widget(list, layout[0], &mut search_results_state);
|
||||
}
|
||||
} else if let Some(e) = &error {
|
||||
let p = Paragraph::new(format!("IMAP error: {}", e))
|
||||
.block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border))
|
||||
.block(Block::default()
|
||||
.title(inbox_title("Inbox"))
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(inbox_border))
|
||||
.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_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border))
|
||||
.block(Block::default()
|
||||
.title(inbox_title("Inbox"))
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(inbox_border))
|
||||
.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_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border))
|
||||
.block(Block::default()
|
||||
.title(inbox_title("Inbox"))
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(inbox_border))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
frame.render_widget(p, layout[0]);
|
||||
} else {
|
||||
|
|
@ -342,7 +442,15 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
.scroll((message_scroll, 0));
|
||||
frame.render_widget(preview, layout[1]);
|
||||
|
||||
let status = Paragraph::new(" 'q' quit | 'r' refresh | 'd' delete | ↑/↓ navigate | Tab switch pane")
|
||||
// Status bar
|
||||
let status_text = if mode == Mode::Search {
|
||||
format!(" Search: {}_", search_query)
|
||||
} else if search_active {
|
||||
" / new search | Esc clear | q quit | ↑/↓ navigate | Tab switch pane".to_string()
|
||||
} else {
|
||||
" / search | q quit | r refresh | d delete | ↑/↓ navigate | Tab switch pane".to_string()
|
||||
};
|
||||
let status = Paragraph::new(status_text)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(status, layout[2]);
|
||||
})?;
|
||||
|
|
@ -350,105 +458,169 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||
// --- Input handling ---
|
||||
if event::poll(Duration::from_millis(200))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
KeyCode::Tab => {
|
||||
focus = match focus {
|
||||
Focus::Inbox => Focus::Message,
|
||||
Focus::Message => Focus::Inbox,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if !loading {
|
||||
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
||||
loading = true;
|
||||
last_fetch = Instant::now();
|
||||
if mode == Mode::Search {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => search_query.push(c),
|
||||
KeyCode::Backspace => { search_query.pop(); }
|
||||
KeyCode::Enter => {
|
||||
if !search_query.is_empty() {
|
||||
search_loading = true;
|
||||
search_active = true;
|
||||
search_results.clear();
|
||||
search_results_state.select(None);
|
||||
let _ = cmd_tx.send(WorkerCmd::Search { query: search_query.clone() });
|
||||
}
|
||||
mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
mode = Mode::Normal;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => match focus {
|
||||
Focus::Inbox => {
|
||||
let len = emails.len();
|
||||
if len > 0 {
|
||||
let i = list_state.selected().map_or(0, |i| i + 1);
|
||||
if i >= len && has_older && !loading {
|
||||
if let Some(seq) = oldest_seq {
|
||||
let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq });
|
||||
loading = true;
|
||||
}
|
||||
}
|
||||
let new_idx = i.min(len.saturating_sub(1));
|
||||
list_state.select(Some(new_idx));
|
||||
let seq = emails[new_idx].seq;
|
||||
if message_seq != Some(seq) {
|
||||
message_seq = Some(seq);
|
||||
message_scroll = 0;
|
||||
pending_fetch_seq = Some(seq);
|
||||
last_nav = Instant::now();
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => break,
|
||||
KeyCode::Esc => {
|
||||
if search_active {
|
||||
search_active = false;
|
||||
search_results.clear();
|
||||
search_results_state.select(None);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Focus::Message => {
|
||||
message_scroll = message_scroll.saturating_add(1);
|
||||
KeyCode::Char('/') => {
|
||||
search_query.clear();
|
||||
mode = Mode::Search;
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => match focus {
|
||||
Focus::Inbox => {
|
||||
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
||||
list_state.select(Some(i));
|
||||
if !emails.is_empty() {
|
||||
let seq = emails[i].seq;
|
||||
if message_seq != Some(seq) {
|
||||
message_seq = Some(seq);
|
||||
message_scroll = 0;
|
||||
pending_fetch_seq = Some(seq);
|
||||
last_nav = Instant::now();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
focus = match focus {
|
||||
Focus::Inbox => Focus::Message,
|
||||
Focus::Message => Focus::Inbox,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if !loading && !search_active {
|
||||
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
||||
loading = true;
|
||||
last_fetch = Instant::now();
|
||||
}
|
||||
}
|
||||
Focus::Message => {
|
||||
message_scroll = message_scroll.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') if focus == Focus::Inbox => {
|
||||
if let Some(idx) = list_state.selected() {
|
||||
if idx < emails.len() {
|
||||
let seq = emails[idx].seq;
|
||||
// Remove from UI immediately
|
||||
emails.remove(idx);
|
||||
// IMAP expunge shifts sequence numbers: every message
|
||||
// that had seq > deleted seq is renumbered down by 1.
|
||||
for e in emails.iter_mut() {
|
||||
if e.seq > seq {
|
||||
e.seq -= 1;
|
||||
KeyCode::Down | KeyCode::Char('j') => match focus {
|
||||
Focus::Inbox => {
|
||||
if search_active {
|
||||
let len = search_results.len();
|
||||
if len > 0 {
|
||||
let i = search_results_state
|
||||
.selected()
|
||||
.map_or(0, |i| (i + 1).min(len - 1));
|
||||
search_results_state.select(Some(i));
|
||||
let seq = search_results[i].seq;
|
||||
if message_seq != Some(seq) {
|
||||
message_seq = Some(seq);
|
||||
message_scroll = 0;
|
||||
pending_fetch_seq = Some(seq);
|
||||
last_nav = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
if emails.is_empty() {
|
||||
list_state.select(None);
|
||||
message_seq = None;
|
||||
message_body.clear();
|
||||
message_rendered = None;
|
||||
pending_fetch_seq = None;
|
||||
} else {
|
||||
let new_idx = idx.min(emails.len().saturating_sub(1));
|
||||
list_state.select(Some(new_idx));
|
||||
let new_seq = emails[new_idx].seq;
|
||||
if message_seq != Some(new_seq) {
|
||||
message_seq = Some(new_seq);
|
||||
let len = emails.len();
|
||||
if len > 0 {
|
||||
let i = list_state.selected().map_or(0, |i| i + 1);
|
||||
if i >= len && has_older && !loading {
|
||||
if let Some(seq) = oldest_seq {
|
||||
let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq });
|
||||
loading = true;
|
||||
}
|
||||
}
|
||||
let new_idx = i.min(len.saturating_sub(1));
|
||||
list_state.select(Some(new_idx));
|
||||
let seq = emails[new_idx].seq;
|
||||
if message_seq != Some(seq) {
|
||||
message_seq = Some(seq);
|
||||
message_scroll = 0;
|
||||
pending_fetch_seq = Some(seq);
|
||||
last_nav = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Focus::Message => {
|
||||
message_scroll = message_scroll.saturating_add(1);
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => match focus {
|
||||
Focus::Inbox => {
|
||||
if search_active {
|
||||
let i = search_results_state
|
||||
.selected()
|
||||
.map_or(0, |i| i.saturating_sub(1));
|
||||
search_results_state.select(Some(i));
|
||||
if !search_results.is_empty() {
|
||||
let seq = search_results[i].seq;
|
||||
if message_seq != Some(seq) {
|
||||
message_seq = Some(seq);
|
||||
message_scroll = 0;
|
||||
pending_fetch_seq = Some(seq);
|
||||
last_nav = Instant::now();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
||||
list_state.select(Some(i));
|
||||
if !emails.is_empty() {
|
||||
let seq = emails[i].seq;
|
||||
if message_seq != Some(seq) {
|
||||
message_seq = Some(seq);
|
||||
message_scroll = 0;
|
||||
pending_fetch_seq = Some(seq);
|
||||
last_nav = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Focus::Message => {
|
||||
message_scroll = message_scroll.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') if focus == Focus::Inbox && !search_active => {
|
||||
if let Some(idx) = list_state.selected() {
|
||||
if idx < emails.len() {
|
||||
let seq = emails[idx].seq;
|
||||
emails.remove(idx);
|
||||
// IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted
|
||||
for e in emails.iter_mut() {
|
||||
if e.seq > seq {
|
||||
e.seq -= 1;
|
||||
}
|
||||
}
|
||||
if emails.is_empty() {
|
||||
list_state.select(None);
|
||||
message_seq = None;
|
||||
message_body.clear();
|
||||
message_rendered = None;
|
||||
message_scroll = 0;
|
||||
body_loading = true;
|
||||
pending_fetch_seq = None;
|
||||
wanted_body_seq.store(new_seq, Ordering::Relaxed);
|
||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
|
||||
} else {
|
||||
let new_idx = idx.min(emails.len().saturating_sub(1));
|
||||
list_state.select(Some(new_idx));
|
||||
let new_seq = emails[new_idx].seq;
|
||||
if message_seq != Some(new_seq) {
|
||||
message_seq = Some(new_seq);
|
||||
message_body.clear();
|
||||
message_rendered = None;
|
||||
message_scroll = 0;
|
||||
body_loading = true;
|
||||
pending_fetch_seq = None;
|
||||
wanted_body_seq.store(new_seq, Ordering::Relaxed);
|
||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
|
||||
}
|
||||
}
|
||||
let _ = cmd_tx.send(WorkerCmd::Delete { seq });
|
||||
}
|
||||
// Delete on server in background
|
||||
let _ = cmd_tx.send(WorkerCmd::Delete { seq });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue