C12.8: sync now, timed sync, pause, offline folders
This commit is contained in:
23 files changed
+763
-66
No files matched your search
@@ -0,0 +1,88 @@
|
||||
//! Sync now, pause, and folders kept whole.
|
||||
|
||||
use ely_gpui_component::{buttons::Button, feedback::Banner, forms::Switch, primitives::Severity};
|
||||
use ely_mail_engine::{AccountId, Protocol};
|
||||
use gpui::{
|
||||
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div,
|
||||
};
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
impl Mail {
|
||||
pub fn sync_buttons(&self, id: AccountId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let running = self.syncs.get(&id).is_some_and(|each| each.task.is_some());
|
||||
let this = cx.weak_entity();
|
||||
let now = Button::new("mail-sync-now", text(cx, "mail.sync.now", &[]))
|
||||
.disabled(running || self.paused)
|
||||
.on_click({
|
||||
let this = this.clone();
|
||||
move |_, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.sync(id, cx)).ok();
|
||||
}
|
||||
});
|
||||
let key = if self.paused {
|
||||
"mail.sync.resume"
|
||||
} else {
|
||||
"mail.sync.pause"
|
||||
};
|
||||
let pause = Button::new("mail-sync-pause", text(cx, key, &[])).on_click(move |_, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.set_paused(!mail.paused, cx))
|
||||
.ok();
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(now)
|
||||
.child(pause)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub fn paused_banner(&self, cx: &Context<Self>) -> Option<Banner> {
|
||||
self.paused.then(|| {
|
||||
Banner::new(
|
||||
"mail-sync-paused",
|
||||
Severity::Info,
|
||||
text(cx, "mail.sync.paused", &[]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// One switch per IMAP folder.
|
||||
pub fn offline_switches(
|
||||
&self,
|
||||
id: AccountId,
|
||||
protocol: Protocol,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
if protocol != Protocol::Imap {
|
||||
return None;
|
||||
}
|
||||
let folders = &self.syncs.get(&id)?.folders;
|
||||
if folders.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
let switches = folders.iter().map(|folder| {
|
||||
let mailbox = folder.id;
|
||||
let this = this.clone();
|
||||
let label = text(cx, "mail.sync.offline", &[("name", &folder.name)]);
|
||||
let switch = Switch::new(("mail-offline", mailbox.0 as u64), folder.offline)
|
||||
.label(label)
|
||||
.on_change(move |on, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.set_offline(id, mailbox, on, cx))
|
||||
.ok();
|
||||
});
|
||||
div()
|
||||
.debug_selector(move || format!("mail-offline-{}", mailbox.0))
|
||||
.child(switch)
|
||||
});
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(switches)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ pub struct Mail {
|
||||
pub adding: Option<Adding>,
|
||||
pub removing: Option<Removing>,
|
||||
pub syncs: Syncs,
|
||||
/// Numbers every sync, even across forgotten accounts.
|
||||
pub runs: u64,
|
||||
/// Until resumed, no account syncs.
|
||||
pub paused: bool,
|
||||
/// Numbers every request across dialogs.
|
||||
pub asked: u64,
|
||||
pub focus: FocusHandle,
|
||||
@@ -102,12 +106,15 @@ impl Mail {
|
||||
adding: None,
|
||||
removing: None,
|
||||
syncs: Syncs::new(),
|
||||
runs: 0,
|
||||
paused: false,
|
||||
asked: 0,
|
||||
focus: cx.focus_handle(),
|
||||
root: WeakEntity::new_invalid(),
|
||||
side: cx.new(|_| Side::new(this)),
|
||||
};
|
||||
mail.read_accounts(cx);
|
||||
Self::sync_on_a_timer(cx);
|
||||
mail
|
||||
}
|
||||
|
||||
@@ -126,10 +133,7 @@ impl Mail {
|
||||
}
|
||||
mail.accounts = Some(read);
|
||||
if first {
|
||||
let ids: Vec<_> = mail.accounts().iter().map(|(id, _)| *id).collect();
|
||||
for id in ids {
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
mail.sync_all(cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
mod add_view;
|
||||
mod adding;
|
||||
mod controls;
|
||||
mod flow;
|
||||
mod kinds;
|
||||
mod mail;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
//! The Account menu.
|
||||
//! The Account and Sync menus.
|
||||
|
||||
use gpui::{App, Menu, MenuItem, actions};
|
||||
|
||||
use crate::mail::text;
|
||||
|
||||
actions!(mail, [AddAccount, RemoveAccount]);
|
||||
actions!(mail, [AddAccount, RemoveAccount, SyncAll]);
|
||||
|
||||
pub const CONTEXT: &str = "mail";
|
||||
|
||||
pub fn menus(cx: &App) -> Vec<Menu> {
|
||||
vec![Menu::new(text(cx, "mail.menu.account", &[])).items([
|
||||
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
|
||||
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
|
||||
])]
|
||||
vec![
|
||||
Menu::new(text(cx, "mail.menu.account", &[])).items([
|
||||
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
|
||||
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
|
||||
]),
|
||||
Menu::new(text(cx, "mail.menu.sync", &[]))
|
||||
.items([MenuItem::action(text(cx, "mail.sync.now", &[]), SyncAll)]),
|
||||
]
|
||||
}
|
||||
@@ -1,28 +1,39 @@
|
||||
//! Accounts sync on open and once added.
|
||||
//! Accounts sync on open, on demand and on a timer.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, Protocol, Synced, sync};
|
||||
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Protocol, Synced, sync};
|
||||
use gpui::Context;
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::mail::Mail;
|
||||
|
||||
/// How often every account syncs while the window is open.
|
||||
pub const EVERY: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// One account's sync as the window shows it.
|
||||
#[derive(Default)]
|
||||
pub struct Syncing {
|
||||
pub task: Option<AbortHandle>,
|
||||
/// Which sync `task` is; older completions are stale.
|
||||
pub run: u64,
|
||||
/// A folder changed mid-sync: sync once more.
|
||||
pub again: bool,
|
||||
pub synced: Option<Synced>,
|
||||
pub failed: Option<String>,
|
||||
pub folders: Vec<LocalFolder>,
|
||||
}
|
||||
|
||||
pub type Syncs = HashMap<AccountId, Syncing>;
|
||||
|
||||
impl Mail {
|
||||
pub fn sync(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
if self.paused {
|
||||
return log::info!("mail: account {} not synced: paused", id.0);
|
||||
}
|
||||
let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else {
|
||||
return log::info!("mail: sync asked of a gone account");
|
||||
};
|
||||
@@ -41,6 +52,9 @@ impl Mail {
|
||||
}
|
||||
};
|
||||
let (account, vault) = (account.clone(), self.vault.clone());
|
||||
self.runs += 1;
|
||||
let run = self.runs;
|
||||
self.syncs.entry(id).or_default().run = run;
|
||||
let work = async move {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -64,14 +78,90 @@ impl Mail {
|
||||
let Some(syncing) = mail.syncs.get_mut(&id) else {
|
||||
return log::info!("mail: a sync ended for a removed account");
|
||||
};
|
||||
if syncing.run != run {
|
||||
return log::info!("mail: account {} sync {run} ended after a newer one", id.0);
|
||||
}
|
||||
syncing.task = None;
|
||||
syncing.failed = failed;
|
||||
let again = std::mem::take(&mut syncing.again);
|
||||
mail.read_synced(id, cx);
|
||||
if again {
|
||||
log::info!("mail: account {} syncs again for a changed folder", id.0);
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
});
|
||||
self.syncs.entry(id).or_default().task = Some(task);
|
||||
self.read_synced(id, cx);
|
||||
}
|
||||
|
||||
pub fn sync_all(&mut self, cx: &mut Context<Self>) {
|
||||
if self.paused {
|
||||
return log::info!("mail: sync is paused");
|
||||
}
|
||||
let ids: Vec<_> = self.accounts().iter().map(|(id, _)| *id).collect();
|
||||
for id in ids {
|
||||
self.sync(id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pausing stops running syncs; resuming syncs every account.
|
||||
pub fn set_paused(&mut self, paused: bool, cx: &mut Context<Self>) {
|
||||
log::info!("mail: sync paused: {paused}");
|
||||
self.paused = paused;
|
||||
if paused {
|
||||
for syncing in self.syncs.values_mut() {
|
||||
syncing.again = false;
|
||||
if let Some(task) = syncing.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.sync_all(cx);
|
||||
}
|
||||
self.notify(cx);
|
||||
}
|
||||
|
||||
/// Syncs every account each `EVERY` until the window goes.
|
||||
pub fn sync_on_a_timer(cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(EVERY).await;
|
||||
if this.update(cx, |mail, cx| mail.sync_all(cx)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Takes the folder's mail whole, then syncs to fetch it.
|
||||
pub fn set_offline(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
mailbox: MailboxId,
|
||||
offline: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.set_offline(mailbox, offline),
|
||||
move |mail, done, cx| match done {
|
||||
Ok(()) => {
|
||||
mail.read_synced(id, cx);
|
||||
match mail.syncs.get_mut(&id) {
|
||||
Some(syncing) if offline && syncing.task.is_some() => {
|
||||
log::info!("mail: account {} syncs again after this one", id.0);
|
||||
syncing.again = true;
|
||||
}
|
||||
_ if offline => mail.sync(id, cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(error) => mail.failed("mail.sync.offline_failed", &error, cx),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops the account's sync and forgets it.
|
||||
pub fn stop_sync(&mut self, id: AccountId) {
|
||||
if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) {
|
||||
@@ -82,9 +172,12 @@ impl Mail {
|
||||
fn read_synced(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.synced(id),
|
||||
move |store| Ok((store.synced(id)?, store.folders(id)?)),
|
||||
move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) {
|
||||
(Ok(synced), Some(syncing)) => syncing.synced = Some(synced),
|
||||
(Ok((synced, folders)), Some(syncing)) => {
|
||||
syncing.synced = Some(synced);
|
||||
syncing.folders = folders;
|
||||
}
|
||||
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
|
||||
(_, None) => {}
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::{
|
||||
mail::{Mail, Parts},
|
||||
};
|
||||
|
||||
mod controls;
|
||||
mod flows;
|
||||
mod syncs;
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Role, Store};
|
||||
use gpui::{
|
||||
Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, size,
|
||||
};
|
||||
|
||||
use super::{Keys, account, settle, window};
|
||||
use crate::{mail::Mail, syncing::EVERY, view::imap_range};
|
||||
|
||||
/// An account on a server that never answers.
|
||||
fn silent(dir: &std::path::Path, port: u16) -> AccountId {
|
||||
let mut ann = account("ann@example.com");
|
||||
ann.incoming.port = port;
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let id = store.add_account(&ann).unwrap();
|
||||
store
|
||||
.set_folders(id, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn with_secret() -> Keys {
|
||||
let keys = Keys::default();
|
||||
let secret = ("account:ann@example.com".to_owned(), "pw".to_owned());
|
||||
keys.kept.lock().unwrap().extend([secret]);
|
||||
keys
|
||||
}
|
||||
|
||||
/// Waits on the network task alone, not the window.
|
||||
fn finished(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let done = |cx: &mut VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let task = mail.read(app).syncs[&id].task.as_ref();
|
||||
task.unwrap().is_finished()
|
||||
})
|
||||
};
|
||||
while !done(cx) {
|
||||
assert!(Instant::now() < deadline, "the sync never ended");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// An account whose sync fails at once: no secret saved.
|
||||
fn failing(dir: &std::path::Path) -> AccountId {
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
ann
|
||||
}
|
||||
|
||||
fn failed(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) -> bool {
|
||||
cx.update(|_, app| mail.read(app).syncs[&id].failed.is_some())
|
||||
}
|
||||
|
||||
fn forget(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, _| {
|
||||
mail.syncs.get_mut(&id).unwrap().failed = None
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn settled_failure(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&id)
|
||||
.is_some_and(|each| each.failed.is_some() && each.task.is_none())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_account_syncs_again_on_the_timer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settled_failure(&mail, ann, cx);
|
||||
forget(&mail, ann, cx);
|
||||
cx.executor().advance_clock(EVERY);
|
||||
settled_failure(&mail, ann, cx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_sync_waits_until_resumed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settled_failure(&mail, ann, cx);
|
||||
forget(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
|
||||
cx.executor().advance_clock(EVERY);
|
||||
cx.run_until_parked();
|
||||
assert!(!failed(&mail, ann, cx), "a paused account synced");
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(false, cx)));
|
||||
settled_failure(&mail, ann, cx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_kept_offline_is_saved_and_synced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.failed.is_some() && !each.folders.is_empty())
|
||||
});
|
||||
let inbox = cx.update(|_, app| mail.read(app).syncs[&ann].folders[0].clone());
|
||||
assert!(!inbox.offline);
|
||||
forget(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox.id, true, cx)));
|
||||
settle(cx, |app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
syncing.folders[0].offline && syncing.failed.is_some() && syncing.task.is_none()
|
||||
});
|
||||
assert!(Store::open(dir.path()).unwrap().offline(inbox.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_end_queued_across_a_resume_keeps_the_new_sync() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
settled_failure(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
|
||||
finished(&mail, ann, cx);
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let resumed = cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.set_paused(true, cx);
|
||||
mail.set_paused(false, cx);
|
||||
mail.syncs[&ann].task.clone().unwrap()
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
|
||||
"an old end cleared the new sync"
|
||||
);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !resumed.is_finished() {
|
||||
assert!(Instant::now() < deadline, "pausing left the sync running");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_end_after_a_failed_removal_keeps_the_new_sync() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
settled_failure(&mail, ann, cx);
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.stop_sync(ann);
|
||||
mail.sync(ann, cx);
|
||||
})
|
||||
});
|
||||
finished(&mail, ann, cx);
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let restarted = cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.stop_sync(ann);
|
||||
mail.sync(ann, cx);
|
||||
mail.syncs[&ann].task.clone().unwrap()
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
|
||||
"an old end cleared the restarted sync"
|
||||
);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !restarted.is_finished() {
|
||||
assert!(Instant::now() < deadline, "pausing left the sync running");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_kept_offline_mid_sync_syncs_once_more() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (hang_up, hung) = mpsc::channel::<()>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let held = listener.accept().unwrap();
|
||||
hung.recv().unwrap();
|
||||
drop((held, listener));
|
||||
});
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), port);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), with_secret());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.task.is_some() && !each.folders.is_empty())
|
||||
});
|
||||
let (inbox, run) = cx.update(|_, app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
(syncing.folders[0].id, syncing.run)
|
||||
});
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox, true, cx)));
|
||||
settle(cx, |app| mail.read(app).syncs[&ann].again);
|
||||
hang_up.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
settle(cx, |app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
syncing.run == run + 1 && syncing.task.is_none() && syncing.failed.is_some()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_page_scrolls_to_its_last_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
|
||||
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
|
||||
let last = *Store::open(dir.path())
|
||||
.unwrap()
|
||||
.set_folders(ann, &folders)
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.simulate_resize(size(px(1100.), px(800.)));
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.folders.len() == 40)
|
||||
});
|
||||
let selector: &'static str = format!("mail-offline-{}", last.0).leak();
|
||||
let height = cx.update(|window, _| window.viewport_size().height);
|
||||
let before = cx
|
||||
.debug_bounds(selector)
|
||||
.expect("the last switch is laid out");
|
||||
assert!(before.bottom() > height, "the folders fit the window");
|
||||
let at = point(px(400.), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
|
||||
..Default::default()
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let after = cx
|
||||
.debug_bounds(selector)
|
||||
.expect("the last switch is laid out");
|
||||
assert!(
|
||||
after.bottom() <= height,
|
||||
"the last switch is out of reach: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_range_names_the_folders_kept_whole() {
|
||||
let folder = |id, name: &str, offline| LocalFolder {
|
||||
id: MailboxId(id),
|
||||
name: name.into(),
|
||||
role: None,
|
||||
offline,
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
cx.update(|app| {
|
||||
ely_ui::i18n::install_as("en-US", app);
|
||||
let rows = |folders: &[LocalFolder]| -> Vec<String> {
|
||||
imap_range(folders, app)
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}: {value}"))
|
||||
.collect()
|
||||
};
|
||||
let both = [folder(1, "INBOX", true), folder(2, "Sent", false), folder(3, "Work", true)];
|
||||
assert_eq!(
|
||||
rows(&both),
|
||||
[
|
||||
"Downloaded: Other folders: headers of all mail; text from the last 90 days; attachments when asked",
|
||||
"Kept whole: Every message in INBOX, Work, with attachments",
|
||||
]
|
||||
);
|
||||
assert_eq!(rows(&[folder(1, "INBOX", true)]), ["Kept whole: Every message in INBOX, with attachments"]);
|
||||
assert_eq!(
|
||||
rows(&[folder(2, "Sent", false)]),
|
||||
["Downloaded: Headers of all mail; text from the last 90 days; attachments when asked"]
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -357,23 +357,31 @@ fn two_saves_in_flight_add_an_address_once() {
|
||||
});
|
||||
passed(&mail, account("ANN@example.com"), "pw2", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
// Either save may reach the store first.
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
mail.read(app).adding.as_ref().map(|adding| &adding.step),
|
||||
Some(Step::Failed(_))
|
||||
)
|
||||
let mail = mail.read(app);
|
||||
mail.accounts().len() == 1
|
||||
&& !matches!(
|
||||
mail.adding.as_ref().map(|adding| &adding.step),
|
||||
Some(Step::Saving)
|
||||
)
|
||||
});
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).accounts().len(), 1));
|
||||
assert_eq!(
|
||||
Store::open(dir.path()).unwrap().accounts().unwrap()[0].1,
|
||||
account("ann@example.com")
|
||||
);
|
||||
let won = Store::open(dir.path()).unwrap().accounts().unwrap();
|
||||
assert_eq!(won.len(), 1);
|
||||
let (address, secret) = if won[0].1 == account("ann@example.com") {
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
("ann", "pw")
|
||||
} else {
|
||||
assert_eq!(won[0].1, account("ANN@example.com"));
|
||||
cx.update(|_, app| assert!(mail.read(app).adding.is_none()));
|
||||
("ANN", "pw2")
|
||||
};
|
||||
let kept = keys.kept.lock().unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(
|
||||
kept.get("account:ann@example.com").map(String::as_str),
|
||||
Some("pw")
|
||||
kept.get(&format!("account:{address}@example.com"))
|
||||
.map(String::as_str),
|
||||
Some(secret)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, EmptyState, ErrorView},
|
||||
layout::ScrollArea,
|
||||
overlays::ConfirmDialog,
|
||||
primitives::{IconName, Severity},
|
||||
typography::format,
|
||||
};
|
||||
use ely_mail_engine::{Account, AccountId, Auth, Protocol, Security, Server, sync};
|
||||
use ely_mail_engine::{Account, AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Shell;
|
||||
use gpui::{
|
||||
@@ -97,18 +98,26 @@ impl Mail {
|
||||
server(&account.outgoing, cx),
|
||||
)
|
||||
.text(label("mail.account.auth"), auth(account.auth, cx));
|
||||
let (details, failed) = self.sync_rows(*id, account.protocol, details, cx);
|
||||
let (id, protocol) = (*id, account.protocol);
|
||||
let (details, failed) = self.sync_rows(id, protocol, details, cx);
|
||||
let remove = Button::new("mail-remove", label("mail.remove.open"))
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
|
||||
div()
|
||||
let page = div()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.max_w_128()
|
||||
.children(self.paused_banner(cx))
|
||||
.children(failed)
|
||||
.child(details)
|
||||
.child(div().child(remove))
|
||||
.child(self.sync_buttons(id, cx))
|
||||
.children(self.offline_switches(id, protocol, cx))
|
||||
.child(div().child(remove));
|
||||
ScrollArea::new("mail-account-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(page)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -140,11 +149,9 @@ impl Mail {
|
||||
.text(label("mail.sync.range"), label("mail.sync.range_pop"))
|
||||
.text(label("mail.sync.server"), server);
|
||||
} else {
|
||||
let days = sync::BODY_DAYS.to_string();
|
||||
details = details.text(
|
||||
label("mail.sync.range"),
|
||||
text(cx, "mail.sync.range_value", &[("days", &days)]),
|
||||
);
|
||||
for (key, value) in imap_range(&syncing.folders, cx) {
|
||||
details = details.text(key, value);
|
||||
}
|
||||
}
|
||||
if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) {
|
||||
for (key, n) in [
|
||||
@@ -206,6 +213,28 @@ impl Mail {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rest download, then folders kept whole.
|
||||
pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> {
|
||||
let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline);
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let days = sync::BODY_DAYS.to_string();
|
||||
let mut rows = Vec::new();
|
||||
if whole.is_empty() || !rest.is_empty() {
|
||||
let key = match whole.is_empty() {
|
||||
true => "mail.sync.range_value",
|
||||
false => "mail.sync.range_rest",
|
||||
};
|
||||
rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)])));
|
||||
}
|
||||
if !whole.is_empty() {
|
||||
let sep = label("mail.sync.whole_sep");
|
||||
let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect();
|
||||
let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]);
|
||||
rows.push((label("mail.sync.whole"), value));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn protocol_name(protocol: Protocol) -> &'static str {
|
||||
match protocol {
|
||||
Protocol::Imap => "IMAP",
|
||||
@@ -235,6 +264,7 @@ impl Render for Mail {
|
||||
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
|
||||
.on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx)))
|
||||
.child(shell)
|
||||
.children(self.add_dialog(cx))
|
||||
.children(self.remove_dialog(cx))
|
||||
|
||||
Reference in new issue
Block a user