mirror of
https://github.com/flectar/mail.git
synced 2026-11-12 18:23:36 +00:00
795 lines
33 KiB
Rust
795 lines
33 KiB
Rust
//! Files owns one serialized async session. UI callbacks only capture input and
|
|
//! schedule work; protocol, database, dialogs and disk I/O never block Slint.
|
|
use crate::date_format::{self, DateFormats, DateStyle};
|
|
use crate::document_preview::{Preview, preview};
|
|
use crate::documents::safe_name;
|
|
use crate::{AppWindow, FileRow, FilesUi, InboxState, OperationRow};
|
|
use flectar_mail_core::files::{
|
|
self, FileClient,
|
|
service::{
|
|
Entry, FilesService as Directory, Output as ServiceOutput, Preview as ServicePreview,
|
|
},
|
|
};
|
|
use slint::{
|
|
ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel,
|
|
};
|
|
use std::{cell::RefCell, rc::Rc, sync::Arc};
|
|
use tokio::sync::Mutex;
|
|
|
|
struct SyncWorker(tokio::task::JoinHandle<()>);
|
|
impl Drop for SyncWorker {
|
|
fn drop(&mut self) {
|
|
self.0.abort();
|
|
}
|
|
}
|
|
#[cfg(mobile)]
|
|
static SUSPEND: std::sync::OnceLock<Box<dyn Fn() + Send + Sync>> = std::sync::OnceLock::new();
|
|
|
|
#[cfg(mobile)]
|
|
pub(crate) fn suspend() {
|
|
crate::pdf_preview::cancel_active();
|
|
if let Some(suspend) = SUSPEND.get() {
|
|
suspend();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn register(
|
|
app: &AppWindow,
|
|
state: &Rc<RefCell<InboxState>>,
|
|
runtime: &tokio::runtime::Handle,
|
|
documents: Arc<dyn crate::documents::DocumentProvider>,
|
|
scratch_root: std::path::PathBuf,
|
|
) {
|
|
app.global::<FilesUi>().set_pdf_supported(true);
|
|
#[cfg(mobile)]
|
|
{
|
|
let weak = app.as_weak();
|
|
let _ = SUSPEND.set(Box::new(move || {
|
|
let _ = weak.upgrade_in_event_loop(|app| {
|
|
let ui = app.global::<FilesUi>();
|
|
ui.invoke_command("release-preview".into(), "".into(), "".into());
|
|
ui.set_preview_open(false);
|
|
});
|
|
}));
|
|
crate::platform::install_preview_lifecycle();
|
|
}
|
|
let view_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let directory = Arc::new(Mutex::new((
|
|
(0usize, 0u64),
|
|
Directory {
|
|
attachments: true,
|
|
..Default::default()
|
|
},
|
|
)));
|
|
let pdf_document = Arc::new(std::sync::Mutex::new(None::<Arc<Vec<u8>>>));
|
|
let profile_pdf = pdf_document.clone();
|
|
let weak = app.as_weak();
|
|
let inbox = Rc::clone(state);
|
|
let handle = runtime.clone();
|
|
let worker = Rc::new(RefCell::new(
|
|
None::<(Arc<flectar_mail_core::Core>, SyncWorker)>,
|
|
));
|
|
let preview_pending = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let pending = Rc::new(RefCell::new(None::<tokio::task::AbortHandle>));
|
|
let generation = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
|
let account_epoch = Rc::new(std::cell::Cell::new(0_u64));
|
|
let account_state = state.clone();
|
|
let account_pending = pending.clone();
|
|
let account_generation = generation.clone();
|
|
let account_pdf = pdf_document.clone();
|
|
let epoch = account_epoch.clone();
|
|
let previous_accounts = Rc::new(RefCell::new(Vec::<i64>::new()));
|
|
let window = app.as_weak();
|
|
app.global::<FilesUi>().on_context_changed(move || {
|
|
let ids = account_state
|
|
.borrow()
|
|
.connected_accounts
|
|
.iter()
|
|
.map(|a| a.id)
|
|
.collect::<Vec<_>>();
|
|
if *previous_accounts.borrow() == ids {
|
|
return;
|
|
}
|
|
*previous_accounts.borrow_mut() = ids;
|
|
epoch.set(epoch.get().wrapping_add(1));
|
|
account_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(task) = account_pending.borrow_mut().take() {
|
|
task.abort();
|
|
}
|
|
*account_pdf.lock().unwrap() = None;
|
|
if let Some(app) = window.upgrade() {
|
|
reset_profile(&app.global::<FilesUi>());
|
|
}
|
|
});
|
|
let profile_pending = pending.clone();
|
|
let profile_generation = generation.clone();
|
|
let profile_ui = weak.clone();
|
|
let worker_timer = slint::Timer::default();
|
|
let worker_state = inbox.clone();
|
|
let worker_slot = worker.clone();
|
|
let worker_runtime = handle.clone();
|
|
worker_timer.start(
|
|
slint::TimerMode::Repeated,
|
|
std::time::Duration::from_millis(500),
|
|
move || {
|
|
if profile_ui
|
|
.upgrade()
|
|
.is_none_or(|app| app.get_active_view() != "files")
|
|
{
|
|
return;
|
|
}
|
|
let core = worker_state.borrow().core.as_ref().map(|c| c.file_core());
|
|
if let Some(core) = core {
|
|
if worker_slot
|
|
.borrow()
|
|
.as_ref()
|
|
.is_none_or(|(prior, _)| !Arc::ptr_eq(prior, &core))
|
|
{
|
|
if worker_slot.borrow().is_some() {
|
|
profile_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(task) = profile_pending.borrow_mut().take() {
|
|
task.abort();
|
|
}
|
|
if let Some(app) = profile_ui.upgrade() {
|
|
reset_profile(&app.global::<FilesUi>());
|
|
*profile_pdf.lock().unwrap() = None;
|
|
}
|
|
}
|
|
*worker_slot.borrow_mut() = Some((
|
|
core.clone(),
|
|
SyncWorker(worker_runtime.spawn(files::sync::run((*core).clone()))),
|
|
));
|
|
}
|
|
} else if worker_slot.borrow_mut().take().is_some() {
|
|
profile_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(task) = profile_pending.borrow_mut().take() {
|
|
task.abort();
|
|
}
|
|
if let Some(app) = profile_ui.upgrade() {
|
|
reset_profile(&app.global::<FilesUi>());
|
|
*profile_pdf.lock().unwrap() = None;
|
|
}
|
|
}
|
|
},
|
|
);
|
|
let progress = Arc::new(files::progress::Progress::default());
|
|
let progress_timer = slint::Timer::default();
|
|
let observed = progress.clone();
|
|
let progress_ui = weak.clone();
|
|
progress_timer.start(
|
|
slint::TimerMode::Repeated,
|
|
std::time::Duration::from_millis(100),
|
|
move || {
|
|
if let Some(app) = progress_ui.upgrade() {
|
|
let ui = app.global::<FilesUi>();
|
|
if ui.get_busy() {
|
|
let p = observed.snapshot();
|
|
let phase = match p.phase {
|
|
1 => "Uploading",
|
|
2 => "Downloading",
|
|
3 => "Copying",
|
|
_ => "Working",
|
|
};
|
|
ui.set_transfer_status(
|
|
if p.phase == 0 {
|
|
String::new()
|
|
} else if p.total > 0 {
|
|
format!("{phase} · {} of {}", size(p.done), size(p.total))
|
|
} else {
|
|
format!("{phase} · {}", size(p.done))
|
|
}
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
app.global::<FilesUi>().on_command(move |action, a, b| {
|
|
let _keep_worker_timer_alive = (&worker_timer,&progress_timer);
|
|
let Some(app) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let ui = app.global::<FilesUi>();
|
|
if action.as_str() == "load" { view_active.store(true, std::sync::atomic::Ordering::SeqCst); }
|
|
if action.as_str() == "release-view" {
|
|
view_active.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
// The sync worker only refreshes rebuildable projections. A user
|
|
// transfer is a separate task and is allowed to finish.
|
|
worker.borrow_mut().take();
|
|
if preview_pending.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
|
generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(task) = pending.borrow_mut().take() { task.abort(); }
|
|
ui.set_busy(false);
|
|
}
|
|
*pdf_document.lock().unwrap() = None;
|
|
ui.set_pdf_pages(0);
|
|
ui.set_preview_image(slint::Image::default());
|
|
ui.set_preview_text("".into());
|
|
ui.set_preview_open(false);
|
|
ui.set_list_loading(false);
|
|
ui.set_rows(Default::default());
|
|
ui.set_operations(Default::default());
|
|
let directory = directory.clone();
|
|
let generation = generation.clone();
|
|
let current = generation.load(std::sync::atomic::Ordering::SeqCst);
|
|
let view_active = view_active.clone();
|
|
handle.spawn(async move {
|
|
let mut workspace = directory.lock().await;
|
|
if !view_active.load(std::sync::atomic::Ordering::SeqCst) && generation.load(std::sync::atomic::Ordering::SeqCst) == current {
|
|
workspace.1.entries = Vec::new();
|
|
workspace.1.client = None;
|
|
workspace.1.mail_search = None;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if action.as_str() == "release-preview" {
|
|
if preview_pending.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
|
generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(task) = pending.borrow_mut().take() { task.abort(); }
|
|
ui.set_busy(false);
|
|
}
|
|
*pdf_document.lock().unwrap() = None;
|
|
ui.set_pdf_pages(0);
|
|
ui.set_preview_image(slint::Image::default());
|
|
ui.set_preview_text("".into());
|
|
return;
|
|
}
|
|
if action.as_str() == "cancel" {
|
|
generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(task) = pending.borrow_mut().take() {
|
|
task.abort();
|
|
}
|
|
ui.set_busy(false);
|
|
ui.set_list_loading(false);
|
|
if preview_pending.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
|
ui.set_status("".into());
|
|
return;
|
|
}
|
|
ui.set_selected(-1);
|
|
ui.set_can_create(false);
|
|
ui.set_can_more(false);
|
|
ui.set_status(
|
|
"Cancelled. Refresh storage before retrying; a server change may have completed."
|
|
.into(),
|
|
);
|
|
return;
|
|
}
|
|
if ui.get_busy() {
|
|
return;
|
|
}
|
|
let Some(core) = inbox.borrow().core.as_ref().map(|c| c.file_core()) else {
|
|
ui.set_status("Mail is still starting. Try again shortly.".into());
|
|
return;
|
|
};
|
|
let identity = (Arc::as_ptr(&core) as usize, account_epoch.get());
|
|
if worker
|
|
.borrow()
|
|
.as_ref()
|
|
.is_none_or(|(prior, _)| !Arc::ptr_eq(prior, &core))
|
|
{
|
|
*worker.borrow_mut() = Some((
|
|
core.clone(),
|
|
SyncWorker(handle.spawn(files::sync::run((*core).clone()))),
|
|
));
|
|
}
|
|
if matches!(action.as_str(), "scope" | "account" | "account-id" | "space" | "load" | "search" | "filter" | "refresh" | "enter" | "up" | "attachment-source") {
|
|
ui.set_dialog("".into());
|
|
ui.set_preview_open(false);
|
|
ui.set_preview_image(slint::Image::default());
|
|
ui.set_pdf_pages(0);
|
|
*pdf_document.lock().unwrap() = None;
|
|
}
|
|
if matches!(action.as_str(), "preview" | "details" | "notifications" | "clear-notifications") {
|
|
ui.set_preview_image(slint::Image::default());
|
|
ui.set_preview_text("".into());
|
|
ui.set_preview_is_image(false);
|
|
ui.set_activity_open(matches!(action.as_str(), "notifications" | "clear-notifications"));
|
|
ui.set_preview_open(true);
|
|
}
|
|
if matches!(action.as_str(), "preview" | "details" | "notifications" | "clear-notifications") {
|
|
ui.set_pdf_pages(0);
|
|
ui.set_pdf_zoom(100);
|
|
*pdf_document.lock().unwrap() = None;
|
|
}
|
|
let pdf_source = pdf_document.lock().unwrap().clone();
|
|
let pdf_document = pdf_document.clone();
|
|
let selected = ui.get_selected();
|
|
let collision = ui.get_collision();
|
|
let case_insensitive = ui.get_case_insensitive();
|
|
let action = action.to_string();
|
|
let list_loading = matches!(
|
|
action.as_str(),
|
|
"scope"
|
|
| "account"
|
|
| "account-id"
|
|
| "space"
|
|
| "load"
|
|
| "search"
|
|
| "filter"
|
|
| "refresh"
|
|
| "enter"
|
|
| "up"
|
|
| "attachment-source"
|
|
| "connect"
|
|
);
|
|
let a = if action.as_str()=="filter" {
|
|
let size = |s:slint::SharedString|->Option<u32> {s.parse().ok()};
|
|
let min=ui.get_min_size();let max=ui.get_max_size();
|
|
if (!min.is_empty()&&size(min.clone()).is_none())||(!max.is_empty()&&size(max.clone()).is_none()) {
|
|
ui.set_status("Enter file sizes as whole bytes between 0 and 4294967295.".into());return;
|
|
}
|
|
serde_json::json!({"nodeType":match ui.get_file_kind(){1=>Some("file"),2=>Some("directory"),_=>None},"minSize":size(min),"maxSize":size(max),"sort":match ui.get_file_sort(){1=>"size",2=>"nodeType",_=>"name"},"descending":ui.get_descending()}).to_string()
|
|
} else {a.to_string()};
|
|
let b = b.to_string();
|
|
let documents = documents.clone();
|
|
let scratch_root = scratch_root.clone();
|
|
let directory = Arc::clone(&directory);
|
|
let weak = weak.clone();
|
|
progress.reset();
|
|
ui.set_transfer_status("".into());
|
|
let progress=progress.clone();
|
|
preview_pending.store(matches!(action.as_str(), "preview" | "pdf-page" | "details" | "notifications" | "clear-notifications"), std::sync::atomic::Ordering::SeqCst);
|
|
let preview_pending = preview_pending.clone();
|
|
ui.set_list_loading(list_loading);
|
|
ui.set_busy(true);
|
|
ui.set_status("".into());
|
|
let current_generation = generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
|
let generation = Arc::clone(&generation);
|
|
let task = handle.spawn(async move {
|
|
let mut workspace = directory.lock().await;
|
|
if workspace.0!=identity {workspace.0=identity;workspace.1=Directory{attachments:true,..Default::default()};}
|
|
let dir=&mut workspace.1;
|
|
dir.collision = match collision {
|
|
1 => files::CollisionPolicy::Rename,
|
|
2 => files::CollisionPolicy::Replace,
|
|
3 => files::CollisionPolicy::Newest,
|
|
_ => files::CollisionPolicy::Reject,
|
|
};
|
|
dir.case_insensitive = case_insensitive;
|
|
let mut scratch = None;
|
|
let selection = if action == "download" {
|
|
let _ = tokio::fs::create_dir_all(&scratch_root).await;
|
|
match tempfile::tempdir_in(&scratch_root) {
|
|
Ok(dir) => {
|
|
let path = dir.path().join("download");
|
|
scratch = Some(dir);
|
|
Ok(Some(crate::documents::ImportedDocument::source(path)))
|
|
}
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
} else if matches!(action.as_str(), "upload" | "replace") {
|
|
documents.import().await
|
|
} else {
|
|
Ok(None)
|
|
};
|
|
let export_path = selection.as_ref().ok().and_then(|v| v.as_ref()).map(|v| v.path().to_owned());
|
|
let mut result = match selection {
|
|
Err(error) => Err(error),
|
|
Ok(None) if matches!(action.as_str(), "upload" | "replace") => {
|
|
Ok(ServiceOutput::Status("File selection cancelled.".into()))
|
|
}
|
|
Ok(_) if action == "pdf-page" => Ok(ServiceOutput::Status(String::new())),
|
|
Ok(document) => {
|
|
let name = document
|
|
.as_ref()
|
|
.filter(|_| matches!(action.as_str(), "upload" | "replace"))
|
|
.and_then(|document| document.name());
|
|
let path = document.as_ref().map(|document| document.path().to_owned());
|
|
files::progress::track(progress, dir.execute(&core, &action, &a, &b, selected, path, name.as_deref())).await
|
|
}
|
|
};
|
|
if action == "download" && result.is_ok()
|
|
&& let Some(path) = export_path {
|
|
let name = dir
|
|
.selected(selected)
|
|
.map(|e| match e {
|
|
Entry::Attachment(a) => a.filename,
|
|
Entry::Remote(n) => n.name,
|
|
})
|
|
.unwrap_or_else(|_| "download".into());
|
|
let dialog = crate::documents::download_dialog_for(&core).await;
|
|
result = documents.export_with(path, safe_name(&name), dialog).await.map(|saved| {
|
|
ServiceOutput::Status(
|
|
if saved {
|
|
"File downloaded."
|
|
} else {
|
|
"Export cancelled; the cached file is still available."
|
|
}
|
|
.into(),
|
|
)
|
|
});
|
|
}
|
|
drop(scratch);
|
|
let result = if action == "pdf-page" {
|
|
match (pdf_source, a.parse::<u32>(), b.parse::<u32>()) {
|
|
(Some(data), Ok(index), Ok(zoom)) => crate::pdf_preview::render(data.clone(), index, zoom).await
|
|
.map(|page| Output::Preview(Preview::Pdf(data, page))),
|
|
_ => Err("Select a PDF to preview.".into()),
|
|
}
|
|
} else { match result {
|
|
Ok(ServiceOutput::Operations(rows)) => Ok(Output::Operations(rows)),
|
|
Ok(ServiceOutput::Status(s)) => Ok(Output::Status(s)),
|
|
Ok(ServiceOutput::Preview(ServicePreview::Text(s))) => {
|
|
Ok(Output::Preview(Preview::Text(s)))
|
|
}
|
|
Ok(ServiceOutput::Preview(ServicePreview::Data(data, media))) => {
|
|
preview(data, &media).await.map(Output::Preview)
|
|
}
|
|
Err(e) => Err(e),
|
|
}};
|
|
let snapshot = Snapshot::new(dir);
|
|
let reset_selection = !matches!(
|
|
action.as_str(),
|
|
"preview"
|
|
| "pdf-page"
|
|
| "details"
|
|
| "download"
|
|
| "more"
|
|
| "notifications"
|
|
| "clear-notifications"
|
|
);
|
|
let _ = weak.upgrade_in_event_loop(move |app| {
|
|
if generation.load(std::sync::atomic::Ordering::SeqCst) != current_generation {
|
|
return;
|
|
}
|
|
let ui = app.global::<FilesUi>();
|
|
if app.get_active_view() != "files" {
|
|
ui.set_busy(false);
|
|
ui.set_list_loading(false);
|
|
preview_pending.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
return;
|
|
}
|
|
snapshot.apply(&ui);
|
|
if action == "connect" && result.is_ok() { ui.set_connection_revision(ui.get_connection_revision().wrapping_add(1)); }
|
|
if action == "more" || result.is_ok() {
|
|
ui.set_pagination_failed(action == "more" && result.is_err());
|
|
}
|
|
if result.is_ok() && !matches!(action.as_str(), "preview" | "pdf-page" | "details" | "download" | "notifications" | "clear-notifications" | "transfers") {
|
|
ui.set_content_revision(ui.get_content_revision().wrapping_add(1));
|
|
}
|
|
if reset_selection {
|
|
ui.set_selected(-1);
|
|
}
|
|
match result {
|
|
Ok(Output::Operations(rows)) => {
|
|
ui.set_operations(ModelRc::new(VecModel::from(
|
|
rows.into_iter()
|
|
.map(|r| OperationRow {
|
|
id: r.id.to_string().into(),
|
|
action: format!("#{} · {} · {}",r.id,r.action,r.description).into(),
|
|
state: r.state.into(),
|
|
error: r.error.into(),
|
|
bytes: if r.bytes>0 {size(r.bytes).into()}else{slint::SharedString::default()},
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
)));
|
|
ui.set_transfers_open(true);
|
|
}
|
|
Ok(Output::Preview(preview)) => {
|
|
ui.set_activity_open(matches!(
|
|
action.as_str(),
|
|
"notifications" | "clear-notifications"
|
|
));
|
|
match preview {
|
|
Preview::Text(text) => {
|
|
ui.set_preview_text(text.into());
|
|
ui.set_preview_is_image(false);
|
|
}
|
|
Preview::Pdf(data, page) => {
|
|
*pdf_document.lock().unwrap() = Some(data);
|
|
ui.set_pdf_page(page.index as i32);
|
|
ui.set_pdf_pages(page.count as i32);
|
|
let buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(&page.pixels, page.width, page.height);
|
|
ui.set_preview_image(slint::Image::from_rgba8(buffer));
|
|
ui.set_preview_is_image(true);
|
|
}
|
|
Preview::Image(data, w, h) => {
|
|
let buffer =
|
|
SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(&data, w, h);
|
|
ui.set_preview_image(slint::Image::from_rgba8(buffer));
|
|
ui.set_preview_is_image(true);
|
|
}
|
|
}
|
|
ui.set_preview_open(true);
|
|
}
|
|
Ok(Output::Status(message)) => ui.set_status(message.into()),
|
|
Err(error) => ui.set_status(error.into()),
|
|
}
|
|
preview_pending.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
ui.set_list_loading(false);
|
|
ui.set_busy(false);
|
|
});
|
|
});
|
|
*pending.borrow_mut() = Some(task.abort_handle());
|
|
});
|
|
}
|
|
enum Output {
|
|
Operations(Vec<files::store::Operation>),
|
|
Status(String),
|
|
Preview(Preview),
|
|
}
|
|
|
|
fn reset_profile(ui: &FilesUi) {
|
|
ui.set_rows(ModelRc::new(VecModel::from(Vec::<FileRow>::new())));
|
|
ui.set_operations(ModelRc::new(VecModel::from(Vec::<OperationRow>::new())));
|
|
ui.set_accounts(ModelRc::new(VecModel::from(vec![
|
|
slint::SharedString::from("All accounts"),
|
|
])));
|
|
ui.set_storage_accounts(ModelRc::new(VecModel::from(
|
|
Vec::<slint::SharedString>::new(),
|
|
)));
|
|
ui.set_account(0);
|
|
ui.set_selected_account_id(-1);
|
|
ui.set_storage_account(0);
|
|
ui.set_selected(-1);
|
|
ui.set_pdf_pages(0);
|
|
ui.set_pdf_zoom(100);
|
|
ui.set_pagination_failed(false);
|
|
ui.set_busy(false);
|
|
ui.set_list_loading(false);
|
|
ui.set_connected(false);
|
|
ui.set_can_create(false);
|
|
ui.set_can_more(false);
|
|
ui.set_can_up(false);
|
|
ui.set_preview_open(false);
|
|
ui.set_preview_text("".into());
|
|
ui.set_preview_image(slint::Image::default());
|
|
ui.set_dialog("".into());
|
|
ui.set_transfers_open(false);
|
|
ui.set_attachments(true);
|
|
ui.set_server_attachments(false);
|
|
ui.set_query("".into());
|
|
ui.set_endpoint("".into());
|
|
ui.set_quota("".into());
|
|
ui.set_status("".into());
|
|
}
|
|
fn size(bytes: u64) -> String {
|
|
if bytes < 1024 {
|
|
format!("{bytes} B")
|
|
} else if bytes < 1024 * 1024 {
|
|
format!("{:.1} KB", bytes as f64 / 1024.)
|
|
} else {
|
|
format!("{:.1} MB", bytes as f64 / (1024. * 1024.))
|
|
}
|
|
}
|
|
|
|
struct Snapshot {
|
|
rows: Vec<FileRow>,
|
|
accounts: Vec<String>,
|
|
account: i32,
|
|
account_id: i32,
|
|
spaces: Vec<String>,
|
|
space: i32,
|
|
attachments: bool,
|
|
connected: bool,
|
|
endpoint: String,
|
|
webdav: bool,
|
|
can_create: bool,
|
|
can_lock: bool,
|
|
can_more: bool,
|
|
can_up: bool,
|
|
path: String,
|
|
query: String,
|
|
quota: String,
|
|
}
|
|
|
|
/// The local day of a file's date. Snapshots are built off the UI thread,
|
|
/// which formats it with the current language in `Snapshot::apply`.
|
|
fn iso_day(date: chrono::DateTime<chrono::Local>) -> String {
|
|
date.format("%Y-%m-%d").to_string()
|
|
}
|
|
|
|
/// JMAP stores RFC 3339 times and WebDAV reports RFC 2822 ones.
|
|
fn modified_day(modified: &str) -> Option<String> {
|
|
chrono::DateTime::parse_from_rfc3339(modified)
|
|
.or_else(|_| chrono::DateTime::parse_from_rfc2822(modified))
|
|
.ok()
|
|
.map(|date| iso_day(date.with_timezone(&chrono::Local)))
|
|
}
|
|
|
|
fn file_date(formats: &DateFormats, iso_date: &str) -> SharedString {
|
|
chrono::NaiveDate::parse_from_str(iso_date, "%Y-%m-%d")
|
|
.map(|date| formats.date(DateStyle::Medium, date).into())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Rewrites the listed files' dates after the date formats changed.
|
|
pub(crate) fn refresh_dates(app: &AppWindow) {
|
|
let rows = app.global::<FilesUi>().get_rows();
|
|
let formats = date_format::current();
|
|
for index in 0..rows.row_count() {
|
|
if let Some(mut row) = rows.row_data(index) {
|
|
let date = file_date(&formats, &row.iso_date);
|
|
if row.date != date {
|
|
row.date = date;
|
|
rows.set_row_data(index, row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Snapshot {
|
|
fn new(dir: &Directory) -> Self {
|
|
let rows = dir
|
|
.entries
|
|
.iter()
|
|
.map(|entry| match entry {
|
|
Entry::Attachment(a) => FileRow {
|
|
identity: match &a.remote {
|
|
Some(remote) => format!(
|
|
"mail:{}:{}:{}:{}",
|
|
a.account_id, remote.account, remote.email, remote.blob
|
|
),
|
|
None => format!("cached:{}:{}", a.account_id, a.id),
|
|
}
|
|
.into(),
|
|
name: a.filename.clone().into(),
|
|
detail: format!("{} · {}", a.sender, a.subject).into(),
|
|
size: size(a.size).into(),
|
|
iso_date: chrono::DateTime::from_timestamp_millis(a.date)
|
|
.map(|date| iso_day(date.with_timezone(&chrono::Local)))
|
|
.unwrap_or_default()
|
|
.into(),
|
|
attachment: true,
|
|
readable: true,
|
|
..Default::default()
|
|
},
|
|
Entry::Remote(n) => FileRow {
|
|
identity: format!("storage:{}:{}:{}", dir.account, dir.settings.endpoint, n.id)
|
|
.into(),
|
|
created: n.created.clone().unwrap_or_default().into(),
|
|
modified: n.modified.clone().unwrap_or_default().into(),
|
|
accessed: n.accessed.clone().unwrap_or_default().into(),
|
|
executable: n.executable,
|
|
subscribed: n.is_subscribed.unwrap_or(true),
|
|
name: n.name.clone().into(),
|
|
detail: if n.is_directory() {
|
|
"Folder".into()
|
|
} else {
|
|
n.media_type.clone().unwrap_or_default().into()
|
|
},
|
|
size: n.size.map(size).unwrap_or_default().into(),
|
|
iso_date: n
|
|
.modified
|
|
.as_deref()
|
|
.and_then(modified_day)
|
|
.unwrap_or_default()
|
|
.into(),
|
|
directory: n.is_directory(),
|
|
readable: n.my_rights.may_read,
|
|
writable: n.my_rights.modify(),
|
|
renameable: n.my_rights.rename(),
|
|
deletable: n.my_rights.delete(),
|
|
shareable: n.my_rights.may_share,
|
|
locked: n.locked,
|
|
owns_lock: matches!(&dir.client,Some(FileClient::Dav(c)) if c.owns_lock(&n.id)),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.collect();
|
|
let (spaces, space) = if dir.offline {
|
|
(
|
|
dir.cached_spaces
|
|
.iter()
|
|
.map(|(_, name, _)| name.clone())
|
|
.collect(),
|
|
dir.cached_spaces
|
|
.iter()
|
|
.position(|(id, _, _)| Some(*id) == dir.cached_space)
|
|
.unwrap_or(0) as i32,
|
|
)
|
|
} else {
|
|
match &dir.client {
|
|
Some(FileClient::Jmap(c)) => (
|
|
c.accounts.iter().map(|a| a.name.clone()).collect(),
|
|
c.accounts
|
|
.iter()
|
|
.position(|a| a.id == c.account_id)
|
|
.unwrap_or(0) as i32,
|
|
),
|
|
_ => (Vec::new(), 0),
|
|
}
|
|
};
|
|
Self {
|
|
rows,
|
|
accounts: std::iter::once("All accounts".into())
|
|
.chain(dir.accounts.iter().map(|a| a.1.clone()))
|
|
.collect(),
|
|
account: dir.account as i32,
|
|
account_id: dir
|
|
.account_id()
|
|
.and_then(|id| i32::try_from(id).ok())
|
|
.unwrap_or(-1),
|
|
spaces,
|
|
space,
|
|
attachments: dir.attachments,
|
|
connected: dir.client.is_some() || dir.cached_space.is_some(),
|
|
endpoint: dir.settings.endpoint.clone(),
|
|
webdav: dir.settings.webdav,
|
|
can_create: dir.can_create,
|
|
can_lock: matches!(&dir.client,Some(FileClient::Dav(c)) if c.supports_locks),
|
|
can_more: dir.next.is_some(),
|
|
can_up: !dir.history.is_empty(),
|
|
path: if dir.attachments {
|
|
"Files from your email, in one place".into()
|
|
} else {
|
|
format!(
|
|
"/{}",
|
|
dir.history
|
|
.iter()
|
|
.map(|n| n.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" / ")
|
|
)
|
|
},
|
|
query: dir.query.clone(),
|
|
quota: dir.quota.clone(),
|
|
}
|
|
}
|
|
fn apply(mut self, ui: &FilesUi) {
|
|
let formats = date_format::current();
|
|
for row in &mut self.rows {
|
|
row.date = file_date(&formats, &row.iso_date);
|
|
}
|
|
// Reuse Mail's reconciliation so appending pages retains the viewport
|
|
// and existing row delegates instead of rebuilding the entire list.
|
|
let retained = ui.get_rows();
|
|
if let Some(model) = retained.as_any().downcast_ref::<VecModel<FileRow>>() {
|
|
crate::reconcile_model_rows_by(
|
|
model,
|
|
self.rows,
|
|
|row| row.identity.clone(),
|
|
PartialEq::eq,
|
|
);
|
|
} else {
|
|
ui.set_rows(ModelRc::new(VecModel::from(self.rows)));
|
|
}
|
|
ui.set_accounts(ModelRc::new(VecModel::from(
|
|
self.accounts
|
|
.into_iter()
|
|
.map(Into::into)
|
|
.collect::<Vec<_>>(),
|
|
)));
|
|
ui.set_account(self.account);
|
|
ui.set_selected_account_id(self.account_id);
|
|
ui.set_storage_accounts(ModelRc::new(VecModel::from(
|
|
self.spaces.into_iter().map(Into::into).collect::<Vec<_>>(),
|
|
)));
|
|
ui.set_storage_account(self.space);
|
|
ui.set_attachments(self.attachments);
|
|
ui.set_connected(self.connected);
|
|
ui.set_endpoint(self.endpoint.into());
|
|
ui.set_webdav(self.webdav);
|
|
ui.set_can_create(self.can_create);
|
|
ui.set_can_lock(self.can_lock);
|
|
ui.set_can_more(self.can_more);
|
|
ui.set_can_up(self.can_up);
|
|
ui.set_path(self.path.into());
|
|
ui.set_query(self.query.into());
|
|
ui.set_quota(self.quota.into());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::date_format::HourCycle;
|
|
|
|
#[test]
|
|
fn file_dates_read_jmap_and_webdav_times() {
|
|
let noon = chrono::DateTime::parse_from_rfc3339("2026-10-22T12:00:00Z")
|
|
.unwrap()
|
|
.with_timezone(&chrono::Local);
|
|
let day = iso_day(noon);
|
|
assert_eq!(modified_day("2026-10-22T12:00:00Z"), Some(day.clone()));
|
|
assert_eq!(modified_day("Thu, 22 Oct 2026 12:00:00 GMT"), Some(day));
|
|
assert_eq!(modified_day("yesterday"), None);
|
|
|
|
let formats = DateFormats::english(HourCycle::TwentyFour);
|
|
assert_eq!(file_date(&formats, "2026-10-22"), "Oct 22, 2026");
|
|
assert_eq!(file_date(&formats, ""), "");
|
|
}
|
|
}
|