use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::io::{self, Stdout};
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
use crate::app::patch as patch_worker;
use crate::app::sync as sync_worker;
use crate::domain::subscriptions::{
DEFAULT_SUBSCRIPTIONS, SubscriptionCategory, category_for_mailbox,
};
use crate::infra::bootstrap::BootstrapState;
use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig};
use crate::infra::error::{CriewError, ErrorCode, Result};
use crate::infra::mail_store::{self, ThreadRow};
use crate::infra::reply_store::{self, ReplySendRecordRequest, ReplySendStatus};
use crate::infra::sendmail::{self, SendOutcome, SendRequest, SendStatus};
use crate::infra::ui_state::{self, UiState};
use chrono::Utc;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders};
mod config;
mod input;
mod keymap;
mod palette;
mod preview;
mod render;
mod reply;
#[cfg(test)]
mod tests;
use input::{LoopAction, handle_key_event};
use keymap::{
KeymapEditorState, PendingMainPageSequenceState, ResolvedMainPageKeymap, draw_keymap_editor,
handle_keymap_editor_key_event, handle_main_page_key_event, main_page_focus_shortcuts,
main_page_move_shortcuts, main_page_navigation_shortcuts, resolve_active_main_page_keymap,
};
use palette::short_commit_id;
#[cfg(test)]
use palette::{is_palette_open_shortcut, is_palette_toggle, resolve_palette_local_workdir};
#[cfg(test)]
use render::{
code_edit_cursor_position, load_source_file_preview, mail_page_panes, sanitize_inline_ui_text,
thread_line,
};
use render::{draw, subscription_line};
use preview::{MailPreview, load_mail_preview};
#[cfg(test)]
use preview::{extract_mail_body_preview, extract_mail_preview};
use reply::{
PreparedReplyMessage, ReplyIdentity, ReplyPreview, ReplyPreviewLine, ReplyPreviewRequest,
ReplySeed, build_reply_seed, prepare_reply_message, render_reply_preview,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pane {
Subscriptions,
Threads,
Preview,
}
impl Pane {
fn title(self) -> &'static str {
match self {
Self::Subscriptions => "Subscriptions",
Self::Threads => "Threads",
Self::Preview => "Preview",
}
}
fn next(self) -> Self {
match self {
Self::Subscriptions => Self::Threads,
Self::Threads => Self::Preview,
Self::Preview => Self::Subscriptions,
}
}
fn previous(self) -> Self {
match self {
Self::Subscriptions => Self::Preview,
Self::Threads => Self::Subscriptions,
Self::Preview => Self::Threads,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MailPaneLayout {
subscriptions_width: u16,
preview_width: u16,
}
impl Default for MailPaneLayout {
fn default() -> Self {
Self {
subscriptions_width: ui_state::DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH,
preview_width: ui_state::DEFAULT_MAIL_PREVIEW_WIDTH,
}
}
}
impl MailPaneLayout {
fn from_persisted(state: Option<&UiState>) -> Self {
let defaults = Self::default();
Self {
subscriptions_width: state
.map(|persisted| persisted.mail_subscriptions_width)
.unwrap_or(defaults.subscriptions_width)
.max(MIN_MAIL_SUBSCRIPTIONS_WIDTH),
preview_width: state
.map(|persisted| persisted.mail_preview_width)
.unwrap_or(defaults.preview_width)
.max(MIN_MAIL_PREVIEW_WIDTH),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HorizontalResizeDirection {
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MailPaneResizeMode {
Expand,
Shrink,
}
#[derive(Debug, Clone, Copy)]
struct PaletteCommand {
name: &'static str,
description: &'static str,
}
#[derive(Debug, Clone)]
struct PaletteSuggestion {
value: String,
description: Option<String>,
}
#[derive(Debug, Clone)]
struct LocalCommandResult {
command: String,
cwd: PathBuf,
exit_code: String,
output: String,
}
#[derive(Debug, Clone)]
struct LastApplySnapshot {
thread_id: i64,
before_head: String,
after_head: String,
}
const PALETTE_COMMANDS: &[PaletteCommand] = &[
PaletteCommand {
name: "quit",
description: "Exit CRIEW",
},
PaletteCommand {
name: "exit",
description: "Exit CRIEW",
},
PaletteCommand {
name: "help",
description: "Show available commands",
},
PaletteCommand {
name: "restart",
description: "Restart TUI with startup config",
},
PaletteCommand {
name: "sync",
description: "Sync mailbox now",
},
PaletteCommand {
name: "config",
description: "Open visual config editor or update runtime config",
},
PaletteCommand {
name: "keymap",
description: "Open visual keymap editor for main-page navigation",
},
PaletteCommand {
name: "vim",
description: "Open selected source file in external vim",
},
];
const PALETTE_SYNC_RECONNECT_ATTEMPTS: u8 = 3;
const MAIL_PANE_RESIZE_STEP: u16 = 4;
const MIN_MAIL_PREVIEW_WIDTH: u16 = 40;
const MIN_MAIL_SUBSCRIPTIONS_WIDTH: u16 = 12;
const PREVIEW_TAB_SPACES: &str = " ";
const PREVIEW_RECIPIENT_PREVIEW_LIMIT: usize = 2;
const THREAD_LINE_MAX_CHARS: usize = 120;
const KERNEL_TREE_MAX_ROWS: usize = 2048;
const CODE_PREVIEW_MAX_BYTES: usize = 256 * 1024;
const CODE_PREVIEW_MAX_LINES: usize = 800;
const MY_INBOX_LABEL: &str = "My Inbox";
const CONFIG_GET_KEYS: &[&str] = &[
"config.path",
"storage.data_dir",
"storage.database",
"storage.raw_mail_dir",
"storage.patch_dir",
"logging.dir",
"logging.filter",
"b4.path",
"source.mailbox",
"imap.mailbox",
"imap.email",
"imap.user",
"imap.pass",
"imap.server",
"imap.serverport",
"imap.encryption",
"imap.proxy",
"source.lore_base_url",
"ui.startup_sync",
"ui.keymap",
"ui.keymap_base",
"ui.inbox_auto_sync_interval_secs",
"kernel.tree",
"kernel.trees",
];
const CONFIG_SET_KEYS: &[&str] = &[
"storage.data_dir",
"storage.database",
"storage.raw_mail_dir",
"storage.patch_dir",
"logging.dir",
"logging.filter",
"b4.path",
"source.mailbox",
"imap.mailbox",
"imap.email",
"imap.user",
"imap.pass",
"imap.server",
"imap.serverport",
"imap.encryption",
"imap.proxy",
"source.lore_base_url",
"ui.startup_sync",
"ui.keymap",
"ui.keymap_base",
"ui.inbox_auto_sync_interval_secs",
"kernel.tree",
"kernel.trees",
];
const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[
ConfigEditorField {
key: "source.mailbox",
description: "Default lore mailbox used when sync runs without an explicit mailbox.",
},
ConfigEditorField {
key: "source.lore_base_url",
description: "Base URL used for lore links and message lookups.",
},
ConfigEditorField {
key: "ui.startup_sync",
description: "Whether enabled subscriptions start syncing automatically after TUI launch.",
},
ConfigEditorField {
key: "ui.keymap",
description: "Main-page navigation scheme. default=j/l+i/k+count, vim=h/l+j/k+count+gg/G+qq, custom=ui.keymap_base plus ui.custom_keymap overrides.",
},
ConfigEditorField {
key: "ui.inbox_auto_sync_interval_secs",
description: "Seconds between My Inbox background auto-sync runs while TUI stays open.",
},
ConfigEditorField {
key: "logging.filter",
description: "Tracing/logging filter level for CRIEW runtime logs.",
},
ConfigEditorField {
key: "logging.dir",
description: "Directory where CRIEW writes runtime log files.",
},
ConfigEditorField {
key: "storage.data_dir",
description: "Runtime data root used for db, mail cache, patches and UI state defaults.",
},
ConfigEditorField {
key: "storage.database",
description: "SQLite database path used for synced mail and patch metadata.",
},
ConfigEditorField {
key: "storage.raw_mail_dir",
description: "Directory where raw downloaded .eml files are stored.",
},
ConfigEditorField {
key: "storage.patch_dir",
description: "Directory where downloaded patch files are written.",
},
ConfigEditorField {
key: "b4.path",
description: "Optional explicit path to the b4 executable or wrapper script.",
},
ConfigEditorField {
key: "imap.email",
description: "Self email address for matching your own mail; also used as login when imap.user is omitted.",
},
ConfigEditorField {
key: "imap.user",
description: "IMAP login account. Gmail usually expects the full email address.",
},
ConfigEditorField {
key: "imap.pass",
description: "IMAP login password or app password.",
},
ConfigEditorField {
key: "imap.server",
description: "IMAP server host name.",
},
ConfigEditorField {
key: "imap.serverport",
description: "IMAP server port number.",
},
ConfigEditorField {
key: "imap.encryption",
description: "Connection security mode. Gmail 993 uses ssl/tls here.",
},
ConfigEditorField {
key: "imap.proxy",
description: "Optional proxy URL for IMAP. Supports http://, socks5:// and socks5h://.",
},
ConfigEditorField {
key: "kernel.tree",
description: "Single kernel tree root shown in the code browser pane.",
},
ConfigEditorField {
key: "kernel.trees",
description: "Multiple kernel tree roots, written as a TOML array of paths.",
},
];
const CODE_EDIT_ENTRY_HINT: &str = "select a source file in Source pane, then press e";
const EXTERNAL_EDITOR_ENTRY_HINT: &str = "select a source file in Source pane, then press E";
fn shrink_mail_pane_width(width: &mut u16, minimum_width: u16) -> bool {
if *width <= minimum_width {
return false;
}
*width = width
.saturating_sub(MAIL_PANE_RESIZE_STEP)
.max(minimum_width);
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ExternalEditorProcessResult {
success: bool,
exit_code: Option<i32>,
}
type ExternalEditorRunner =
fn(&str, &Path) -> std::result::Result<ExternalEditorProcessResult, String>;
type ReplyIdentityResolver = fn() -> std::result::Result<ReplyIdentity, String>;
type ReplySendExecutor = fn(&RuntimeConfig, &SendRequest) -> SendOutcome;
type MailboxSyncSpawner = fn(RuntimeConfig, Vec<String>) -> Receiver<StartupSyncEvent>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingMainPageCountState {
count: u16,
ui_page: UiPage,
focus: Pane,
code_focus: CodePaneFocus,
}
#[derive(Debug, Clone)]
enum StartupSyncEvent {
MailboxStarted {
mailbox: String,
index: usize,
total: usize,
},
MailboxFinished {
mailbox: String,
fetched: usize,
inserted: usize,
updated: usize,
},
MailboxFailed {
mailbox: String,
error: String,
},
WorkerCompleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartupSyncMailboxStatus {
Pending,
InFlight,
Finished,
Failed,
}
impl StartupSyncMailboxStatus {
fn ui_suffix(self) -> &'static str {
match self {
Self::Pending => " [queued]",
Self::InFlight => " [sync]",
Self::Finished => " [done]",
Self::Failed => " [failed]",
}
}
fn log_label(self) -> &'static str {
match self {
Self::Pending => "queued",
Self::InFlight => "syncing",
Self::Finished => "done",
Self::Failed => "failed",
}
}
}
#[derive(Debug)]
struct StartupSyncState {
receiver: Receiver<StartupSyncEvent>,
mailbox_order: Vec<String>,
mailboxes: HashMap<String, StartupSyncMailboxStatus>,
total: usize,
completed: usize,
succeeded: usize,
failed: usize,
}
impl StartupSyncState {
fn pending_count(&self) -> usize {
self.mailbox_order
.iter()
.filter(|mailbox| {
matches!(
self.mailboxes.get(mailbox.as_str()),
Some(StartupSyncMailboxStatus::Pending)
)
})
.count()
}
fn inflight_mailboxes_display(&self) -> String {
let running: Vec<&str> = self
.mailbox_order
.iter()
.filter_map(|mailbox| {
matches!(
self.mailboxes.get(mailbox.as_str()),
Some(StartupSyncMailboxStatus::InFlight)
)
.then_some(mailbox.as_str())
})
.collect();
if running.is_empty() {
"-".to_string()
} else {
running.join(",")
}
}
fn mailbox_states_display(&self) -> String {
self.mailbox_order
.iter()
.map(|mailbox| {
let status = self
.mailboxes
.get(mailbox.as_str())
.copied()
.unwrap_or(StartupSyncMailboxStatus::Pending);
format!("{mailbox}:{}", status.log_label())
})
.collect::<Vec<String>>()
.join(" ")
}
#[cfg(test)]
fn progress_summary(&self) -> String {
format!(
"{}/{} ok={} fail={} queued={} running={}",
self.completed,
self.total,
self.succeeded,
self.failed,
self.pending_count(),
self.inflight_mailboxes_display()
)
}
}
#[derive(Debug)]
struct InboxAutoSyncState {
receiver: Option<Receiver<StartupSyncEvent>>,
next_due_at: Instant,
}
impl InboxAutoSyncState {
fn new(next_due_at: Instant) -> Self {
Self {
receiver: None,
next_due_at,
}
}
fn in_flight(&self) -> bool {
self.receiver.is_some()
}
}
#[derive(Debug, Clone, Copy)]
enum ManualSyncOrigin {
PaletteCommand,
SubscriptionOpen,
}
impl ManualSyncOrigin {
fn log_label(self) -> &'static str {
match self {
Self::PaletteCommand => "palette",
Self::SubscriptionOpen => "subscription_open",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ManualSyncRequestOutcome {
Started,
AlreadySyncing,
Busy,
}
#[derive(Debug)]
struct ManualSyncState {
receiver: Receiver<StartupSyncEvent>,
mailbox_order: Vec<String>,
mailboxes: HashMap<String, StartupSyncMailboxStatus>,
total: usize,
completed: usize,
succeeded: usize,
failed: usize,
total_fetched: usize,
total_inserted: usize,
total_updated: usize,
first_error: Option<String>,
}
impl ManualSyncState {
fn pending_count(&self) -> usize {
self.mailbox_order
.iter()
.filter(|mailbox| {
matches!(
self.mailboxes.get(mailbox.as_str()),
Some(StartupSyncMailboxStatus::Pending)
)
})
.count()
}
fn inflight_mailboxes_display(&self) -> String {
let running: Vec<&str> = self
.mailbox_order
.iter()
.filter_map(|mailbox| {
matches!(
self.mailboxes.get(mailbox.as_str()),
Some(StartupSyncMailboxStatus::InFlight)
)
.then_some(mailbox.as_str())
})
.collect();
if running.is_empty() {
"-".to_string()
} else {
running.join(",")
}
}
fn progress_summary(&self) -> String {
format!(
"{}/{} ok={} fail={} queued={} running={}",
self.completed,
self.total,
self.succeeded,
self.failed,
self.pending_count(),
self.inflight_mailboxes_display()
)
}
fn mailbox_states_display(&self) -> String {
self.mailbox_order
.iter()
.map(|mailbox| {
let status = self
.mailboxes
.get(mailbox.as_str())
.copied()
.unwrap_or(StartupSyncMailboxStatus::Pending);
format!("{mailbox}:{}", status.log_label())
})
.collect::<Vec<String>>()
.join(" ")
}
}
#[derive(Debug)]
struct SubscriptionAutoSyncState {
receiver: Option<Receiver<StartupSyncEvent>>,
next_due_at: Instant,
in_flight_mailboxes: HashSet<String>,
}
impl SubscriptionAutoSyncState {
fn new(next_due_at: Instant) -> Self {
Self {
receiver: None,
next_due_at,
in_flight_mailboxes: HashSet::new(),
}
}
fn in_flight(&self) -> bool {
self.receiver.is_some()
}
fn mailbox_in_flight(&self, mailbox: &str) -> bool {
self.in_flight_mailboxes
.iter()
.any(|in_flight| same_mailbox_name(in_flight, mailbox))
}
fn inflight_mailboxes_display(&self) -> String {
if self.in_flight_mailboxes.is_empty() {
"-".to_string()
} else {
let mut mailboxes: Vec<&str> = self
.in_flight_mailboxes
.iter()
.map(|mailbox| mailbox.as_str())
.collect();
mailboxes.sort_unstable();
mailboxes.join(",")
}
}
}
#[derive(Debug, Default)]
struct CommandPaletteState {
open: bool,
input: String,
suggestions: Vec<PaletteSuggestion>,
show_suggestions: bool,
last_tab_input: String,
last_local_result: Option<LocalCommandResult>,
}
impl CommandPaletteState {
fn clear_completion(&mut self) {
self.suggestions.clear();
self.show_suggestions = false;
self.last_tab_input.clear();
}
fn clear_local_result(&mut self) {
self.last_local_result = None;
}
}
#[derive(Debug, Default)]
struct SearchState {
active: bool,
input: String,
applied_query: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ConfigEditorMode {
#[default]
Browse,
Edit,
}
#[derive(Debug, Default)]
struct ConfigEditorState {
open: bool,
selected_field: usize,
mode: ConfigEditorMode,
input: String,
}
#[derive(Debug, Clone, Copy)]
struct ConfigEditorField {
key: &'static str,
description: &'static str,
}
#[derive(Debug, Clone)]
struct SubscriptionItem {
mailbox: String,
label: String,
enabled: bool,
category: Option<SubscriptionCategory>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubscriptionSection {
Enabled,
Disabled,
}
impl SubscriptionSection {
const fn label(self) -> &'static str {
match self {
Self::Enabled => "enabled",
Self::Disabled => "disabled",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubscriptionRowKind {
EnabledHeader,
DisabledHeader,
CategoryHeader {
section: SubscriptionSection,
category: SubscriptionCategory,
},
Item(usize),
}
#[derive(Debug, Clone)]
struct SubscriptionRow {
kind: SubscriptionRowKind,
text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UiPage {
Mail,
CodeBrowser,
}
impl UiPage {
fn toggled(self) -> Self {
match self {
Self::Mail => Self::CodeBrowser,
Self::CodeBrowser => Self::Mail,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CodePaneFocus {
Tree,
Source,
}
impl CodePaneFocus {
fn next(self) -> Self {
match self {
Self::Tree => Self::Source,
Self::Source => Self::Tree,
}
}
fn previous(self) -> Self {
match self {
Self::Tree => Self::Source,
Self::Source => Self::Tree,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CodeEditMode {
Browse,
VimNormal,
VimInsert,
VimCommand,
}
impl CodeEditMode {
fn is_active(self) -> bool {
!matches!(self, Self::Browse)
}
fn label(self) -> &'static str {
match self {
Self::Browse => "BROWSE",
Self::VimNormal => "NORMAL",
Self::VimInsert => "INSERT",
Self::VimCommand => "COMMAND",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplyEditMode {
Normal,
Insert,
Command,
}
impl ReplyEditMode {
fn label(self) -> &'static str {
match self {
Self::Normal => "NORMAL",
Self::Insert => "INSERT",
Self::Command => "COMMAND",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplySection {
From,
To,
Cc,
Subject,
Body,
}
impl ReplySection {
fn label(self) -> &'static str {
match self {
Self::From => "From",
Self::To => "To",
Self::Cc => "Cc",
Self::Subject => "Subject",
Self::Body => "Body",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplyNoticeKind {
Warning,
Info,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplyNoticeAction {
OpenPreview,
Send,
}
#[derive(Debug, Clone)]
struct ReplyNoticeState {
kind: ReplyNoticeKind,
title: String,
message: String,
hint: String,
action: Option<ReplyNoticeAction>,
}
#[derive(Debug, Clone)]
struct ReplyPanelState {
thread_id: i64,
mail_id: i64,
from: String,
to: String,
cc: String,
subject: String,
in_reply_to: String,
references: Vec<String>,
body: Vec<String>,
self_addresses: Vec<String>,
mode: ReplyEditMode,
section: ReplySection,
body_row: usize,
cursor_col: usize,
dirty: bool,
scroll: u16,
command_input: String,
preview_open: bool,
preview_scroll: u16,
preview_rendered: String,
preview_lines: Vec<ReplyPreviewLine>,
preview_errors: Vec<String>,
preview_warnings: Vec<String>,
preview_confirmed: bool,
preview_confirmed_at: Option<String>,
reply_notice: Option<ReplyNoticeState>,
}
impl ReplyPanelState {
fn new(seed: ReplySeed, self_addresses: Vec<String>, mail_id: i64, thread_id: i64) -> Self {
let mut state = Self {
thread_id,
mail_id,
from: seed.from,
to: seed.to,
cc: seed.cc,
subject: seed.subject,
in_reply_to: seed.in_reply_to,
references: seed.references,
body: if seed.body.is_empty() {
vec![String::new()]
} else {
seed.body
},
self_addresses,
mode: ReplyEditMode::Normal,
section: ReplySection::From,
body_row: 0,
cursor_col: 0,
dirty: false,
scroll: 0,
command_input: String::new(),
preview_open: false,
preview_scroll: 0,
preview_rendered: String::new(),
preview_lines: Vec::new(),
preview_errors: Vec::new(),
preview_warnings: Vec::new(),
preview_confirmed: false,
preview_confirmed_at: None,
reply_notice: None,
};
state.clamp_cursor();
state
}
fn mark_dirty(&mut self) {
self.dirty = true;
self.preview_confirmed = false;
self.preview_confirmed_at = None;
self.reply_notice = None;
}
fn current_value(&self) -> &str {
match self.section {
ReplySection::From => self.from.as_str(),
ReplySection::To => self.to.as_str(),
ReplySection::Cc => self.cc.as_str(),
ReplySection::Subject => self.subject.as_str(),
ReplySection::Body => self
.body
.get(self.body_row)
.map(String::as_str)
.unwrap_or_default(),
}
}
fn current_value_mut(&mut self) -> &mut String {
match self.section {
ReplySection::From => &mut self.from,
ReplySection::To => &mut self.to,
ReplySection::Cc => &mut self.cc,
ReplySection::Subject => &mut self.subject,
ReplySection::Body => {
if self.body.is_empty() {
self.body.push(String::new());
}
let row = self.body_row.min(self.body.len().saturating_sub(1));
&mut self.body[row]
}
}
}
fn current_value_len(&self) -> usize {
self.current_value().chars().count()
}
fn clamp_cursor(&mut self) {
if self.body.is_empty() {
self.body.push(String::new());
}
if matches!(self.section, ReplySection::Body) && self.body_row >= self.body.len() {
self.body_row = self.body.len().saturating_sub(1);
}
self.cursor_col = self.cursor_col.min(self.current_value_len());
}
fn current_body_logical_row(&self) -> usize {
if matches!(self.mode, ReplyEditMode::Command) {
return reply_command_line_logical_row(self);
}
reply_body_line_logical_row(self.body_row)
}
fn adjust_scroll(&mut self) {
if matches!(self.mode, ReplyEditMode::Command) || matches!(self.section, ReplySection::Body)
{
let scroll_target = self.current_body_logical_row().saturating_sub(3);
self.scroll = scroll_target.min(u16::MAX as usize) as u16;
}
}
fn move_left(&mut self) {
self.clamp_cursor();
if self.cursor_col > 0 {
self.cursor_col -= 1;
}
self.adjust_scroll();
}
fn move_right(&mut self) {
self.clamp_cursor();
if self.cursor_col < self.current_value_len() {
self.cursor_col += 1;
}
self.adjust_scroll();
}
fn move_up(&mut self) {
self.clamp_cursor();
match self.section {
ReplySection::From => {}
ReplySection::To => self.section = ReplySection::From,
ReplySection::Cc => self.section = ReplySection::To,
ReplySection::Subject => self.section = ReplySection::Cc,
ReplySection::Body => {
if self.body_row > 0 {
self.body_row -= 1;
} else {
self.section = ReplySection::Subject;
}
}
}
self.clamp_cursor();
self.adjust_scroll();
}
fn move_down(&mut self) {
self.clamp_cursor();
match self.section {
ReplySection::From => self.section = ReplySection::To,
ReplySection::To => self.section = ReplySection::Cc,
ReplySection::Cc => self.section = ReplySection::Subject,
ReplySection::Subject => self.section = ReplySection::Body,
ReplySection::Body => {
if self.body_row + 1 < self.body.len() {
self.body_row += 1;
}
}
}
self.clamp_cursor();
self.adjust_scroll();
}
fn insert_char(&mut self, character: char) {
self.clamp_cursor();
let cursor_col = self.cursor_col;
let value = self.current_value_mut();
let byte_index = char_to_byte_index(value, cursor_col);
value.insert(byte_index, character);
self.cursor_col += 1;
self.mark_dirty();
self.adjust_scroll();
}
fn backspace(&mut self) -> bool {
self.clamp_cursor();
if self.cursor_col > 0 {
let cursor_col = self.cursor_col;
let value = self.current_value_mut();
let remove_at = cursor_col - 1;
let start = char_to_byte_index(value, remove_at);
let end = char_to_byte_index(value, remove_at + 1);
value.replace_range(start..end, "");
self.cursor_col -= 1;
self.mark_dirty();
self.adjust_scroll();
return true;
}
if !matches!(self.section, ReplySection::Body) || self.body_row == 0 {
return false;
}
let current = self.body.remove(self.body_row);
self.body_row -= 1;
let previous_len = self.body[self.body_row].chars().count();
self.body[self.body_row].push_str(¤t);
self.cursor_col = previous_len;
self.mark_dirty();
self.adjust_scroll();
true
}
fn delete_char(&mut self) -> bool {
self.clamp_cursor();
if matches!(self.section, ReplySection::Body) {
let row = self.body_row.min(self.body.len().saturating_sub(1));
let line_len = self.body[row].chars().count();
if self.cursor_col < line_len {
let value = &mut self.body[row];
let start = char_to_byte_index(value, self.cursor_col);
let end = char_to_byte_index(value, self.cursor_col + 1);
value.replace_range(start..end, "");
self.mark_dirty();
self.adjust_scroll();
return true;
}
if row + 1 < self.body.len() {
let next = self.body.remove(row + 1);
self.body[row].push_str(&next);
self.mark_dirty();
self.adjust_scroll();
return true;
}
return false;
}
let line_len = self.current_value_len();
if self.cursor_col >= line_len {
return false;
}
let cursor_col = self.cursor_col;
let value = self.current_value_mut();
let start = char_to_byte_index(value, cursor_col);
let end = char_to_byte_index(value, cursor_col + 1);
value.replace_range(start..end, "");
self.mark_dirty();
self.adjust_scroll();
true
}
fn insert_newline(&mut self) {
self.clamp_cursor();
if !matches!(self.section, ReplySection::Body) {
self.move_down();
self.cursor_col = self.current_value_len();
return;
}
let row = self.body_row.min(self.body.len().saturating_sub(1));
let original_line = self.body[row].clone();
let byte_index = char_to_byte_index(&original_line, self.cursor_col);
let tail = original_line[byte_index..].to_string();
let head = original_line[..byte_index].to_string();
self.body[row] = head;
self.body.insert(row + 1, tail);
self.body_row += 1;
self.cursor_col = 0;
self.mark_dirty();
self.adjust_scroll();
}
fn open_line_below(&mut self) {
self.clamp_cursor();
if !matches!(self.section, ReplySection::Body) {
self.move_down();
self.cursor_col = self.current_value_len();
return;
}
let insert_at = self.body_row.min(self.body.len().saturating_sub(1)) + 1;
self.body.insert(insert_at, String::new());
self.body_row = insert_at;
self.cursor_col = 0;
self.mark_dirty();
self.adjust_scroll();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KernelTreeRowKind {
RootDirectory,
Directory,
File,
RootFile,
MissingPath,
}
#[derive(Debug, Clone)]
struct KernelTreeRow {
path: PathBuf,
name: String,
depth: usize,
kind: KernelTreeRowKind,
expandable: bool,
expanded: bool,
}
impl KernelTreeRow {
fn is_file(&self) -> bool {
matches!(
self.kind,
KernelTreeRowKind::File | KernelTreeRowKind::RootFile
)
}
fn display_text(&self) -> String {
match self.kind {
KernelTreeRowKind::RootDirectory => {
let marker = if self.expandable {
if self.expanded { "â–¼" } else { "â–¶" }
} else {
"•"
};
format!("{marker} [root] {}", self.path.display())
}
KernelTreeRowKind::Directory => {
let marker = if self.expandable {
if self.expanded { "â–¼" } else { "â–¶" }
} else {
"•"
};
format!("{}{} {}/", " ".repeat(self.depth), marker, self.name)
}
KernelTreeRowKind::File => {
format!("{} {}", " ".repeat(self.depth), self.name)
}
KernelTreeRowKind::RootFile => format!("[file] {}", self.path.display()),
KernelTreeRowKind::MissingPath => format!("[missing] {}", self.path.display()),
}
}
}
#[derive(Debug)]
struct AppState {
runtime: RuntimeConfig,
ui_state_path: PathBuf,
active_thread_mailbox: String,
imap_defaults_initialized: bool,
ui_page: UiPage,
focus: Pane,
mail_pane_layout: MailPaneLayout,
code_focus: CodePaneFocus,
subscriptions: Vec<SubscriptionItem>,
enabled_group_expanded: bool,
disabled_group_expanded: bool,
enabled_linux_subsystem_expanded: bool,
enabled_qemu_subsystem_expanded: bool,
disabled_linux_subsystem_expanded: bool,
disabled_qemu_subsystem_expanded: bool,
threads: Vec<ThreadRow>,
series_summaries: HashMap<i64, patch_worker::SeriesSummary>,
filtered_thread_indices: Vec<usize>,
subscription_index: usize,
subscription_row_index: usize,
kernel_tree_rows: Vec<KernelTreeRow>,
kernel_tree_expanded_paths: HashSet<PathBuf>,
kernel_tree_row_index: usize,
code_preview_scroll: u16,
code_preview_scroll_limit: Cell<u16>,
code_edit_mode: CodeEditMode,
code_edit_target: Option<PathBuf>,
code_edit_buffer: Vec<String>,
code_edit_cursor_row: usize,
code_edit_cursor_col: usize,
code_edit_dirty: bool,
code_edit_command_input: String,
reply_panel: Option<ReplyPanelState>,
thread_index: usize,
preview_scroll: u16,
preview_scroll_limit: Cell<u16>,
selected_mail_preview: Option<MailPreview>,
started_at: Instant,
status: String,
last_apply_snapshot: Option<LastApplySnapshot>,
palette: CommandPaletteState,
search: SearchState,
config_editor: ConfigEditorState,
keymap_editor: KeymapEditorState,
external_editor_runner: ExternalEditorRunner,
reply_identity_resolver: ReplyIdentityResolver,
reply_send_executor: ReplySendExecutor,
mailbox_sync_spawner: MailboxSyncSpawner,
manual_sync_spawner: MailboxSyncSpawner,
needs_terminal_refresh: bool,
startup_sync: Option<StartupSyncState>,
inbox_auto_sync: Option<InboxAutoSyncState>,
manual_sync: Option<ManualSyncState>,
subscription_auto_sync: Option<SubscriptionAutoSyncState>,
main_page_keymap: ResolvedMainPageKeymap,
pending_main_page_sequence: Option<PendingMainPageSequenceState>,
pending_main_page_count: Option<PendingMainPageCountState>,
}
impl AppState {
fn new(threads: Vec<ThreadRow>, runtime: RuntimeConfig) -> Self {
Self::new_with_ui_state(threads, runtime, None)
}
fn new_with_ui_state(
threads: Vec<ThreadRow>,
runtime: RuntimeConfig,
persisted: Option<UiState>,
) -> Self {
let ui_state_path = ui_state::path_for_data_dir(&runtime.data_dir);
let mail_pane_layout = MailPaneLayout::from_persisted(persisted.as_ref());
let persisted_imap_defaults_initialized = persisted
.as_ref()
.map(|state| state.imap_defaults_initialized)
.unwrap_or(false);
let enabled_mailboxes = persisted
.as_ref()
.map(UiState::normalized_enabled_mailboxes)
.unwrap_or_default();
let enabled_mailboxes: HashSet<String> = enabled_mailboxes.into_iter().collect();
let active_thread_mailbox = persisted
.as_ref()
.and_then(|state| state.active_mailbox.as_ref())
.map(|mailbox| mailbox.trim())
.filter(|mailbox| !mailbox.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| runtime.default_active_mailbox().to_string());
let my_inbox_default = if runtime.imap.is_complete() && !persisted_imap_defaults_initialized
{
MyInboxDefault::EnableOnFirstOpen
} else {
MyInboxDefault::PreservePersistedChoice
};
let subscriptions = default_subscriptions(
&runtime,
&enabled_mailboxes,
Some(active_thread_mailbox.as_str()),
my_inbox_default,
);
let kernel_tree_expanded_paths = default_kernel_tree_expanded_paths(&runtime.kernel_trees);
let kernel_tree_rows =
build_kernel_tree_rows(&runtime.kernel_trees, &kernel_tree_expanded_paths);
let main_page_keymap = resolve_active_main_page_keymap(&runtime);
let mut state = Self {
active_thread_mailbox,
runtime,
ui_state_path,
imap_defaults_initialized: persisted_imap_defaults_initialized,
ui_page: UiPage::Mail,
focus: Pane::Subscriptions,
mail_pane_layout,
code_focus: CodePaneFocus::Tree,
subscriptions,
enabled_group_expanded: persisted
.as_ref()
.map(|state| state.enabled_group_expanded)
.unwrap_or(true),
disabled_group_expanded: persisted
.as_ref()
.map(|state| state.disabled_group_expanded)
.unwrap_or(true),
enabled_linux_subsystem_expanded: persisted
.as_ref()
.map(|state| state.enabled_linux_subsystem_expanded)
.unwrap_or(true),
enabled_qemu_subsystem_expanded: persisted
.as_ref()
.map(|state| state.enabled_qemu_subsystem_expanded)
.unwrap_or(true),
disabled_linux_subsystem_expanded: persisted
.as_ref()
.map(|state| state.disabled_linux_subsystem_expanded)
.unwrap_or(true),
disabled_qemu_subsystem_expanded: persisted
.as_ref()
.map(|state| state.disabled_qemu_subsystem_expanded)
.unwrap_or(true),
threads,
series_summaries: HashMap::new(),
filtered_thread_indices: Vec::new(),
subscription_index: 0,
subscription_row_index: 0,
kernel_tree_rows,
kernel_tree_expanded_paths,
kernel_tree_row_index: 0,
code_preview_scroll: 0,
code_preview_scroll_limit: Cell::new(u16::MAX),
code_edit_mode: CodeEditMode::Browse,
code_edit_target: None,
code_edit_buffer: Vec::new(),
code_edit_cursor_row: 0,
code_edit_cursor_col: 0,
code_edit_dirty: false,
code_edit_command_input: String::new(),
reply_panel: None,
thread_index: 0,
preview_scroll: 0,
preview_scroll_limit: Cell::new(u16::MAX),
selected_mail_preview: None,
started_at: Instant::now(),
status: String::new(),
last_apply_snapshot: None,
palette: CommandPaletteState::default(),
search: SearchState::default(),
config_editor: ConfigEditorState::default(),
keymap_editor: KeymapEditorState::default(),
external_editor_runner: run_external_editor_session,
reply_identity_resolver: resolve_git_reply_identity,
reply_send_executor: send_reply_message,
mailbox_sync_spawner: spawn_startup_sync_worker,
manual_sync_spawner: spawn_startup_sync_worker,
needs_terminal_refresh: false,
startup_sync: None,
inbox_auto_sync: None,
manual_sync: None,
subscription_auto_sync: None,
main_page_keymap,
pending_main_page_sequence: None,
pending_main_page_count: None,
};
if state.runtime.imap.is_complete() {
state.imap_defaults_initialized = true;
}
if let Some(index) = state
.subscriptions
.iter()
.position(|item| same_mailbox_name(&item.mailbox, &state.active_thread_mailbox))
{
state.subscription_index = index;
}
state.refresh_series_summaries();
state.apply_thread_filter();
state.sync_subscription_row_to_selected_item();
state.reconcile_inbox_auto_sync();
state.reconcile_subscription_auto_sync();
state
}
fn apply_thread_filter(&mut self) {
let query = self.search.applied_query.trim().to_ascii_lowercase();
self.filtered_thread_indices = self
.threads
.iter()
.enumerate()
.filter_map(|(index, row)| {
if query.is_empty()
|| row.subject.to_ascii_lowercase().contains(&query)
|| row.from_addr.to_ascii_lowercase().contains(&query)
|| row.message_id.to_ascii_lowercase().contains(&query)
{
Some(index)
} else {
None
}
})
.collect();
if self.thread_index >= self.filtered_thread_indices.len() {
self.thread_index = self.filtered_thread_indices.len().saturating_sub(1);
}
if !query.is_empty() {
self.status = format!(
"search '{}': {} matches",
self.search.applied_query,
self.filtered_thread_indices.len()
);
}
self.refresh_selected_mail_preview();
}
fn replace_threads(&mut self, threads: Vec<ThreadRow>) {
self.threads = threads;
self.refresh_series_summaries();
self.thread_index = 0;
self.preview_scroll = 0;
self.apply_thread_filter();
}
fn replace_threads_preserving_selection(&mut self, threads: Vec<ThreadRow>) {
let selected_message_id = self.selected_thread().map(|row| row.message_id.clone());
let selected_thread_id = self.selected_thread().map(|row| row.thread_id);
let selected_preview_scroll = self.preview_scroll;
self.replace_threads(threads);
if let Some(message_id) = selected_message_id
&& let Some(position) = self.filtered_thread_indices.iter().position(|index| {
self.threads
.get(*index)
.is_some_and(|row| row.message_id == message_id)
})
{
self.thread_index = position;
self.preview_scroll = selected_preview_scroll;
self.refresh_selected_mail_preview();
return;
}
if let Some(thread_id) = selected_thread_id
&& let Some(position) = self.filtered_thread_indices.iter().position(|index| {
self.threads
.get(*index)
.is_some_and(|row| row.thread_id == thread_id)
})
{
self.thread_index = position;
self.preview_scroll = selected_preview_scroll;
self.refresh_selected_mail_preview();
}
}
fn show_mailbox_threads(
&mut self,
mailbox: &str,
threads: Vec<ThreadRow>,
status: String,
persist_ui_state: bool,
) {
self.active_thread_mailbox = mailbox.to_string();
if let Some(index) = self
.subscriptions
.iter()
.position(|item| same_mailbox_name(&item.mailbox, mailbox))
{
self.subscription_index = index;
self.sync_subscription_row_to_selected_item();
}
self.replace_threads(threads);
self.status = status;
if persist_ui_state {
self.persist_ui_state();
}
}
fn recover_from_empty_active_mailbox(&mut self, reason: &str) -> bool {
if !self.threads.is_empty() {
return false;
}
let current_mailbox = self.active_thread_mailbox.clone();
let mut candidates = self.enabled_mailboxes();
if !candidates
.iter()
.any(|mailbox| same_mailbox_name(mailbox, &self.runtime.source_mailbox))
{
candidates.push(self.runtime.source_mailbox.clone());
}
let mut unique_candidates: Vec<String> = Vec::new();
for mailbox in candidates {
if same_mailbox_name(&mailbox, ¤t_mailbox)
|| unique_candidates
.iter()
.any(|candidate| same_mailbox_name(candidate, &mailbox))
{
continue;
}
unique_candidates.push(mailbox);
}
for mailbox in unique_candidates {
match mail_store::load_thread_rows_by_mailbox(
&self.runtime.database_path,
&mailbox,
500,
) {
Ok(rows) if !rows.is_empty() => {
self.show_mailbox_threads(
&mailbox,
rows,
format!("{reason}; showing threads for {mailbox}"),
true,
);
return true;
}
Ok(_) => {}
Err(error) => {
tracing::warn!(
mailbox = %mailbox,
error = %error,
"failed to load fallback mailbox thread rows"
);
}
}
}
false
}
fn startup_sync_mailbox_status(&self, mailbox: &str) -> Option<StartupSyncMailboxStatus> {
self.startup_sync.as_ref().and_then(|state| {
state
.mailboxes
.iter()
.find(|(name, _)| same_mailbox_name(name, mailbox))
.map(|(_, status)| *status)
})
}
fn manual_sync_mailbox_status(&self, mailbox: &str) -> Option<StartupSyncMailboxStatus> {
self.manual_sync.as_ref().and_then(|state| {
state
.mailboxes
.iter()
.find(|(name, _)| same_mailbox_name(name, mailbox))
.map(|(_, status)| *status)
})
}
fn inbox_auto_sync_mailbox_status(&self, mailbox: &str) -> Option<StartupSyncMailboxStatus> {
mailbox
.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX)
.then(|| {
self.inbox_auto_sync
.as_ref()
.filter(|state| state.in_flight())
.map(|_| StartupSyncMailboxStatus::InFlight)
})
.flatten()
}
fn subscription_auto_sync_mailbox_status(
&self,
mailbox: &str,
) -> Option<StartupSyncMailboxStatus> {
self.subscription_auto_sync
.as_ref()
.filter(|state| state.mailbox_in_flight(mailbox))
.map(|_| StartupSyncMailboxStatus::InFlight)
}
fn mailbox_sync_status(&self, mailbox: &str) -> Option<StartupSyncMailboxStatus> {
self.startup_sync_mailbox_status(mailbox)
.or_else(|| self.manual_sync_mailbox_status(mailbox))
.or_else(|| self.inbox_auto_sync_mailbox_status(mailbox))
.or_else(|| self.subscription_auto_sync_mailbox_status(mailbox))
}
fn startup_sync_mailbox_pending(&self, mailbox: &str) -> bool {
matches!(
self.startup_sync_mailbox_status(mailbox),
Some(StartupSyncMailboxStatus::Pending | StartupSyncMailboxStatus::InFlight)
)
}
fn manual_sync_mailbox_pending(&self, mailbox: &str) -> bool {
matches!(
self.manual_sync_mailbox_status(mailbox),
Some(StartupSyncMailboxStatus::Pending | StartupSyncMailboxStatus::InFlight)
)
}
fn mailbox_sync_pending(&self, mailbox: &str) -> bool {
self.startup_sync_mailbox_pending(mailbox)
|| self.manual_sync_mailbox_pending(mailbox)
|| matches!(
self.inbox_auto_sync_mailbox_status(mailbox),
Some(StartupSyncMailboxStatus::InFlight)
)
|| matches!(
self.subscription_auto_sync_mailbox_status(mailbox),
Some(StartupSyncMailboxStatus::InFlight)
)
}
fn background_sync_progress_text(&self) -> Option<String> {
self.manual_sync
.as_ref()
.map(|state| {
format!(
"sync {} {}/{} {}",
self.render_progress_bar(state.completed, state.total),
state.completed,
state.total,
state.inflight_mailboxes_display()
)
})
.or_else(|| {
self.startup_sync.as_ref().map(|state| {
format!(
"sync {} {}/{} {}",
self.render_progress_bar(state.completed, state.total),
state.completed,
state.total,
state.inflight_mailboxes_display()
)
})
})
.or_else(|| {
self.inbox_auto_sync
.as_ref()
.filter(|state| state.in_flight())
.map(|_| {
format!(
"sync {} auto {}",
self.render_indeterminate_progress_bar(),
IMAP_INBOX_MAILBOX
)
})
})
.or_else(|| {
self.subscription_auto_sync
.as_ref()
.filter(|state| state.in_flight())
.map(|state| {
format!(
"sync {} auto {}",
self.render_indeterminate_progress_bar(),
state.inflight_mailboxes_display()
)
})
})
}
fn render_progress_bar(&self, completed: usize, total: usize) -> String {
const PROGRESS_BAR_WIDTH: usize = 12;
let mut cells = vec!['.'; PROGRESS_BAR_WIDTH];
if total == 0 {
return format!("[{}]", cells.into_iter().collect::<String>());
}
let filled = completed.saturating_mul(PROGRESS_BAR_WIDTH) / total;
for cell in cells.iter_mut().take(filled.min(PROGRESS_BAR_WIDTH)) {
*cell = '=';
}
if completed < total {
let pulse_width = PROGRESS_BAR_WIDTH.saturating_sub(filled).max(1);
let pulse_offset = self.sync_animation_tick() % pulse_width;
let pulse_index = (filled + pulse_offset).min(PROGRESS_BAR_WIDTH - 1);
cells[pulse_index] = '>';
}
format!("[{}]", cells.into_iter().collect::<String>())
}
fn render_indeterminate_progress_bar(&self) -> String {
const PROGRESS_BAR_WIDTH: usize = 12;
const RUNNER_WIDTH: usize = 3;
let mut cells = vec!['.'; PROGRESS_BAR_WIDTH];
let start = self.sync_animation_tick() % PROGRESS_BAR_WIDTH;
for step in 0..RUNNER_WIDTH {
let index = (start + step) % PROGRESS_BAR_WIDTH;
cells[index] = '>';
}
format!("[{}]", cells.into_iter().collect::<String>())
}
fn sync_animation_tick(&self) -> usize {
(self.started_at.elapsed().as_millis() / 200) as usize
}
fn refresh_series_summaries(&mut self) {
self.series_summaries =
patch_worker::build_series_index(&self.active_thread_mailbox, &self.threads);
if let Err(error) = patch_worker::hydrate_series_statuses(
&self.runtime.database_path,
&self.active_thread_mailbox,
&mut self.series_summaries,
) {
tracing::warn!(
mailbox = %self.active_thread_mailbox,
error = %error,
"failed to hydrate patch series status from database"
);
}
}
fn enabled_mailboxes(&self) -> Vec<String> {
self.subscriptions
.iter()
.filter(|item| item.enabled)
.map(|item| item.mailbox.clone())
.collect()
}
fn enabled_background_sync_mailboxes(&self) -> Vec<String> {
self.subscriptions
.iter()
.filter(|item| item.enabled && !item.mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX))
.map(|item| item.mailbox.clone())
.collect()
}
fn my_inbox_auto_sync_enabled(&self) -> bool {
self.runtime.imap.is_complete()
&& self
.subscriptions
.iter()
.any(|item| item.enabled && item.mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX))
}
fn subscription_auto_sync_enabled(&self) -> bool {
!self.enabled_background_sync_mailboxes().is_empty()
}
fn reconcile_inbox_auto_sync(&mut self) {
if self.my_inbox_auto_sync_enabled() {
self.inbox_auto_sync.get_or_insert_with(|| {
InboxAutoSyncState::new(Instant::now() + self.runtime.inbox_auto_sync_interval())
});
} else {
self.inbox_auto_sync = None;
}
}
fn reconcile_subscription_auto_sync(&mut self) {
let enabled_mailboxes = self.enabled_background_sync_mailboxes();
if self.subscription_auto_sync_enabled() {
let state = self.subscription_auto_sync.get_or_insert_with(|| {
SubscriptionAutoSyncState::new(
Instant::now() + self.runtime.inbox_auto_sync_interval(),
)
});
state.in_flight_mailboxes.retain(|mailbox| {
enabled_mailboxes
.iter()
.any(|enabled| same_mailbox_name(enabled, mailbox))
});
} else {
self.subscription_auto_sync = None;
}
}
fn defer_inbox_auto_sync(&mut self) {
if let Some(state) = self.inbox_auto_sync.as_mut() {
state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval();
}
}
fn defer_subscription_auto_sync(&mut self) {
if let Some(state) = self.subscription_auto_sync.as_mut() {
state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval();
}
}
fn start_manual_sync(
&mut self,
requested_mailboxes: Vec<String>,
origin: ManualSyncOrigin,
) -> ManualSyncRequestOutcome {
let requested_mailboxes = dedup_mailboxes(requested_mailboxes);
if requested_mailboxes.is_empty() {
self.status = "sync skipped: no mailbox selected".to_string();
tracing::info!(
op = "manual_sync",
status = "skipped",
reason = "no_mailboxes",
origin = origin.log_label()
);
return ManualSyncRequestOutcome::Busy;
}
if let Some(sync_state) = self.manual_sync.as_ref() {
let all_tracked = requested_mailboxes.iter().all(|mailbox| {
sync_state
.mailboxes
.keys()
.any(|tracked| same_mailbox_name(tracked, mailbox))
});
self.status = if all_tracked {
format!(
"sync already running in background: {}",
requested_mailboxes.join(", ")
)
} else {
format!("background sync busy: {}", sync_state.progress_summary())
};
tracing::info!(
op = "manual_sync",
status = "skipped",
reason = if all_tracked {
"mailboxes_already_syncing"
} else {
"manual_sync_busy"
},
origin = origin.log_label(),
requested_mailboxes = %requested_mailboxes.join(",")
);
return if all_tracked {
ManualSyncRequestOutcome::AlreadySyncing
} else {
ManualSyncRequestOutcome::Busy
};
}
let mut skipped_mailboxes = Vec::new();
let mut queued_mailboxes = Vec::new();
for mailbox in requested_mailboxes {
if self.mailbox_sync_pending(&mailbox) {
skipped_mailboxes.push(mailbox);
} else {
queued_mailboxes.push(mailbox);
}
}
if queued_mailboxes.is_empty() {
self.status = format!(
"sync already running in background: {}",
skipped_mailboxes.join(", ")
);
tracing::info!(
op = "manual_sync",
status = "skipped",
reason = "mailboxes_already_syncing",
origin = origin.log_label(),
requested_mailboxes = %skipped_mailboxes.join(",")
);
return ManualSyncRequestOutcome::AlreadySyncing;
}
if queued_mailboxes
.iter()
.any(|mailbox| mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX))
{
self.defer_inbox_auto_sync();
}
if queued_mailboxes
.iter()
.any(|mailbox| !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX))
{
self.defer_subscription_auto_sync();
}
let receiver = (self.manual_sync_spawner)(self.runtime.clone(), queued_mailboxes.clone());
self.manual_sync = Some(ManualSyncState {
receiver,
mailbox_order: queued_mailboxes.clone(),
mailboxes: queued_mailboxes
.iter()
.cloned()
.map(|mailbox| (mailbox, StartupSyncMailboxStatus::Pending))
.collect(),
total: queued_mailboxes.len(),
completed: 0,
succeeded: 0,
failed: 0,
total_fetched: 0,
total_inserted: 0,
total_updated: 0,
first_error: None,
});
self.status = if skipped_mailboxes.is_empty() {
format!("sync queued in background: {}", queued_mailboxes.join(", "))
} else {
format!(
"sync queued in background: {}; skipped already-running: {}",
queued_mailboxes.join(", "),
skipped_mailboxes.join(", ")
)
};
if let Some(sync_state) = self.manual_sync.as_ref() {
tracing::info!(
op = "manual_sync",
status = "started",
origin = origin.log_label(),
total = sync_state.total,
completed = sync_state.completed,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display(),
requested_mailboxes = %sync_state.mailbox_order.join(",")
);
}
ManualSyncRequestOutcome::Started
}
fn queue_palette_sync(&mut self, requested_mailboxes: Vec<String>) {
let _ = self.start_manual_sync(requested_mailboxes, ManualSyncOrigin::PaletteCommand);
}
fn maybe_start_inbox_auto_sync(&mut self) {
self.reconcile_inbox_auto_sync();
let inbox_sync_pending = self.startup_sync_mailbox_pending(IMAP_INBOX_MAILBOX)
|| self.manual_sync_mailbox_pending(IMAP_INBOX_MAILBOX);
let now = Instant::now();
let Some(state) = self.inbox_auto_sync.as_mut() else {
return;
};
if state.in_flight() || inbox_sync_pending || now < state.next_due_at {
return;
}
tracing::info!(
op = "inbox_auto_sync",
status = "started",
mailbox = IMAP_INBOX_MAILBOX
);
state.receiver = Some((self.mailbox_sync_spawner)(
self.runtime.clone(),
vec![IMAP_INBOX_MAILBOX.to_string()],
));
}
fn maybe_start_subscription_auto_sync(&mut self) {
self.reconcile_subscription_auto_sync();
let mailboxes = self.enabled_background_sync_mailboxes();
let background_pending = mailboxes.iter().any(|mailbox| {
self.startup_sync_mailbox_pending(mailbox) || self.manual_sync_mailbox_pending(mailbox)
});
let now = Instant::now();
let Some(state) = self.subscription_auto_sync.as_mut() else {
return;
};
if mailboxes.is_empty()
|| state.in_flight()
|| background_pending
|| now < state.next_due_at
{
return;
}
tracing::info!(
op = "subscription_auto_sync",
status = "started",
mailboxes = %mailboxes.join(",")
);
state.in_flight_mailboxes.clear();
state.receiver = Some((self.mailbox_sync_spawner)(self.runtime.clone(), mailboxes));
}
fn pump_inbox_auto_sync_events(&mut self) {
let mut events = Vec::new();
let mut disconnected = false;
{
let Some(sync_state) = self.inbox_auto_sync.as_ref() else {
return;
};
let Some(receiver) = sync_state.receiver.as_ref() else {
return;
};
loop {
match receiver.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
let mut worker_completed = false;
for event in events {
if matches!(event, StartupSyncEvent::WorkerCompleted) {
worker_completed = true;
}
self.apply_inbox_auto_sync_event(event);
}
if (disconnected || worker_completed)
&& let Some(state) = self.inbox_auto_sync.as_mut()
{
state.receiver = None;
}
}
fn pump_manual_sync_events(&mut self) {
let mut events = Vec::new();
let mut disconnected = false;
{
let Some(sync_state) = self.manual_sync.as_ref() else {
return;
};
loop {
match sync_state.receiver.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
for event in events {
self.apply_manual_sync_event(event);
}
if disconnected && self.manual_sync.is_some() {
let (completed, total, succeeded, failed) = self
.manual_sync
.as_ref()
.map(|state| (state.completed, state.total, state.succeeded, state.failed))
.unwrap_or((0, 0, 0, 0));
self.manual_sync = None;
self.status = format!(
"background sync worker disconnected (completed={completed}/{total} ok={succeeded} failed={failed})"
);
tracing::warn!(
op = "manual_sync",
status = "failed",
completed,
total,
succeeded,
failed,
"manual sync worker disconnected unexpectedly"
);
}
}
fn pump_subscription_auto_sync_events(&mut self) {
let mut events = Vec::new();
let mut disconnected = false;
{
let Some(sync_state) = self.subscription_auto_sync.as_ref() else {
return;
};
let Some(receiver) = sync_state.receiver.as_ref() else {
return;
};
loop {
match receiver.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
let mut worker_completed = false;
for event in events {
if matches!(event, StartupSyncEvent::WorkerCompleted) {
worker_completed = true;
}
self.apply_subscription_auto_sync_event(event);
}
if (disconnected || worker_completed)
&& let Some(state) = self.subscription_auto_sync.as_mut()
{
state.receiver = None;
state.in_flight_mailboxes.clear();
state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval();
}
}
fn apply_manual_sync_event(&mut self, event: StartupSyncEvent) {
match event {
StartupSyncEvent::MailboxStarted {
mailbox,
index,
total,
} => {
if let Some(sync_state) = self.manual_sync.as_mut() {
sync_state
.mailboxes
.insert(mailbox.clone(), StartupSyncMailboxStatus::InFlight);
}
self.status = format!("sync [{index}/{total}] syncing {mailbox} in background...");
if let Some(sync_state) = self.manual_sync.as_ref() {
tracing::info!(
op = "manual_sync",
status = "progress",
phase = "started",
mailbox = %mailbox,
index,
total,
completed = sync_state.completed,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display()
);
}
}
StartupSyncEvent::MailboxFinished {
mailbox,
fetched,
inserted,
updated,
} => {
if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
self.defer_inbox_auto_sync();
} else {
self.defer_subscription_auto_sync();
}
if let Some(sync_state) = self.manual_sync.as_mut() {
sync_state
.mailboxes
.insert(mailbox.clone(), StartupSyncMailboxStatus::Finished);
sync_state.completed += 1;
sync_state.succeeded += 1;
sync_state.total_fetched += fetched;
sync_state.total_inserted += inserted;
sync_state.total_updated += updated;
}
if let Some(sync_state) = self.manual_sync.as_ref() {
self.status = format!(
"sync [{}/{}] finished {}",
sync_state.completed, sync_state.total, mailbox
);
tracing::info!(
op = "manual_sync",
status = "succeeded",
phase = "finished",
mailbox = %mailbox,
fetched,
inserted,
updated,
completed = sync_state.completed,
total = sync_state.total,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display()
);
}
}
StartupSyncEvent::MailboxFailed { mailbox, error } => {
if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
self.defer_inbox_auto_sync();
} else {
self.defer_subscription_auto_sync();
}
if let Some(sync_state) = self.manual_sync.as_mut() {
sync_state
.mailboxes
.insert(mailbox.clone(), StartupSyncMailboxStatus::Failed);
sync_state.completed += 1;
sync_state.failed += 1;
if sync_state.first_error.is_none() {
sync_state.first_error = Some(format!("{mailbox}: {error}"));
}
}
self.status = format!("sync failed for {mailbox}: {error}");
if let Some(sync_state) = self.manual_sync.as_ref() {
tracing::error!(
op = "manual_sync",
status = "failed",
phase = "finished",
mailbox = %mailbox,
error = %error,
completed = sync_state.completed,
total = sync_state.total,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display()
);
}
}
StartupSyncEvent::WorkerCompleted => {}
}
self.maybe_finish_manual_sync();
}
fn apply_inbox_auto_sync_event(&mut self, event: StartupSyncEvent) {
match event {
StartupSyncEvent::MailboxStarted { mailbox, .. } => {
tracing::info!(
op = "inbox_auto_sync",
status = "progress",
phase = "started",
mailbox = %mailbox
);
}
StartupSyncEvent::MailboxFinished {
mailbox,
fetched,
inserted,
updated,
} => {
if let Some(state) = self.inbox_auto_sync.as_mut() {
state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval();
}
if same_mailbox_name(&mailbox, &self.active_thread_mailbox) {
if let Err(error) = self.reload_mailbox_threads_preserving_selection(&mailbox) {
tracing::error!(
op = "inbox_auto_sync",
status = "failed",
mailbox = %mailbox,
error = %error
);
self.status = format!(
"background sync ok but failed to reload threads for {mailbox}: {error}"
);
}
}
if inserted > 0 || updated > 0 {
self.status = format!(
"My Inbox auto-sync: fetched={} inserted={} updated={}",
fetched, inserted, updated
);
}
tracing::info!(
op = "inbox_auto_sync",
status = "succeeded",
mailbox = %mailbox,
fetched,
inserted,
updated
);
}
StartupSyncEvent::MailboxFailed { mailbox, error } => {
if let Some(state) = self.inbox_auto_sync.as_mut() {
state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval();
}
self.status = format!("My Inbox auto-sync failed: {error}");
tracing::error!(
op = "inbox_auto_sync",
status = "failed",
mailbox = %mailbox,
error = %error
);
}
StartupSyncEvent::WorkerCompleted => {}
}
}
fn apply_subscription_auto_sync_event(&mut self, event: StartupSyncEvent) {
match event {
StartupSyncEvent::MailboxStarted { mailbox, .. } => {
if let Some(state) = self.subscription_auto_sync.as_mut() {
state.in_flight_mailboxes.insert(mailbox.clone());
}
tracing::info!(
op = "subscription_auto_sync",
status = "progress",
phase = "started",
mailbox = %mailbox
);
}
StartupSyncEvent::MailboxFinished {
mailbox,
fetched,
inserted,
updated,
} => {
if let Some(state) = self.subscription_auto_sync.as_mut() {
state
.in_flight_mailboxes
.retain(|in_flight| !same_mailbox_name(in_flight, &mailbox));
}
if same_mailbox_name(&mailbox, &self.active_thread_mailbox) {
if let Err(error) = self.reload_mailbox_threads_preserving_selection(&mailbox) {
tracing::error!(
op = "subscription_auto_sync",
status = "failed",
mailbox = %mailbox,
error = %error
);
self.status = format!(
"background sync ok but failed to reload threads for {mailbox}: {error}"
);
}
}
if inserted > 0 || updated > 0 {
self.status = format!(
"Subscription auto-sync {mailbox}: fetched={} inserted={} updated={}",
fetched, inserted, updated
);
}
tracing::info!(
op = "subscription_auto_sync",
status = "succeeded",
mailbox = %mailbox,
fetched,
inserted,
updated
);
}
StartupSyncEvent::MailboxFailed { mailbox, error } => {
if let Some(state) = self.subscription_auto_sync.as_mut() {
state
.in_flight_mailboxes
.retain(|in_flight| !same_mailbox_name(in_flight, &mailbox));
}
self.status = format!("subscription auto-sync failed for {mailbox}: {error}");
tracing::error!(
op = "subscription_auto_sync",
status = "failed",
mailbox = %mailbox,
error = %error
);
}
StartupSyncEvent::WorkerCompleted => {}
}
}
fn start_startup_sync_if_enabled(&mut self) {
if !self.runtime.startup_sync {
tracing::info!(
op = "startup_sync",
status = "disabled",
reason = "ui.startup_sync=false"
);
return;
}
let mailboxes = self.enabled_mailboxes();
if mailboxes.is_empty() {
tracing::info!(
op = "startup_sync",
status = "skipped",
reason = "no_enabled_subscriptions"
);
return;
}
let receiver = spawn_startup_sync_worker(self.runtime.clone(), mailboxes.clone());
self.startup_sync = Some(StartupSyncState {
receiver,
mailbox_order: mailboxes.clone(),
mailboxes: mailboxes
.iter()
.cloned()
.map(|mailbox| (mailbox, StartupSyncMailboxStatus::Pending))
.collect(),
total: mailboxes.len(),
completed: 0,
succeeded: 0,
failed: 0,
});
self.status = format!(
"startup sync queued: {} mailbox(es): {}",
mailboxes.len(),
mailboxes.join(", ")
);
if let Some(sync_state) = self.startup_sync.as_ref() {
tracing::info!(
op = "startup_sync",
status = "started",
total = sync_state.total,
completed = sync_state.completed,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display(),
mailboxes = %mailboxes.join(",")
);
}
}
fn pump_startup_sync_events(&mut self) {
let mut events = Vec::new();
let mut disconnected = false;
{
let Some(sync_state) = self.startup_sync.as_ref() else {
return;
};
loop {
match sync_state.receiver.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
for event in events {
self.apply_startup_sync_event(event);
}
if disconnected && self.startup_sync.is_some() {
let (completed, total, succeeded, failed) = self
.startup_sync
.as_ref()
.map(|state| (state.completed, state.total, state.succeeded, state.failed))
.unwrap_or((0, 0, 0, 0));
self.startup_sync = None;
self.status = format!(
"startup sync worker disconnected (completed={completed}/{total} ok={succeeded} failed={failed})"
);
tracing::warn!(
op = "startup_sync",
status = "failed",
completed,
total,
succeeded,
failed,
"startup sync worker disconnected unexpectedly"
);
}
}
fn apply_startup_sync_event(&mut self, event: StartupSyncEvent) {
match event {
StartupSyncEvent::MailboxStarted {
mailbox,
index,
total,
} => {
if let Some(sync_state) = self.startup_sync.as_mut() {
sync_state
.mailboxes
.insert(mailbox.clone(), StartupSyncMailboxStatus::InFlight);
}
self.status = format!("startup sync [{index}/{total}] syncing {mailbox}...");
if let Some(sync_state) = self.startup_sync.as_ref() {
tracing::info!(
op = "startup_sync",
status = "progress",
phase = "started",
mailbox = %mailbox,
index,
total,
completed = sync_state.completed,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display()
);
}
}
StartupSyncEvent::MailboxFinished {
mailbox,
fetched,
inserted,
updated,
} => {
if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
self.defer_inbox_auto_sync();
}
if !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
self.defer_subscription_auto_sync();
}
if let Some(sync_state) = self.startup_sync.as_mut() {
sync_state
.mailboxes
.insert(mailbox.clone(), StartupSyncMailboxStatus::Finished);
sync_state.completed += 1;
sync_state.succeeded += 1;
}
if same_mailbox_name(&mailbox, &self.active_thread_mailbox) {
self.reload_active_mailbox_threads_after_sync();
}
if self.threads.is_empty()
&& !self.startup_sync_mailbox_pending(&self.active_thread_mailbox)
{
let _ = self.recover_from_empty_active_mailbox(&format!(
"startup sync ready for {mailbox}"
));
}
if let Some(sync_state) = self.startup_sync.as_ref() {
tracing::info!(
op = "startup_sync",
status = "succeeded",
phase = "finished",
mailbox = %mailbox,
fetched,
inserted,
updated,
completed = sync_state.completed,
total = sync_state.total,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display()
);
}
}
StartupSyncEvent::MailboxFailed { mailbox, error } => {
if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
self.defer_inbox_auto_sync();
}
if !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
self.defer_subscription_auto_sync();
}
if let Some(sync_state) = self.startup_sync.as_mut() {
sync_state
.mailboxes
.insert(mailbox.clone(), StartupSyncMailboxStatus::Failed);
sync_state.completed += 1;
sync_state.failed += 1;
}
self.status = format!("startup sync failed for {mailbox}: {error}");
if same_mailbox_name(&mailbox, &self.active_thread_mailbox)
&& self.threads.is_empty()
{
let _ = self.recover_from_empty_active_mailbox(&format!(
"startup sync failed for {mailbox}: {error}"
));
}
if let Some(sync_state) = self.startup_sync.as_ref() {
tracing::error!(
op = "startup_sync",
status = "failed",
phase = "finished",
mailbox = %mailbox,
error = %error,
completed = sync_state.completed,
total = sync_state.total,
succeeded = sync_state.succeeded,
failed = sync_state.failed,
queued = sync_state.pending_count(),
running = %sync_state.inflight_mailboxes_display(),
mailbox_states = %sync_state.mailbox_states_display()
);
}
}
StartupSyncEvent::WorkerCompleted => {}
}
self.maybe_finish_startup_sync();
}
fn maybe_finish_startup_sync(&mut self) {
let Some(sync_state) = self.startup_sync.as_ref() else {
return;
};
if sync_state.completed < sync_state.total {
return;
}
let succeeded = sync_state.succeeded;
let failed = sync_state.failed;
let total = sync_state.total;
let mailbox_states = sync_state.mailbox_states_display();
self.startup_sync = None;
self.status =
format!("startup sync finished: ok={succeeded} failed={failed} total={total}");
tracing::info!(
op = "startup_sync",
status = if failed == 0 { "succeeded" } else { "partial" },
succeeded,
failed,
total,
mailbox_states = %mailbox_states
);
}
fn maybe_finish_manual_sync(&mut self) {
let Some(sync_state) = self.manual_sync.as_ref() else {
return;
};
if sync_state.completed < sync_state.total {
return;
}
let succeeded = sync_state.succeeded;
let failed = sync_state.failed;
let total = sync_state.total;
let total_fetched = sync_state.total_fetched;
let total_inserted = sync_state.total_inserted;
let total_updated = sync_state.total_updated;
let first_error = sync_state.first_error.clone();
let first_error_text = first_error
.clone()
.unwrap_or_else(|| "worker reported no success".to_string());
let mailbox_states = sync_state.mailbox_states_display();
let active_mailbox = self.active_thread_mailbox.clone();
let should_reload_active_mailbox = sync_state.mailboxes.iter().any(|(mailbox, status)| {
same_mailbox_name(mailbox, &active_mailbox)
&& *status == StartupSyncMailboxStatus::Finished
});
self.manual_sync = None;
if should_reload_active_mailbox
&& let Err(error) = self.reload_mailbox_threads_preserving_selection(&active_mailbox)
{
tracing::error!(
op = "manual_sync",
status = "failed",
mailbox = %active_mailbox,
error = %error
);
self.status =
format!("sync ok but failed to reload threads for {active_mailbox}: {error}");
return;
}
self.status = if failed == 0 {
format!(
"sync finished: ok={succeeded} total={total} fetched={total_fetched} inserted={total_inserted} updated={total_updated}"
)
} else if succeeded == 0 {
format!("sync failed: {first_error_text}")
} else {
format!(
"sync finished with failures: ok={succeeded} failed={failed} fetched={total_fetched} inserted={total_inserted} updated={total_updated}"
)
};
tracing::info!(
op = "manual_sync",
status = if failed == 0 {
"succeeded"
} else if succeeded == 0 {
"failed"
} else {
"partial"
},
succeeded,
failed,
total,
total_fetched,
total_inserted,
total_updated,
first_error = %first_error.as_deref().unwrap_or("-"),
mailbox_states = %mailbox_states
);
}
fn reload_active_mailbox_threads_after_sync(&mut self) {
let mailbox = self.active_thread_mailbox.clone();
match self.reload_mailbox_threads_preserving_selection(&mailbox) {
Ok(()) => {}
Err(error) => {
tracing::error!(
op = "startup_sync",
status = "failed",
mailbox = %self.active_thread_mailbox,
error = %error
);
self.status = format!(
"startup sync ok but failed to reload threads for {}: {}",
self.active_thread_mailbox, error
);
}
}
}
fn reload_mailbox_threads_preserving_selection(&mut self, mailbox: &str) -> Result<()> {
let rows =
mail_store::load_thread_rows_by_mailbox(&self.runtime.database_path, mailbox, 500)?;
if same_mailbox_name(mailbox, &self.active_thread_mailbox) {
self.replace_threads_preserving_selection(rows);
}
Ok(())
}
fn to_ui_state(&self) -> UiState {
UiState {
enabled_mailboxes: self.enabled_mailboxes(),
enabled_group_expanded: self.enabled_group_expanded,
disabled_group_expanded: self.disabled_group_expanded,
enabled_linux_subsystem_expanded: self.enabled_linux_subsystem_expanded,
enabled_qemu_subsystem_expanded: self.enabled_qemu_subsystem_expanded,
disabled_linux_subsystem_expanded: self.disabled_linux_subsystem_expanded,
disabled_qemu_subsystem_expanded: self.disabled_qemu_subsystem_expanded,
imap_defaults_initialized: self.imap_defaults_initialized,
active_mailbox: Some(self.active_thread_mailbox.clone()),
mail_subscriptions_width: self.mail_pane_layout.subscriptions_width,
mail_preview_width: self.mail_pane_layout.preview_width,
}
}
fn persist_ui_state(&self) {
if let Err(error) = ui_state::save(&self.ui_state_path, &self.to_ui_state()) {
tracing::warn!(
path = %self.ui_state_path.display(),
error = %error,
"failed to persist ui state"
);
}
}
fn subscription_category_expanded(
&self,
section: SubscriptionSection,
category: SubscriptionCategory,
) -> bool {
match (section, category) {
(SubscriptionSection::Enabled, SubscriptionCategory::LinuxSubsystem) => {
self.enabled_linux_subsystem_expanded
}
(SubscriptionSection::Enabled, SubscriptionCategory::QemuSubsystem) => {
self.enabled_qemu_subsystem_expanded
}
(SubscriptionSection::Disabled, SubscriptionCategory::LinuxSubsystem) => {
self.disabled_linux_subsystem_expanded
}
(SubscriptionSection::Disabled, SubscriptionCategory::QemuSubsystem) => {
self.disabled_qemu_subsystem_expanded
}
}
}
fn toggle_subscription_category_group(
&mut self,
section: SubscriptionSection,
category: SubscriptionCategory,
) {
let expanded = match (section, category) {
(SubscriptionSection::Enabled, SubscriptionCategory::LinuxSubsystem) => {
self.enabled_linux_subsystem_expanded = !self.enabled_linux_subsystem_expanded;
self.enabled_linux_subsystem_expanded
}
(SubscriptionSection::Enabled, SubscriptionCategory::QemuSubsystem) => {
self.enabled_qemu_subsystem_expanded = !self.enabled_qemu_subsystem_expanded;
self.enabled_qemu_subsystem_expanded
}
(SubscriptionSection::Disabled, SubscriptionCategory::LinuxSubsystem) => {
self.disabled_linux_subsystem_expanded = !self.disabled_linux_subsystem_expanded;
self.disabled_linux_subsystem_expanded
}
(SubscriptionSection::Disabled, SubscriptionCategory::QemuSubsystem) => {
self.disabled_qemu_subsystem_expanded = !self.disabled_qemu_subsystem_expanded;
self.disabled_qemu_subsystem_expanded
}
};
let state = if expanded { "expanded" } else { "collapsed" };
self.status = format!("{} {} group {}", section.label(), category.label(), state);
self.clamp_subscription_row_selection();
self.persist_ui_state();
}
fn push_subscription_group_rows(
&self,
rows: &mut Vec<SubscriptionRow>,
section: SubscriptionSection,
items: Vec<(usize, &SubscriptionItem)>,
) {
for (index, item) in items
.iter()
.copied()
.filter(|(_, item)| item.category.is_none())
{
rows.push(SubscriptionRow {
kind: SubscriptionRowKind::Item(index),
text: format!(
" {}",
subscription_line(item, self.mailbox_sync_status(&item.mailbox))
),
});
}
for category in SubscriptionCategory::ALL {
let category_items: Vec<(usize, &SubscriptionItem)> = items
.iter()
.copied()
.filter(|(_, item)| item.category == Some(category))
.collect();
if category_items.is_empty() {
continue;
}
let expanded = self.subscription_category_expanded(section, category);
let marker = if expanded { "â–¼" } else { "â–¶" };
rows.push(SubscriptionRow {
kind: SubscriptionRowKind::CategoryHeader { section, category },
text: format!(" {marker} {} ({})", category.label(), category_items.len()),
});
if expanded {
for (index, item) in category_items {
rows.push(SubscriptionRow {
kind: SubscriptionRowKind::Item(index),
text: format!(
" {}",
subscription_line(item, self.mailbox_sync_status(&item.mailbox))
),
});
}
}
}
}
fn subscription_rows(&self) -> Vec<SubscriptionRow> {
let enabled_count = self
.subscriptions
.iter()
.filter(|item| item.enabled)
.count();
let disabled_count = self.subscriptions.len().saturating_sub(enabled_count);
let mut rows = Vec::new();
let enabled_marker = if self.enabled_group_expanded {
"â–¼"
} else {
"â–¶"
};
rows.push(SubscriptionRow {
kind: SubscriptionRowKind::EnabledHeader,
text: format!("{enabled_marker} enabled ({enabled_count})"),
});
if self.enabled_group_expanded {
let items: Vec<(usize, &SubscriptionItem)> = self
.subscriptions
.iter()
.enumerate()
.filter(|(_, item)| item.enabled)
.collect();
self.push_subscription_group_rows(&mut rows, SubscriptionSection::Enabled, items);
}
let disabled_marker = if self.disabled_group_expanded {
"â–¼"
} else {
"â–¶"
};
rows.push(SubscriptionRow {
kind: SubscriptionRowKind::DisabledHeader,
text: format!("{disabled_marker} disabled ({disabled_count})"),
});
if self.disabled_group_expanded {
let items: Vec<(usize, &SubscriptionItem)> = self
.subscriptions
.iter()
.enumerate()
.filter(|(_, item)| !item.enabled)
.collect();
self.push_subscription_group_rows(&mut rows, SubscriptionSection::Disabled, items);
}
rows
}
fn selected_subscription_row_kind(&self) -> Option<SubscriptionRowKind> {
let rows = self.subscription_rows();
if rows.is_empty() {
return None;
}
let selected = self
.subscription_row_index
.min(rows.len().saturating_sub(1));
rows.get(selected).map(|row| row.kind)
}
fn selected_subscription_index(&self) -> Option<usize> {
match self.selected_subscription_row_kind() {
Some(SubscriptionRowKind::Item(index)) => Some(index),
_ => None,
}
}
fn sync_subscription_row_to_selected_item(&mut self) {
let rows = self.subscription_rows();
if rows.is_empty() {
self.subscription_row_index = 0;
return;
}
self.subscription_row_index = rows
.iter()
.position(
|row| matches!(row.kind, SubscriptionRowKind::Item(index) if index == self.subscription_index),
)
.unwrap_or(0);
}
fn clamp_subscription_row_selection(&mut self) {
let rows = self.subscription_rows();
if rows.is_empty() {
self.subscription_row_index = 0;
return;
}
if self.subscription_row_index >= rows.len() {
self.subscription_row_index = rows.len().saturating_sub(1);
}
if let Some(SubscriptionRowKind::Item(index)) =
rows.get(self.subscription_row_index).map(|row| row.kind)
{
self.subscription_index = index;
}
}
fn set_current_subscription_enabled(&mut self, enabled: bool) {
let Some(selected_index) = self.selected_subscription_index() else {
self.status = "move to a subscription item, then press y/n".to_string();
return;
};
let mailbox = self.subscriptions[selected_index].mailbox.clone();
let label = self.subscriptions[selected_index].label.clone();
if let Some(item) = self.subscriptions.get_mut(selected_index) {
item.enabled = enabled;
}
self.sort_subscriptions_keep_selected(&mailbox);
let marker = if enabled { "enabled" } else { "disabled" };
self.status = format!("{marker} subscription {label}");
self.reconcile_inbox_auto_sync();
self.reconcile_subscription_auto_sync();
self.persist_ui_state();
}
fn sort_subscriptions_keep_selected(&mut self, selected_mailbox: &str) {
self.subscriptions.sort_by(compare_subscription_items);
self.subscription_index = self
.subscriptions
.iter()
.position(|item| same_mailbox_name(&item.mailbox, selected_mailbox))
.unwrap_or(0);
self.sync_subscription_row_to_selected_item();
}
fn toggle_selected_subscription_group(&mut self) {
match self.selected_subscription_row_kind() {
Some(SubscriptionRowKind::EnabledHeader) => {
self.enabled_group_expanded = !self.enabled_group_expanded;
let state = if self.enabled_group_expanded {
"expanded"
} else {
"collapsed"
};
self.status = format!("enabled group {state}");
self.clamp_subscription_row_selection();
self.persist_ui_state();
}
Some(SubscriptionRowKind::DisabledHeader) => {
self.disabled_group_expanded = !self.disabled_group_expanded;
let state = if self.disabled_group_expanded {
"expanded"
} else {
"collapsed"
};
self.status = format!("disabled group {state}");
self.clamp_subscription_row_selection();
self.persist_ui_state();
}
Some(SubscriptionRowKind::CategoryHeader { section, category }) => {
self.toggle_subscription_category_group(section, category);
}
_ => {}
}
}
fn handle_subscription_enter(&mut self) {
match self.selected_subscription_row_kind() {
Some(SubscriptionRowKind::EnabledHeader)
| Some(SubscriptionRowKind::DisabledHeader)
| Some(SubscriptionRowKind::CategoryHeader { .. }) => {
self.toggle_selected_subscription_group()
}
Some(SubscriptionRowKind::Item(_)) => self.open_threads_for_selected_subscription(),
None => {}
}
}
fn open_threads_for_selected_subscription(&mut self) {
let Some(selected_index) = self.selected_subscription_index() else {
self.status = "press Enter on a subscription item".to_string();
return;
};
let Some(item) = self.subscriptions.get(selected_index) else {
return;
};
let mailbox = item.mailbox.clone();
let enabled = item.enabled;
tracing::debug!(mailbox = %mailbox, enabled, "user opened subscription");
if !enabled {
self.status = format!("subscription {} is disabled, press y to enable", mailbox);
return;
}
match mail_store::load_thread_rows_by_mailbox(&self.runtime.database_path, &mailbox, 500) {
Ok(rows) if !rows.is_empty() => {
self.show_mailbox_threads(
&mailbox,
rows,
format!("showing threads for {}", mailbox),
true,
);
self.focus = Pane::Threads;
}
Ok(_) => {
if self.mailbox_sync_pending(&mailbox) {
self.show_mailbox_threads(
&mailbox,
Vec::new(),
format!("{mailbox} is syncing in background; page stays responsive"),
true,
);
self.focus = Pane::Threads;
return;
}
let outcome = self
.start_manual_sync(vec![mailbox.clone()], ManualSyncOrigin::SubscriptionOpen);
let background_status = match outcome {
ManualSyncRequestOutcome::Started
| ManualSyncRequestOutcome::AlreadySyncing => {
format!("{mailbox} is syncing in background; page stays responsive")
}
ManualSyncRequestOutcome::Busy => {
"another background sync is running; page stays responsive".to_string()
}
};
self.show_mailbox_threads(&mailbox, Vec::new(), background_status, true);
self.focus = Pane::Threads;
}
Err(error) => {
tracing::error!(
mailbox = %mailbox,
error = %error,
"failed to load mailbox thread rows"
);
self.status = format!("failed to load threads for {}: {error}", mailbox);
}
}
}
fn selected_thread(&self) -> Option<&ThreadRow> {
self.filtered_thread_indices
.get(self.thread_index)
.and_then(|index| self.threads.get(*index))
}
fn selected_mail_preview(&self) -> Option<&MailPreview> {
self.selected_mail_preview.as_ref()
}
fn refresh_selected_mail_preview(&mut self) {
self.selected_mail_preview = self.selected_thread().map(load_mail_preview);
}
fn selected_series(&self) -> Option<&patch_worker::SeriesSummary> {
let thread = self.selected_thread()?;
self.series_summaries.get(&thread.thread_id)
}
fn open_reply_panel(&mut self, require_preview_focus: bool) {
if !matches!(self.ui_page, UiPage::Mail) {
self.status = "reply is only available on mail page".to_string();
return;
}
if require_preview_focus && !matches!(self.focus, Pane::Preview) {
self.status = "move focus to Preview, then press e to reply".to_string();
return;
}
let Some(thread) = self.selected_thread().cloned() else {
self.status = "select a mail thread before replying".to_string();
return;
};
let Some(raw_path) = thread.raw_path.clone() else {
self.status = "selected mail has no raw source; cannot build reply draft".to_string();
return;
};
let raw = match fs::read(&raw_path) {
Ok(raw) => raw,
Err(error) => {
self.status = format!("failed to read {}: {}", raw_path.display(), error);
return;
}
};
let identity_resolver = self.reply_identity_resolver;
let identity = match identity_resolver() {
Ok(identity) => identity,
Err(error) => {
self.status = format!("reply identity unavailable: {error}");
return;
}
};
let mut self_addresses = vec![identity.email.clone()];
if let Some(email) = self.runtime.imap.email.as_ref() {
self_addresses.push(email.clone());
}
let seed = build_reply_seed(&raw, &thread, &identity, &self_addresses);
self.reply_panel = Some(ReplyPanelState::new(
seed,
self_addresses,
thread.mail_id,
thread.thread_id,
));
self.status = format!(
"reply panel opened for <{}>; edit From/To/Cc/Subject before Send Preview",
thread.message_id
);
}
fn close_reply_panel(&mut self, status: impl Into<String>) {
self.reply_panel = None;
self.status = status.into();
}
fn open_reply_notice(
&mut self,
kind: ReplyNoticeKind,
title: impl Into<String>,
message: impl Into<String>,
hint: impl Into<String>,
action: Option<ReplyNoticeAction>,
status: impl Into<String>,
) {
if let Some(panel) = self.reply_panel.as_mut() {
panel.reply_notice = Some(ReplyNoticeState {
kind,
title: title.into(),
message: message.into(),
hint: hint.into(),
action,
});
}
self.status = status.into();
}
fn close_reply_notice(&mut self, status: impl Into<String>) {
if let Some(panel) = self.reply_panel.as_mut() {
panel.reply_notice = None;
}
self.status = status.into();
}
fn open_send_preview(&mut self) {
let Some(panel) = self.reply_panel.as_mut() else {
self.status = "reply panel is not open".to_string();
return;
};
panel.reply_notice = None;
let ReplyPreview {
content,
lines,
errors,
warnings,
} = render_reply_preview(ReplyPreviewRequest {
from: &panel.from,
to: &panel.to,
cc: &panel.cc,
subject: &panel.subject,
in_reply_to: &panel.in_reply_to,
references: &panel.references,
body: &panel.body,
self_addresses: &panel.self_addresses,
});
panel.preview_rendered = content;
panel.preview_lines = lines;
panel.preview_errors = errors;
panel.preview_warnings = warnings;
panel.preview_open = true;
panel.preview_scroll = 0;
if !panel.preview_errors.is_empty() {
self.status = format!("send preview blocked: {}", panel.preview_errors.join("; "));
} else if !panel.preview_warnings.is_empty() {
self.status = format!(
"send preview warning: {}; press Enter/c to confirm anyway",
panel.preview_warnings.join("; ")
);
} else {
self.status = "send preview ready; press Enter/c to confirm".to_string();
}
}
fn close_send_preview(&mut self, status: impl Into<String>) {
if let Some(panel) = self.reply_panel.as_mut() {
panel.preview_open = false;
}
self.status = status.into();
}
fn confirm_send_preview(&mut self) {
let Some(panel) = self.reply_panel.as_mut() else {
self.status = "reply panel is not open".to_string();
return;
};
if !panel.preview_open {
self.status = "open Send Preview first".to_string();
return;
}
if !panel.preview_errors.is_empty() {
self.status = format!(
"cannot confirm send preview: {}",
panel.preview_errors.join("; ")
);
return;
}
panel.preview_open = false;
panel.preview_confirmed = true;
panel.preview_confirmed_at = Some(now_timestamp());
self.open_reply_notice(
ReplyNoticeKind::Info,
"Ready To Send",
"Send Preview has been confirmed. Press S to send the reply, or Esc/Enter to keep editing.",
"S send | Esc/Enter close",
Some(ReplyNoticeAction::Send),
"send preview confirmed; ready to send",
);
}
fn attempt_reply_send(&mut self) {
let Some(panel) = self.reply_panel.as_ref().cloned() else {
self.status = "reply panel is not open".to_string();
return;
};
if !panel.preview_confirmed {
self.open_reply_notice(
ReplyNoticeKind::Warning,
"Send Blocked",
"You must open Send Preview and confirm it before CRIEW will send this reply.",
"P preview | Esc/Enter close",
Some(ReplyNoticeAction::OpenPreview),
"send blocked: run Send Preview and confirm first",
);
return;
}
let (prepared, errors) = prepare_reply_message(ReplyPreviewRequest {
from: &panel.from,
to: &panel.to,
cc: &panel.cc,
subject: &panel.subject,
in_reply_to: &panel.in_reply_to,
references: &panel.references,
body: &panel.body,
self_addresses: &panel.self_addresses,
});
if !errors.is_empty() {
self.status = format!("send blocked: {}", errors.join("; "));
return;
}
let request = build_send_request(&panel, prepared);
tracing::info!(
op = "reply.send",
status = "started",
mail_id = request.mail_id,
thread_id = request.thread_id,
"sending reply"
);
let outcome = (self.reply_send_executor)(&self.runtime, &request);
let persist_result = persist_reply_send_result(&self.runtime, &request, &outcome);
match outcome.status {
SendStatus::Sent => {
tracing::info!(
op = "reply.send",
status = "sent",
mail_id = request.mail_id,
thread_id = request.thread_id,
message_id = %outcome.message_id,
"reply sent"
);
let status = if let Err(error) = persist_result {
format!(
"reply sent as <{}> but failed to persist send result: {}",
outcome.message_id, error
)
} else {
format!("reply sent as <{}>", outcome.message_id)
};
self.close_reply_panel(status);
}
SendStatus::Failed | SendStatus::TimedOut => {
let summary = outcome
.error_summary
.as_deref()
.unwrap_or("reply send failed");
tracing::error!(
op = "reply.send",
status = "failed",
mail_id = request.mail_id,
thread_id = request.thread_id,
message_id = %outcome.message_id,
exit_code = outcome.exit_code,
timed_out = outcome.timed_out,
error = %summary,
"reply send failed"
);
self.status = if let Err(error) = persist_result {
format!(
"send failed: {}; retry with S after fixing the issue (persist failed: {})",
summary, error
)
} else {
format!(
"send failed: {}; retry with S after fixing the issue",
summary
)
};
}
}
}
fn execute_reply_command(&mut self) {
let command = if let Some(panel) = self.reply_panel.as_mut() {
let command = panel.command_input.trim().to_string();
panel.command_input.clear();
panel.mode = ReplyEditMode::Normal;
command
} else {
self.status = "reply panel is not open".to_string();
return;
};
if command.is_empty() {
self.status = "empty command".to_string();
return;
}
match command.as_str() {
"q!" => self.close_reply_panel("discarded reply draft"),
"q" => {
if self.reply_panel.as_ref().is_some_and(|panel| panel.dirty) {
self.status = "unsaved reply draft, run :q! to discard".to_string();
} else {
self.close_reply_panel("closed reply panel");
}
}
"preview" => self.open_send_preview(),
"send" => self.attempt_reply_send(),
_ => {
self.status = format!("unsupported command: :{command}");
}
}
}
fn run_patch_action(&mut self, action: patch_worker::PatchAction) {
tracing::info!(
op = "patch.action",
status = "started",
action = action.name()
);
if !matches!(self.ui_page, UiPage::Mail) {
self.status = "patch action is only available on mail page".to_string();
return;
}
let Some(series) = self.selected_series().cloned() else {
self.status = "current thread is not a patch series".to_string();
return;
};
match patch_worker::run_action(&self.runtime, &series, action) {
Ok(result) => {
if let Some(series_summary) = self.series_summaries.get_mut(&series.thread_id) {
series_summary.status = result.status;
}
if matches!(action, patch_worker::PatchAction::Apply)
&& result.status == crate::domain::models::PatchSeriesStatus::Applied
&& let (Some(before_head), Some(after_head)) =
(result.head_before.as_deref(), result.head_after.as_deref())
{
self.last_apply_snapshot = Some(LastApplySnapshot {
thread_id: series.thread_id,
before_head: before_head.to_string(),
after_head: after_head.to_string(),
});
}
let exit_code = result
.exit_code
.map(|value| value.to_string())
.unwrap_or_else(|| "unknown".to_string());
let output_dir = result
.output_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "-".to_string());
self.status = format!(
"{}: {} (status={} exit={} timeout={})",
action.name(),
result.summary,
match result.status {
crate::domain::models::PatchSeriesStatus::New => "new",
crate::domain::models::PatchSeriesStatus::Reviewing => "reviewing",
crate::domain::models::PatchSeriesStatus::Applied => "applied",
crate::domain::models::PatchSeriesStatus::Failed => "failed",
crate::domain::models::PatchSeriesStatus::Conflict => "conflict",
},
exit_code,
result.timed_out
);
tracing::info!(
op = "patch.action",
status = "succeeded",
action = action.name(),
command = %result.command_line,
output_dir = %output_dir,
);
}
Err(error) => {
tracing::error!(
op = "patch.action",
status = "failed",
action = action.name(),
error = %error
);
self.status = format!("{} failed: {}", action.name(), error);
}
}
}
fn run_patch_undo_action(&mut self) {
tracing::info!(op = "patch.undo", status = "started");
if !matches!(self.ui_page, UiPage::Mail) {
self.status = "undo is only available on mail page".to_string();
return;
}
let Some(snapshot) = self.last_apply_snapshot.clone() else {
self.status = "no apply action to undo in this session".to_string();
return;
};
match patch_worker::undo_last_apply(
&self.runtime,
&snapshot.before_head,
&snapshot.after_head,
) {
Ok(head_after_reset) => {
if let Some(series_summary) = self.series_summaries.get_mut(&snapshot.thread_id) {
series_summary.status = crate::domain::models::PatchSeriesStatus::New;
}
self.last_apply_snapshot = None;
self.status = format!(
"undo apply: reset HEAD to {}",
short_commit_id(&head_after_reset)
);
tracing::info!(
op = "patch.undo",
status = "succeeded",
thread_id = snapshot.thread_id,
head = %head_after_reset
);
}
Err(error) => {
tracing::error!(op = "patch.undo", status = "failed", error = %error);
self.status = format!("undo apply failed: {error}");
}
}
}
fn selected_kernel_tree_row(&self) -> Option<&KernelTreeRow> {
self.kernel_tree_rows.get(
self.kernel_tree_row_index
.min(self.kernel_tree_rows.len().saturating_sub(1)),
)
}
fn selected_kernel_tree_path(&self) -> Option<PathBuf> {
self.selected_kernel_tree_row().map(|row| row.path.clone())
}
fn selected_kernel_tree_file_path(&self) -> Option<&Path> {
self.selected_kernel_tree_row()
.filter(|row| row.is_file())
.map(|row| row.path.as_path())
}
fn refresh_kernel_tree_rows(&mut self, selected_path_hint: Option<&Path>) {
self.kernel_tree_rows =
build_kernel_tree_rows(&self.runtime.kernel_trees, &self.kernel_tree_expanded_paths);
if self.kernel_tree_rows.is_empty() {
self.kernel_tree_row_index = 0;
return;
}
if let Some(path) = selected_path_hint
&& let Some(index) = self
.kernel_tree_rows
.iter()
.position(|row| row.path == path)
{
self.kernel_tree_row_index = index;
return;
}
if self.kernel_tree_row_index >= self.kernel_tree_rows.len() {
self.kernel_tree_row_index = self.kernel_tree_rows.len().saturating_sub(1);
}
}
fn supports_code_browser(&self) -> bool {
!self.runtime.kernel_trees.is_empty()
}
fn toggle_ui_page(&mut self) {
if matches!(self.ui_page, UiPage::Mail) && !self.supports_code_browser() {
self.status =
"no kernel tree configured; set [kernel].tree or [kernel].trees".to_string();
return;
}
self.ui_page = self.ui_page.toggled();
match self.ui_page {
UiPage::Mail => {
self.status = "switched to mail page".to_string();
}
UiPage::CodeBrowser => {
self.refresh_kernel_tree_rows(self.selected_kernel_tree_path().as_deref());
self.code_preview_scroll = 0;
self.status = "switched to code browser page".to_string();
}
}
}
fn move_subscription_up(&mut self) {
let rows = self.subscription_rows();
if rows.is_empty() {
return;
}
if self.subscription_row_index >= rows.len() {
self.subscription_row_index = rows.len().saturating_sub(1);
}
if self.subscription_row_index > 0 {
self.subscription_row_index -= 1;
}
if let Some(SubscriptionRowKind::Item(index)) =
rows.get(self.subscription_row_index).map(|row| row.kind)
{
self.subscription_index = index;
}
}
fn move_subscription_down(&mut self) {
let rows = self.subscription_rows();
if rows.is_empty() {
return;
}
if self.subscription_row_index >= rows.len() {
self.subscription_row_index = rows.len().saturating_sub(1);
} else if self.subscription_row_index + 1 < rows.len() {
self.subscription_row_index += 1;
}
if let Some(SubscriptionRowKind::Item(index)) =
rows.get(self.subscription_row_index).map(|row| row.kind)
{
self.subscription_index = index;
}
}
fn move_kernel_tree_up(&mut self) {
let previous_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf);
if self.kernel_tree_row_index > 0 {
self.kernel_tree_row_index -= 1;
}
let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf);
if previous_file != next_file {
self.code_preview_scroll = 0;
}
}
fn move_kernel_tree_down(&mut self) {
let previous_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf);
if self.kernel_tree_row_index + 1 < self.kernel_tree_rows.len() {
self.kernel_tree_row_index += 1;
}
let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf);
if previous_file != next_file {
self.code_preview_scroll = 0;
}
}
fn handle_kernel_tree_enter(&mut self) {
let Some(row) = self.selected_kernel_tree_row().cloned() else {
self.status = "kernel tree is empty".to_string();
return;
};
if !row.expandable {
self.code_preview_scroll = 0;
self.status = format!("selected {}", row.path.display());
return;
}
if row.expanded {
self.kernel_tree_expanded_paths.remove(&row.path);
self.status = format!("collapsed {}", row.path.display());
} else {
self.kernel_tree_expanded_paths.insert(row.path.clone());
self.status = format!("expanded {}", row.path.display());
}
self.refresh_kernel_tree_rows(Some(&row.path));
self.code_preview_scroll = 0;
}
fn move_focus_next(&mut self) {
match self.ui_page {
UiPage::Mail => {
self.focus = self.focus.next();
}
UiPage::CodeBrowser => {
self.code_focus = self.code_focus.next();
}
}
}
fn move_focus_previous(&mut self) {
match self.ui_page {
UiPage::Mail => {
self.focus = self.focus.previous();
}
UiPage::CodeBrowser => {
self.code_focus = self.code_focus.previous();
}
}
}
fn resize_mail_panes(
&mut self,
direction: HorizontalResizeDirection,
resize_mode: MailPaneResizeMode,
) {
let did_resize = match (self.focus, direction, resize_mode) {
(Pane::Subscriptions, HorizontalResizeDirection::Left, _) => false,
(Pane::Subscriptions, HorizontalResizeDirection::Right, MailPaneResizeMode::Expand) => {
self.grow_mail_subscriptions_pane()
}
(Pane::Subscriptions, HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink) => {
self.shrink_mail_subscriptions_pane()
}
(Pane::Threads, HorizontalResizeDirection::Left, MailPaneResizeMode::Expand) => {
self.shrink_mail_subscriptions_pane()
}
(Pane::Threads, HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink) => {
self.grow_mail_subscriptions_pane()
}
(Pane::Threads, HorizontalResizeDirection::Right, MailPaneResizeMode::Expand) => {
self.shrink_mail_preview_pane()
}
(Pane::Threads, HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink) => {
self.grow_mail_preview_pane()
}
(Pane::Preview, HorizontalResizeDirection::Left, MailPaneResizeMode::Expand) => {
self.grow_mail_preview_pane()
}
(Pane::Preview, HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink) => {
self.shrink_mail_preview_pane()
}
(Pane::Preview, HorizontalResizeDirection::Right, _) => false,
};
if did_resize {
self.persist_ui_state();
self.status = format!(
"mail panes saved: subscriptions {} cols, preview {} cols",
self.mail_pane_layout.subscriptions_width, self.mail_pane_layout.preview_width
);
} else {
self.status = match resize_mode {
MailPaneResizeMode::Expand => "mail pane cannot expand in that direction",
MailPaneResizeMode::Shrink => "mail pane cannot shrink in that direction",
}
.to_string();
}
}
fn grow_mail_subscriptions_pane(&mut self) -> bool {
self.mail_pane_layout.subscriptions_width = self
.mail_pane_layout
.subscriptions_width
.saturating_add(MAIL_PANE_RESIZE_STEP);
true
}
fn shrink_mail_subscriptions_pane(&mut self) -> bool {
shrink_mail_pane_width(
&mut self.mail_pane_layout.subscriptions_width,
MIN_MAIL_SUBSCRIPTIONS_WIDTH,
)
}
fn grow_mail_preview_pane(&mut self) -> bool {
self.mail_pane_layout.preview_width = self
.mail_pane_layout
.preview_width
.saturating_add(MAIL_PANE_RESIZE_STEP);
true
}
fn shrink_mail_preview_pane(&mut self) -> bool {
shrink_mail_pane_width(
&mut self.mail_pane_layout.preview_width,
MIN_MAIL_PREVIEW_WIDTH,
)
}
fn select_filtered_thread_at(&mut self, filtered_index: usize) {
debug_assert!(filtered_index < self.filtered_thread_indices.len());
self.thread_index = filtered_index;
self.preview_scroll = 0;
self.refresh_selected_mail_preview();
}
fn select_previous_thread(&mut self) {
if self.thread_index == 0 {
return;
}
self.select_filtered_thread_at(self.thread_index - 1);
}
fn select_next_thread(&mut self) {
if self.thread_index + 1 >= self.filtered_thread_indices.len() {
return;
}
self.select_filtered_thread_at(self.thread_index + 1);
}
fn move_up(&mut self) {
match self.ui_page {
UiPage::Mail => match self.focus {
Pane::Subscriptions => {
self.move_subscription_up();
}
Pane::Threads => {
self.select_previous_thread();
}
Pane::Preview => {
self.preview_scroll = self
.preview_scroll
.min(self.preview_scroll_limit.get())
.saturating_sub(1);
}
},
UiPage::CodeBrowser => match self.code_focus {
CodePaneFocus::Tree => self.move_kernel_tree_up(),
CodePaneFocus::Source => {
self.code_preview_scroll = self
.code_preview_scroll
.min(self.code_preview_scroll_limit.get())
.saturating_sub(1);
}
},
}
}
fn move_down(&mut self) {
match self.ui_page {
UiPage::Mail => match self.focus {
Pane::Subscriptions => {
self.move_subscription_down();
}
Pane::Threads => {
self.select_next_thread();
}
Pane::Preview => {
let preview_scroll_limit = self.preview_scroll_limit.get();
self.preview_scroll = self
.preview_scroll
.min(preview_scroll_limit)
.saturating_add(1)
.min(preview_scroll_limit);
}
},
UiPage::CodeBrowser => match self.code_focus {
CodePaneFocus::Tree => self.move_kernel_tree_down(),
CodePaneFocus::Source => {
let code_preview_scroll_limit = self.code_preview_scroll_limit.get();
self.code_preview_scroll = self
.code_preview_scroll
.min(code_preview_scroll_limit)
.saturating_add(1)
.min(code_preview_scroll_limit);
}
},
}
}
fn open_search(&mut self) {
self.search.active = true;
self.search.input = self.search.applied_query.clone();
self.status = "search mode".to_string();
}
fn jump_current_pane_to_start(&mut self) {
match self.ui_page {
UiPage::Mail => match self.focus {
Pane::Subscriptions => {
self.subscription_row_index = 0;
self.clamp_subscription_row_selection();
}
Pane::Threads => {
if !self.filtered_thread_indices.is_empty() {
self.select_filtered_thread_at(0);
}
}
Pane::Preview => {
self.preview_scroll = 0;
}
},
UiPage::CodeBrowser => match self.code_focus {
CodePaneFocus::Tree => {
let previous_file =
self.selected_kernel_tree_file_path().map(Path::to_path_buf);
self.kernel_tree_row_index = 0;
let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf);
if previous_file != next_file {
self.code_preview_scroll = 0;
}
}
CodePaneFocus::Source => {
self.code_preview_scroll = 0;
}
},
}
}
fn jump_current_pane_to_end(&mut self) {
match self.ui_page {
UiPage::Mail => match self.focus {
Pane::Subscriptions => {
let rows = self.subscription_rows();
if rows.is_empty() {
return;
}
self.subscription_row_index = rows.len().saturating_sub(1);
self.clamp_subscription_row_selection();
}
Pane::Threads => {
if !self.filtered_thread_indices.is_empty() {
self.select_filtered_thread_at(
self.filtered_thread_indices.len().saturating_sub(1),
);
}
}
Pane::Preview => {
self.preview_scroll = u16::MAX;
}
},
UiPage::CodeBrowser => match self.code_focus {
CodePaneFocus::Tree => {
if self.kernel_tree_rows.is_empty() {
self.kernel_tree_row_index = 0;
return;
}
let previous_file =
self.selected_kernel_tree_file_path().map(Path::to_path_buf);
self.kernel_tree_row_index = self.kernel_tree_rows.len().saturating_sub(1);
let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf);
if previous_file != next_file {
self.code_preview_scroll = 0;
}
}
CodePaneFocus::Source => {
self.code_preview_scroll = self.code_preview_scroll_limit.get();
}
},
}
}
fn pending_main_page_count_state(&self, count: u16) -> PendingMainPageCountState {
PendingMainPageCountState {
count,
ui_page: self.ui_page,
focus: self.focus,
code_focus: self.code_focus,
}
}
fn clear_pending_main_page_inputs(&mut self) {
self.pending_main_page_sequence = None;
self.pending_main_page_count = None;
}
fn clear_pending_main_page_count(&mut self) {
self.pending_main_page_count = None;
}
fn has_pending_main_page_count(&self) -> bool {
self.pending_main_page_count.is_some_and(|state| {
state.ui_page == self.ui_page
&& state.focus == self.focus
&& state.code_focus == self.code_focus
})
}
fn push_pending_main_page_count_digit(&mut self, digit: u16) {
let next_count = self
.pending_main_page_count
.filter(|state| {
state.ui_page == self.ui_page
&& state.focus == self.focus
&& state.code_focus == self.code_focus
})
.map(|state| state.count.saturating_mul(10).saturating_add(digit))
.unwrap_or(digit);
self.pending_main_page_count = Some(self.pending_main_page_count_state(next_count));
}
fn take_pending_main_page_count(&mut self) -> Option<u16> {
let pending_state = self.pending_main_page_count.take()?;
let same_scope = pending_state.ui_page == self.ui_page
&& pending_state.focus == self.focus
&& pending_state.code_focus == self.code_focus;
same_scope.then_some(pending_state.count)
}
fn close_search(&mut self) {
self.search.active = false;
self.search.input.clear();
self.status = "search cancelled".to_string();
}
fn apply_search(&mut self) {
self.search.active = false;
self.search.applied_query = self.search.input.trim().to_string();
self.thread_index = 0;
self.apply_thread_filter();
}
fn toggle_palette(&mut self) {
self.palette.open = !self.palette.open;
if self.palette.open {
self.palette.clear_completion();
self.palette.clear_local_result();
self.status = "command palette opened".to_string();
} else {
self.palette.input.clear();
self.palette.clear_completion();
self.palette.clear_local_result();
self.status = "command palette closed".to_string();
}
}
fn close_palette(&mut self) {
self.palette.open = false;
self.palette.input.clear();
self.palette.clear_completion();
self.palette.clear_local_result();
self.status = "command palette closed".to_string();
}
fn dismiss_palette(&mut self) {
self.palette.open = false;
self.palette.input.clear();
self.palette.clear_completion();
self.palette.clear_local_result();
}
fn is_code_edit_active(&self) -> bool {
self.code_edit_mode.is_active()
}
fn mark_terminal_refresh_needed(&mut self) {
self.needs_terminal_refresh = true;
}
fn take_terminal_refresh_needed(&mut self) -> bool {
std::mem::take(&mut self.needs_terminal_refresh)
}
fn enter_code_edit_mode(&mut self) {
if !matches!(self.ui_page, UiPage::CodeBrowser)
|| !matches!(self.code_focus, CodePaneFocus::Source)
{
self.status = CODE_EDIT_ENTRY_HINT.to_string();
return;
}
let Some(path) = self.selected_kernel_tree_file_path().map(Path::to_path_buf) else {
self.status = CODE_EDIT_ENTRY_HINT.to_string();
return;
};
let buffer = match load_code_edit_buffer_from_path(&path) {
Ok(buffer) => buffer,
Err(error) => {
self.status = error;
return;
}
};
self.code_edit_mode = CodeEditMode::VimNormal;
self.code_edit_target = Some(path.clone());
self.code_edit_buffer = buffer;
self.code_edit_cursor_row = 0;
self.code_edit_cursor_col = 0;
self.code_edit_dirty = false;
self.code_edit_command_input.clear();
self.code_preview_scroll = 0;
self.status = format!("editing {} (NORMAL)", path.display());
tracing::info!(
op = "code.edit",
status = "started",
file = %path.display()
);
}
fn exit_code_edit_mode(&mut self, status: String) {
let target_for_log = self
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<none>".to_string());
self.code_edit_mode = CodeEditMode::Browse;
self.code_edit_target = None;
self.code_edit_buffer.clear();
self.code_edit_cursor_row = 0;
self.code_edit_cursor_col = 0;
self.code_edit_dirty = false;
self.code_edit_command_input.clear();
self.code_preview_scroll = 0;
self.status = status;
tracing::info!(
op = "code.edit",
status = "succeeded",
file = %target_for_log,
detail = %self.status
);
}
fn save_code_edit_buffer(&mut self) -> bool {
let Some(path) = self.code_edit_target.clone() else {
self.status = "no file is being edited".to_string();
return false;
};
let content = self.code_edit_buffer.join("\n");
match fs::write(&path, content) {
Ok(_) => {
self.code_edit_dirty = false;
self.status = format!("saved {}", path.display());
tracing::info!(
op = "code.save",
status = "succeeded",
file = %path.display(),
lines = self.code_edit_buffer.len()
);
true
}
Err(error) => {
self.status = format!("failed to save {}: {}", path.display(), error);
tracing::error!(
op = "code.save",
status = "failed",
file = %path.display(),
error = %error
);
false
}
}
}
fn current_external_editor_target(&self) -> Option<PathBuf> {
if self.is_code_edit_active() {
self.code_edit_target
.clone()
.or_else(|| self.selected_kernel_tree_file_path().map(Path::to_path_buf))
} else {
self.selected_kernel_tree_file_path().map(Path::to_path_buf)
}
}
fn reload_code_edit_target_from_disk(
&mut self,
path: &Path,
) -> std::result::Result<(), String> {
let Some(target) = self.code_edit_target.as_ref() else {
return Ok(());
};
if target != path {
return Ok(());
}
let buffer = load_code_edit_buffer_from_path(path)?;
self.code_edit_buffer = buffer;
self.code_edit_dirty = false;
self.clamp_code_edit_cursor();
self.adjust_code_edit_scroll();
Ok(())
}
fn open_external_editor(&mut self) {
if !matches!(self.ui_page, UiPage::CodeBrowser)
|| !matches!(self.code_focus, CodePaneFocus::Source)
{
tracing::info!(
op = "external_editor",
status = "blocked",
ui_page = ?self.ui_page,
code_focus = ?self.code_focus,
reason = "invalid_page_or_focus"
);
self.status = EXTERNAL_EDITOR_ENTRY_HINT.to_string();
return;
}
if self.code_edit_dirty {
tracing::info!(
op = "external_editor",
status = "blocked",
reason = "inline_buffer_dirty"
);
self.status = "unsaved changes, run :w before external vim".to_string();
return;
}
let Some(path) = self.current_external_editor_target() else {
tracing::info!(
op = "external_editor",
status = "blocked",
reason = "no_source_file_selected"
);
self.status = EXTERNAL_EDITOR_ENTRY_HINT.to_string();
return;
};
let editor = resolve_external_editor();
let from_inline_edit = self.is_code_edit_active();
tracing::info!(
op = "external_editor",
status = "started",
editor = %editor,
file = %path.display(),
from_inline_edit
);
let runner = self.external_editor_runner;
match runner(&editor, &path) {
Ok(result) => {
self.mark_terminal_refresh_needed();
let reload_status = self.reload_code_edit_target_from_disk(&path);
self.code_preview_scroll = 0;
self.status = match (result.success, result.exit_code) {
(true, Some(code)) => format!(
"external vim exited successfully (editor={} exit={} file={})",
editor,
code,
path.display()
),
(true, None) => format!(
"external vim exited successfully (editor={} file={})",
editor,
path.display()
),
(false, Some(code)) => format!(
"external vim exited with code {} (editor={} file={})",
code,
editor,
path.display()
),
(false, None) => format!(
"external vim terminated by signal (editor={} file={})",
editor,
path.display()
),
};
if let Err(error) = reload_status {
self.status = format!(
"{}; failed to reload {}: {}",
self.status,
path.display(),
error
);
}
tracing::info!(
op = "external_editor",
status = if result.success { "succeeded" } else { "failed" },
editor = %editor,
file = %path.display(),
success = result.success,
exit_code = ?result.exit_code
);
}
Err(error) => {
self.mark_terminal_refresh_needed();
self.status = format!("external vim failed for {}: {}", path.display(), error);
tracing::error!(
op = "external_editor",
status = "failed",
editor = %editor,
file = %path.display(),
error = %error
);
}
}
}
fn code_edit_line_len(&self, row: usize) -> usize {
self.code_edit_buffer
.get(row)
.map(|line| line.chars().count())
.unwrap_or(0)
}
fn clamp_code_edit_cursor(&mut self) {
if self.code_edit_buffer.is_empty() {
self.code_edit_buffer.push(String::new());
}
if self.code_edit_cursor_row >= self.code_edit_buffer.len() {
self.code_edit_cursor_row = self.code_edit_buffer.len().saturating_sub(1);
}
let line_len = self.code_edit_line_len(self.code_edit_cursor_row);
if self.code_edit_cursor_col > line_len {
self.code_edit_cursor_col = line_len;
}
}
fn adjust_code_edit_scroll(&mut self) {
const EDIT_HEADER_LINES: usize = 4;
let logical_cursor_line = self.code_edit_cursor_row.saturating_add(EDIT_HEADER_LINES);
let scroll_target = logical_cursor_line.saturating_sub(3);
self.code_preview_scroll = scroll_target.min(u16::MAX as usize) as u16;
}
fn move_code_edit_cursor_left(&mut self) {
self.clamp_code_edit_cursor();
if self.code_edit_cursor_col > 0 {
self.code_edit_cursor_col -= 1;
} else if self.code_edit_cursor_row > 0 {
self.code_edit_cursor_row -= 1;
self.code_edit_cursor_col = self.code_edit_line_len(self.code_edit_cursor_row);
}
self.adjust_code_edit_scroll();
}
fn move_code_edit_cursor_right(&mut self) {
self.clamp_code_edit_cursor();
let line_len = self.code_edit_line_len(self.code_edit_cursor_row);
if self.code_edit_cursor_col < line_len {
self.code_edit_cursor_col += 1;
} else if self.code_edit_cursor_row + 1 < self.code_edit_buffer.len() {
self.code_edit_cursor_row += 1;
self.code_edit_cursor_col = 0;
}
self.adjust_code_edit_scroll();
}
fn move_code_edit_cursor_up(&mut self) {
self.clamp_code_edit_cursor();
if self.code_edit_cursor_row > 0 {
self.code_edit_cursor_row -= 1;
let line_len = self.code_edit_line_len(self.code_edit_cursor_row);
self.code_edit_cursor_col = self.code_edit_cursor_col.min(line_len);
}
self.adjust_code_edit_scroll();
}
fn move_code_edit_cursor_down(&mut self) {
self.clamp_code_edit_cursor();
if self.code_edit_cursor_row + 1 < self.code_edit_buffer.len() {
self.code_edit_cursor_row += 1;
let line_len = self.code_edit_line_len(self.code_edit_cursor_row);
self.code_edit_cursor_col = self.code_edit_cursor_col.min(line_len);
}
self.adjust_code_edit_scroll();
}
fn insert_code_edit_character(&mut self, character: char) -> bool {
self.clamp_code_edit_cursor();
let Some(line) = self.code_edit_buffer.get_mut(self.code_edit_cursor_row) else {
return false;
};
let byte_index = char_to_byte_index(line, self.code_edit_cursor_col);
line.insert(byte_index, character);
self.code_edit_cursor_col += 1;
self.code_edit_dirty = true;
self.adjust_code_edit_scroll();
true
}
fn backspace_code_edit_character(&mut self) -> bool {
self.clamp_code_edit_cursor();
if self.code_edit_cursor_col > 0 {
let Some(line) = self.code_edit_buffer.get_mut(self.code_edit_cursor_row) else {
return false;
};
let remove_at = self.code_edit_cursor_col - 1;
let start = char_to_byte_index(line, remove_at);
let end = char_to_byte_index(line, remove_at + 1);
line.replace_range(start..end, "");
self.code_edit_cursor_col -= 1;
self.code_edit_dirty = true;
self.adjust_code_edit_scroll();
return true;
}
if self.code_edit_cursor_row == 0 {
return false;
}
let current = self.code_edit_buffer.remove(self.code_edit_cursor_row);
self.code_edit_cursor_row -= 1;
let Some(previous_line) = self.code_edit_buffer.get_mut(self.code_edit_cursor_row) else {
return false;
};
let previous_len = previous_line.chars().count();
previous_line.push_str(¤t);
self.code_edit_cursor_col = previous_len;
self.code_edit_dirty = true;
self.adjust_code_edit_scroll();
true
}
fn insert_code_edit_newline(&mut self) -> bool {
self.clamp_code_edit_cursor();
let Some(line) = self.code_edit_buffer.get_mut(self.code_edit_cursor_row) else {
return false;
};
let byte_index = char_to_byte_index(line, self.code_edit_cursor_col);
let tail = line.split_off(byte_index);
self.code_edit_buffer
.insert(self.code_edit_cursor_row + 1, tail);
self.code_edit_cursor_row += 1;
self.code_edit_cursor_col = 0;
self.code_edit_dirty = true;
self.adjust_code_edit_scroll();
true
}
fn delete_code_edit_character(&mut self) -> bool {
self.clamp_code_edit_cursor();
let row = self.code_edit_cursor_row;
if row >= self.code_edit_buffer.len() {
return false;
}
let line_len = self.code_edit_line_len(row);
if self.code_edit_cursor_col < line_len {
let Some(line) = self.code_edit_buffer.get_mut(row) else {
return false;
};
let start = char_to_byte_index(line, self.code_edit_cursor_col);
let end = char_to_byte_index(line, self.code_edit_cursor_col + 1);
line.replace_range(start..end, "");
self.clamp_code_edit_cursor();
self.code_edit_dirty = true;
self.adjust_code_edit_scroll();
return true;
}
if self.code_edit_cursor_col == line_len && row + 1 < self.code_edit_buffer.len() {
let next = self.code_edit_buffer.remove(row + 1);
let Some(line) = self.code_edit_buffer.get_mut(row) else {
return false;
};
line.push_str(&next);
self.code_edit_dirty = true;
self.adjust_code_edit_scroll();
return true;
}
false
}
fn enter_code_edit_command_mode(&mut self) {
self.code_edit_mode = CodeEditMode::VimCommand;
self.code_edit_command_input.clear();
self.status = "command mode".to_string();
}
fn execute_code_edit_command(&mut self) {
let command = self.code_edit_command_input.trim().to_string();
self.code_edit_command_input.clear();
self.code_edit_mode = CodeEditMode::VimNormal;
if command.is_empty() {
self.status = "empty command".to_string();
return;
}
let target_for_log = self
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<none>".to_string());
tracing::info!(
op = "code.command",
status = "started",
command = %command,
file = %target_for_log
);
match command.as_str() {
"w" => {
let _ = self.save_code_edit_buffer();
}
"q!" => {
let target = self
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<file>".to_string());
self.exit_code_edit_mode(format!("discarded unsaved changes for {target}"));
}
"q" => {
if self.code_edit_dirty {
self.status = "unsaved changes, run :w, :wq, or :q!".to_string();
} else {
let target = self
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<file>".to_string());
self.exit_code_edit_mode(format!("exit edit mode for {target}"));
}
}
"wq" => {
if self.save_code_edit_buffer() {
let target = self
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<file>".to_string());
self.exit_code_edit_mode(format!("saved and exited {target}"));
}
}
"vim" => {
self.open_external_editor();
}
_ => {
self.status = format!("unsupported command: :{command}");
}
}
}
}
fn spawn_startup_sync_worker(
runtime: RuntimeConfig,
mailboxes: Vec<String>,
) -> Receiver<StartupSyncEvent> {
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let total = mailboxes.len();
for (index, mailbox) in mailboxes.into_iter().enumerate() {
if sender
.send(StartupSyncEvent::MailboxStarted {
mailbox: mailbox.clone(),
index: index + 1,
total,
})
.is_err()
{
return;
}
let request = sync_worker::SyncRequest {
mailbox: mailbox.clone(),
fixture_dir: None,
uidvalidity: None,
reconnect_attempts: PALETTE_SYNC_RECONNECT_ATTEMPTS,
};
match run_sync_request_guarded(&runtime, request) {
Ok(summary) => {
if sender
.send(StartupSyncEvent::MailboxFinished {
mailbox,
fetched: summary.fetched,
inserted: summary.inserted,
updated: summary.updated,
})
.is_err()
{
return;
}
}
Err(error) => {
if sender
.send(StartupSyncEvent::MailboxFailed {
mailbox,
error: error.to_string(),
})
.is_err()
{
return;
}
}
}
}
let _ = sender.send(StartupSyncEvent::WorkerCompleted);
});
receiver
}
fn dedup_mailboxes(mailboxes: Vec<String>) -> Vec<String> {
let mut deduped: Vec<String> = Vec::new();
for mailbox in mailboxes {
if deduped
.iter()
.any(|existing| same_mailbox_name(existing, &mailbox))
{
continue;
}
deduped.push(mailbox);
}
deduped
}
fn run_sync_request_guarded(
runtime: &RuntimeConfig,
request: sync_worker::SyncRequest,
) -> Result<sync_worker::SyncSummary> {
let mailbox = request.mailbox.clone();
catch_sync_panic(&mailbox, || sync_worker::run(runtime, request))
}
fn catch_sync_panic<T, F>(mailbox: &str, operation: F) -> Result<T>
where
F: FnOnce() -> Result<T>,
{
match panic::catch_unwind(AssertUnwindSafe(operation)) {
Ok(result) => result,
Err(payload) => {
let message = if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else {
"unknown panic payload".to_string()
};
Err(CriewError::new(
ErrorCode::Tui,
format!("sync panicked for {mailbox}: {message}"),
))
}
}
}
fn load_code_edit_buffer_from_path(path: &Path) -> std::result::Result<Vec<String>, String> {
let content =
fs::read(path).map_err(|error| format!("failed to read {}: {}", path.display(), error))?;
let text = String::from_utf8_lossy(&content)
.replace("\r\n", "\n")
.replace('\r', "\n");
let mut buffer: Vec<String> = text.split('\n').map(ToOwned::to_owned).collect();
if buffer.is_empty() {
buffer.push(String::new());
}
Ok(buffer)
}
fn pick_external_editor(visual: Option<&str>, editor: Option<&str>) -> String {
visual
.and_then(normalized_external_editor_value)
.or_else(|| editor.and_then(normalized_external_editor_value))
.unwrap_or_else(|| "vim".to_string())
}
fn normalized_external_editor_value(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn resolve_external_editor() -> String {
let visual = env::var("VISUAL").ok();
let editor = env::var("EDITOR").ok();
pick_external_editor(visual.as_deref(), editor.as_deref())
}
fn run_external_editor_session(
editor_spec: &str,
file_path: &Path,
) -> std::result::Result<ExternalEditorProcessResult, String> {
run_external_editor_session_with(
editor_spec,
file_path,
|| disable_raw_mode().map_err(|error| error.to_string()),
|| {
let mut stdout = io::stdout();
execute!(stdout, LeaveAlternateScreen).map_err(|error| error.to_string())
},
launch_external_editor_process,
|| {
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).map_err(|error| error.to_string())
},
|| enable_raw_mode().map_err(|error| error.to_string()),
)
}
fn run_external_editor_session_with<DisableRaw, LeaveAlt, LaunchEditor, EnterAlt, EnableRaw>(
editor_spec: &str,
file_path: &Path,
mut disable_raw: DisableRaw,
mut leave_alternate_screen: LeaveAlt,
mut launch_editor: LaunchEditor,
mut enter_alternate_screen: EnterAlt,
mut enable_raw: EnableRaw,
) -> std::result::Result<ExternalEditorProcessResult, String>
where
DisableRaw: FnMut() -> std::result::Result<(), String>,
LeaveAlt: FnMut() -> std::result::Result<(), String>,
LaunchEditor: FnMut(&str, &Path) -> std::result::Result<ExternalEditorProcessResult, String>,
EnterAlt: FnMut() -> std::result::Result<(), String>,
EnableRaw: FnMut() -> std::result::Result<(), String>,
{
disable_raw().map_err(|error| format!("failed to disable raw mode: {error}"))?;
if let Err(error) = leave_alternate_screen() {
let _ = enable_raw();
return Err(format!("failed to leave alternate screen: {error}"));
}
let launch_result = launch_editor(editor_spec, file_path);
let enter_result = enter_alternate_screen();
let enable_result = enable_raw();
let mut restore_errors = Vec::new();
if let Err(error) = enter_result {
restore_errors.push(format!("failed to re-enter alternate screen: {error}"));
}
if let Err(error) = enable_result {
restore_errors.push(format!("failed to re-enable raw mode: {error}"));
}
if !restore_errors.is_empty() {
return match launch_result {
Ok(result) => Err(format!(
"terminal restore failed after external editor session (exit={:?}): {}",
result.exit_code,
restore_errors.join("; ")
)),
Err(error) => Err(format!("{error}; {}", restore_errors.join("; "))),
};
}
launch_result
}
fn launch_external_editor_process(
editor_spec: &str,
file_path: &Path,
) -> std::result::Result<ExternalEditorProcessResult, String> {
let (program, args) = split_external_editor_command(editor_spec)
.ok_or_else(|| "external editor command is empty".to_string())?;
let status = ProcessCommand::new(&program)
.args(args)
.arg(file_path)
.status()
.map_err(|error| format!("failed to launch {program}: {error}"))?;
Ok(ExternalEditorProcessResult {
success: status.success(),
exit_code: status.code(),
})
}
fn split_external_editor_command(editor_spec: &str) -> Option<(String, Vec<String>)> {
let mut parts = editor_spec.split_whitespace();
let program = parts.next()?.to_string();
let args = parts.map(ToOwned::to_owned).collect();
Some((program, args))
}
fn resolve_git_reply_identity() -> std::result::Result<ReplyIdentity, String> {
reply::resolve_git_identity()
}
fn send_reply_message(runtime: &RuntimeConfig, request: &SendRequest) -> SendOutcome {
sendmail::send(runtime, request)
}
fn build_send_request(panel: &ReplyPanelState, prepared: PreparedReplyMessage) -> SendRequest {
SendRequest {
mail_id: panel.mail_id,
thread_id: panel.thread_id,
from: prepared.from,
to: prepared.to,
cc: prepared.cc,
subject: prepared.subject,
in_reply_to: prepared.in_reply_to,
references: prepared.references,
body: prepared.body,
preview_confirmed_at: panel
.preview_confirmed_at
.clone()
.unwrap_or_else(now_timestamp),
}
}
fn persist_reply_send_result(
runtime: &RuntimeConfig,
request: &SendRequest,
outcome: &SendOutcome,
) -> Result<i64> {
reply_store::insert_reply_send(
&runtime.database_path,
&ReplySendRecordRequest {
thread_id: request.thread_id,
mail_id: request.mail_id,
transport: outcome.transport.clone(),
message_id: outcome.message_id.clone(),
from_addr: request.from.clone(),
to_addrs: request.to.join(", "),
cc_addrs: request.cc.join(", "),
subject: request.subject.clone(),
preview_confirmed_at: request.preview_confirmed_at.clone(),
status: match outcome.status {
SendStatus::Sent => ReplySendStatus::Sent,
SendStatus::Failed => ReplySendStatus::Failed,
SendStatus::TimedOut => ReplySendStatus::TimedOut,
},
command: outcome.command_line.clone(),
draft_path: outcome.draft_path.clone(),
exit_code: outcome.exit_code,
timed_out: outcome.timed_out,
error_summary: outcome.error_summary.clone(),
stdout: if outcome.stdout.is_empty() {
None
} else {
Some(outcome.stdout.clone())
},
stderr: if outcome.stderr.is_empty() {
None
} else {
Some(outcome.stderr.clone())
},
started_at: outcome.started_at.clone(),
finished_at: outcome.finished_at.clone(),
},
)
}
fn now_timestamp() -> String {
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
fn reply_body_line_logical_row(body_row: usize) -> usize {
body_row
}
fn reply_command_line_logical_row(panel: &ReplyPanelState) -> usize {
reply_body_line_logical_row(panel.body.len()) + 1
}
fn reply_editable_field_prefix(section: ReplySection) -> &'static str {
match section {
ReplySection::From => "[edit] From: ",
ReplySection::To => "[edit] To: ",
ReplySection::Cc => "[edit] Cc: ",
ReplySection::Subject => "[edit] Subject: ",
ReplySection::Body => "",
}
}
fn reply_field_prefix_width(section: ReplySection) -> usize {
1 + 1 + reply_editable_field_prefix(section).chars().count()
}
fn reply_body_prefix_width(body_row: usize) -> usize {
let number_width = ((body_row + 1).to_string().chars().count()).max(4);
number_width + 2
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiAction {
Exit,
Restart,
}
pub fn run(config: &RuntimeConfig, bootstrap: &BootstrapState) -> Result<TuiAction> {
let ui_state_path = ui_state::path_for_data_dir(&config.data_dir);
let persisted_ui_state = load_persisted_ui_state(&ui_state_path);
let should_persist_imap_defaults = config.imap.is_complete()
&& !persisted_ui_state
.as_ref()
.map(|state| state.imap_defaults_initialized)
.unwrap_or(false);
let initial_mailbox = persisted_ui_state
.as_ref()
.and_then(|state| state.active_mailbox.as_ref())
.map(|mailbox| mailbox.trim())
.filter(|mailbox| !mailbox.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| config.default_active_mailbox().to_string());
let threads =
mail_store::load_thread_rows_by_mailbox(&config.database_path, &initial_mailbox, 500)?;
let mut terminal = setup_terminal()?;
let guard = TerminalGuard;
let mut state = if let Some(persisted) = persisted_ui_state {
AppState::new_with_ui_state(threads, config.clone(), Some(persisted))
} else {
AppState::new(threads, config.clone())
};
if should_persist_imap_defaults {
state.persist_ui_state();
}
if state.filtered_thread_indices.is_empty()
&& !state.recover_from_empty_active_mailbox("active mailbox has no local data")
{
state.status = "no synced thread data, run `criew sync` first".to_string();
}
state.start_startup_sync_if_enabled();
let result = tui_loop(&mut terminal, &mut state, config, bootstrap);
drop(guard);
result
}
fn load_persisted_ui_state(path: &std::path::Path) -> Option<UiState> {
match ui_state::load(path) {
Ok(state) => state,
Err(error) => {
tracing::warn!(
path = %path.display(),
error = %error,
"failed to load persisted ui state"
);
None
}
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode().map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to enable raw mode", error)
})?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to enter alternate screen", error)
})?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).map_err(|error| {
CriewError::with_source(
ErrorCode::Tui,
"failed to initialize terminal backend",
error,
)
})?;
Ok(terminal)
}
fn tui_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
state: &mut AppState,
config: &RuntimeConfig,
bootstrap: &BootstrapState,
) -> Result<TuiAction> {
loop {
state.pump_startup_sync_events();
state.pump_manual_sync_events();
state.pump_inbox_auto_sync_events();
state.pump_subscription_auto_sync_events();
state.maybe_start_inbox_auto_sync();
state.maybe_start_subscription_auto_sync();
if state.take_terminal_refresh_needed() {
terminal.clear().map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to clear terminal", error)
})?;
terminal.hide_cursor().map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to hide terminal cursor", error)
})?;
}
terminal
.draw(|frame| draw(frame, state, config, bootstrap))
.map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to render frame", error)
})?;
if event::poll(Duration::from_millis(200)).map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to poll terminal events", error)
})? {
let event = event::read().map_err(|error| {
CriewError::with_source(ErrorCode::Tui, "failed to read terminal event", error)
})?;
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press {
continue;
}
match handle_key_event(state, key) {
LoopAction::Continue => {}
LoopAction::Exit => return Ok(TuiAction::Exit),
LoopAction::Restart => return Ok(TuiAction::Restart),
}
}
}
}
}
fn default_kernel_tree_expanded_paths(root_paths: &[PathBuf]) -> HashSet<PathBuf> {
root_paths
.iter()
.filter(|path| path.exists() && path.is_dir())
.cloned()
.collect()
}
fn build_kernel_tree_rows(
root_paths: &[PathBuf],
expanded_paths: &HashSet<PathBuf>,
) -> Vec<KernelTreeRow> {
let mut rows = Vec::new();
for root in root_paths {
if rows.len() >= KERNEL_TREE_MAX_ROWS {
break;
}
if !root.exists() {
rows.push(KernelTreeRow {
path: root.clone(),
name: String::new(),
depth: 0,
kind: KernelTreeRowKind::MissingPath,
expandable: false,
expanded: false,
});
continue;
}
if !root.is_dir() {
rows.push(KernelTreeRow {
path: root.clone(),
name: root
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| root.display().to_string()),
depth: 0,
kind: KernelTreeRowKind::RootFile,
expandable: false,
expanded: false,
});
continue;
}
let has_children = has_child_entries(root);
let is_expanded = expanded_paths.contains(root);
rows.push(KernelTreeRow {
path: root.clone(),
name: root
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| root.display().to_string()),
depth: 0,
kind: KernelTreeRowKind::RootDirectory,
expandable: has_children,
expanded: has_children && is_expanded,
});
if has_children && is_expanded {
append_kernel_tree_rows(root, 1, expanded_paths, &mut rows);
}
}
rows
}
fn append_kernel_tree_rows(
directory: &Path,
depth: usize,
expanded_paths: &HashSet<PathBuf>,
rows: &mut Vec<KernelTreeRow>,
) {
if rows.len() >= KERNEL_TREE_MAX_ROWS {
return;
}
let children = child_entries(directory);
for child in children {
if rows.len() >= KERNEL_TREE_MAX_ROWS {
break;
}
if child.is_dir() {
let has_children = has_child_entries(&child);
let is_expanded = expanded_paths.contains(&child);
let name = child
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| child.display().to_string());
rows.push(KernelTreeRow {
path: child.clone(),
name,
depth,
kind: KernelTreeRowKind::Directory,
expandable: has_children,
expanded: has_children && is_expanded,
});
if has_children && is_expanded {
append_kernel_tree_rows(&child, depth + 1, expanded_paths, rows);
}
continue;
}
let name = child
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| child.display().to_string());
rows.push(KernelTreeRow {
path: child,
name,
depth,
kind: KernelTreeRowKind::File,
expandable: false,
expanded: false,
});
}
}
fn child_entries(path: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut files = Vec::new();
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
for entry in entries.flatten() {
let child = entry.path();
if child.is_dir() {
dirs.push(child);
} else if child.is_file() {
files.push(child);
}
}
dirs.sort_by(|left, right| {
left.file_name()
.map(|name| name.to_string_lossy().to_string())
.cmp(
&right
.file_name()
.map(|name| name.to_string_lossy().to_string()),
)
});
files.sort_by(|left, right| {
left.file_name()
.map(|name| name.to_string_lossy().to_string())
.cmp(
&right
.file_name()
.map(|name| name.to_string_lossy().to_string()),
)
});
dirs.extend(files);
dirs
}
fn has_child_entries(path: &Path) -> bool {
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(_) => return false,
};
for entry in entries.flatten() {
let child = entry.path();
if child.is_dir() || child.is_file() {
return true;
}
}
false
}
fn default_subscriptions(
runtime: &RuntimeConfig,
enabled_mailboxes: &HashSet<String>,
active_mailbox: Option<&str>,
my_inbox_default: MyInboxDefault,
) -> Vec<SubscriptionItem> {
let mut items: Vec<SubscriptionItem> = DEFAULT_SUBSCRIPTIONS
.iter()
.map(|entry| SubscriptionItem {
mailbox: entry.mailbox.to_string(),
label: entry.mailbox.to_string(),
enabled: mailbox_set_contains(enabled_mailboxes, entry.mailbox),
category: Some(entry.category),
})
.collect();
if runtime.imap.is_complete() {
let enable_my_inbox = mailbox_set_contains(enabled_mailboxes, IMAP_INBOX_MAILBOX)
|| my_inbox_default.should_enable_when_missing();
items.insert(
0,
SubscriptionItem {
mailbox: IMAP_INBOX_MAILBOX.to_string(),
label: MY_INBOX_LABEL.to_string(),
enabled: enable_my_inbox,
category: None,
},
);
}
if !subscription_items_contain_mailbox(&items, &runtime.source_mailbox) {
items.insert(
0,
SubscriptionItem {
mailbox: runtime.source_mailbox.clone(),
label: runtime.source_mailbox.clone(),
enabled: mailbox_set_contains(enabled_mailboxes, runtime.source_mailbox.as_str()),
category: category_for_mailbox(&runtime.source_mailbox),
},
);
}
for mailbox in enabled_mailboxes {
if subscription_items_contain_mailbox(&items, mailbox) {
continue;
}
items.push(SubscriptionItem {
mailbox: mailbox.clone(),
label: if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
MY_INBOX_LABEL.to_string()
} else {
mailbox.clone()
},
enabled: true,
category: category_for_mailbox(mailbox),
});
}
if let Some(mailbox) = active_mailbox
&& !mailbox.is_empty()
&& !subscription_items_contain_mailbox(&items, mailbox)
{
items.push(SubscriptionItem {
mailbox: mailbox.to_string(),
label: if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) {
MY_INBOX_LABEL.to_string()
} else {
mailbox.to_string()
},
enabled: mailbox_set_contains(enabled_mailboxes, mailbox),
category: category_for_mailbox(mailbox),
});
}
items.sort_by(compare_subscription_items);
items
}
fn subscription_category_rank(category: Option<SubscriptionCategory>) -> u8 {
category.map_or(0, SubscriptionCategory::sort_rank)
}
#[derive(Debug, Clone, Copy)]
enum MyInboxDefault {
EnableOnFirstOpen,
PreservePersistedChoice,
}
impl MyInboxDefault {
fn should_enable_when_missing(self) -> bool {
matches!(self, Self::EnableOnFirstOpen)
}
}
fn same_mailbox_name(left: &str, right: &str) -> bool {
left.trim().eq_ignore_ascii_case(right.trim())
}
fn mailbox_set_contains(mailboxes: &HashSet<String>, candidate: &str) -> bool {
mailboxes
.iter()
.any(|mailbox| same_mailbox_name(mailbox, candidate))
}
fn subscription_items_contain_mailbox(items: &[SubscriptionItem], candidate: &str) -> bool {
items
.iter()
.any(|item| same_mailbox_name(&item.mailbox, candidate))
}
fn compare_subscription_items(
left: &SubscriptionItem,
right: &SubscriptionItem,
) -> std::cmp::Ordering {
right
.enabled
.cmp(&left.enabled)
.then_with(|| {
subscription_category_rank(left.category)
.cmp(&subscription_category_rank(right.category))
})
.then_with(|| left.label.cmp(&right.label))
.then_with(|| left.mailbox.cmp(&right.mailbox))
}
fn char_to_byte_index(value: &str, char_index: usize) -> usize {
if char_index == 0 {
return 0;
}
value
.char_indices()
.nth(char_index)
.map(|(byte_index, _)| byte_index)
.unwrap_or(value.len())
}
fn matching_commands(input: &str) -> Vec<&'static PaletteCommand> {
let query = input.trim().to_ascii_lowercase();
if query.starts_with('!') {
return Vec::new();
}
let mut matched: Vec<(u8, &PaletteCommand)> = Vec::new();
for command in PALETTE_COMMANDS {
if query.is_empty() || command.name.starts_with(&query) {
matched.push((0, command));
continue;
}
let description = command.description.to_ascii_lowercase();
if command.name.contains(&query) || description.contains(&query) {
matched.push((1, command));
}
}
matched.sort_by_key(|(score, command)| (*score, command.name));
matched.into_iter().map(|(_, command)| command).collect()
}
fn panel_block(panel: Pane, focus: Pane) -> Block<'static> {
let is_focused = panel == focus;
panel_block_with_title(panel.title(), is_focused)
}
fn panel_block_with_title(title: &str, is_focused: bool) -> Block<'static> {
let decorated_title = if is_focused {
format!("{title} *")
} else {
title.to_string()
};
let border_style = if is_focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
Block::default()
.title(decorated_title)
.borders(Borders::ALL)
.border_style(border_style)
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen);
}
}