diff --git a/i18n/en/cosmic_edit.ftl b/i18n/en/cosmic_edit.ftl index e935fa8..4584ea8 100644 --- a/i18n/en/cosmic_edit.ftl +++ b/i18n/en/cosmic_edit.ftl @@ -30,6 +30,11 @@ prompt-save-changes-title = Unsaved changes prompt-unsaved-changes = You have unsaved changes. Save? discard = Discard changes +## Session +session = Session +seconds = seconds +auto-save-secs = Auto save (seconds) + ## Settings settings = Settings diff --git a/src/config.rs b/src/config.rs index 8756395..de66efc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,7 @@ use cosmic::{ }; use cosmic_text::Metrics; use serde::{Deserialize, Serialize}; -use std::{collections::VecDeque, path::PathBuf}; +use std::{collections::VecDeque, path::PathBuf, num::NonZeroU64}; pub const CONFIG_VERSION: u64 = 1; @@ -40,6 +40,7 @@ pub struct Config { pub tab_width: u16, pub vim_bindings: bool, pub word_wrap: bool, + pub auto_save_secs: Option } impl Default for Config { @@ -56,6 +57,7 @@ impl Default for Config { tab_width: 4, vim_bindings: false, word_wrap: true, + auto_save_secs: None } } } diff --git a/src/main.rs b/src/main.rs index 92a3175..31da3e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ use std::{ any::TypeId, collections::HashMap, env, fs, io, + num::NonZeroU64, path::{Path, PathBuf}, process, sync::{Mutex, OnceLock}, @@ -61,6 +62,9 @@ mod project; use self::search::ProjectSearchResult; mod search; +mod session; +use session::{auto_save_subscription, AutoSaveMessage}; + use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; @@ -303,6 +307,8 @@ impl PartialEq for WatcherWrapper { #[derive(Clone, Debug)] pub enum Message { AppTheme(AppTheme), + AutoSaveSender(futures::channel::mpsc::Sender), + AutoSaveTimeout(Option), Config(Config), ConfigState(ConfigState), CloseFile, @@ -345,6 +351,7 @@ pub enum Message { Quit, Redo, Save, + SaveAny(segmented_button::Entity), SaveAsDialog, SaveAsResult(segmented_button::Entity, DialogResult), SelectAll, @@ -430,6 +437,7 @@ pub struct App { project_search_value: String, project_search_result: Option, watcher_opt: Option, + auto_save_sender: Option>, modifiers: Modifiers, } @@ -720,6 +728,25 @@ impl App { ]) } + // Send a message to the auto saver if enabled + fn update_auto_saver(&mut self, message: AutoSaveMessage) -> Command { + if let Some(mut sender) = self + .config + // Auto saving is enabled if a timeout is set + .auto_save_secs + .and_then(|_| self.auto_save_sender.clone()) + { + Command::perform(async move { sender.send(message).await }, |res| { + if let Err(e) = res { + log::error!("failed to send message to auto saver: {e}"); + } + message::none() + }) + } else { + Command::none() + } + } + fn about(&self) -> Element { let cosmic_theme::Spacing { space_xxs, .. } = self.core().system_theme().cosmic().spacing; let repository = "https://github.com/pop-os/cosmic-edit"; @@ -1123,6 +1150,11 @@ impl App { .font_sizes .iter() .position(|font_size| font_size == &self.config.font_size); + let save_seconds = self + .config + .auto_save_secs + .map(|secs| secs.to_string()) + .unwrap_or_default(); widget::settings::view_column(vec![ widget::settings::view_section(fl!("appearance")) .add( @@ -1173,6 +1205,16 @@ impl App { .toggler(self.config.vim_bindings, Message::VimBindings), ) .into(), + widget::settings::view_section(fl!("session")) + .add( + widget::settings::item::builder(fl!("auto-save-secs")).control( + widget::text_input(fl!("seconds"), save_seconds).on_input(|s| { + let secs = s.parse().ok(); + Message::AutoSaveTimeout(secs) + }), + ), + ) + .into(), ]) .into() } @@ -1273,6 +1315,7 @@ impl Application for App { project_search_value: String::new(), project_search_result: None, watcher_opt: None, + auto_save_sender: None, modifiers: Modifiers::empty(), }; @@ -1421,6 +1464,39 @@ impl Application for App { self.config.app_theme = app_theme; return self.save_config(); } + Message::AutoSaveSender(sender) => { + self.auto_save_sender = Some(sender); + } + Message::AutoSaveTimeout(timeout) => { + self.config.auto_save_secs = timeout; + if let Some(timeout) = timeout { + let entities: Vec<_> = self + .tab_model + .iter() + .filter_map(|entity| { + self.tab_model.data::(entity).map(|tab| { + if let Tab::Editor(tab) = tab { + tab.changed().then_some(entity) + } else { + None + } + }) + }) + .flatten() + .collect(); + + // Set new timeout and register all modified tabs. + return Command::batch([ + self.save_config(), + self.update_auto_saver(AutoSaveMessage::UpdateTimeout(timeout)), + self.update_auto_saver(AutoSaveMessage::RegisterBatch(entities)), + ]); + } + return Command::batch([ + self.save_config(), + self.update_auto_saver(AutoSaveMessage::CancelAll), + ]); + } Message::Config(config) => { if config != self.config { log::info!("update config"); @@ -1920,6 +1996,21 @@ impl Application for App { if let Some(title) = title_opt { self.tab_model.text_set(self.tab_model.active(), title); } + + // Remove saved tab from auto saver to avoid double saves + let entity = self.tab_model.active(); + return self.update_auto_saver(AutoSaveMessage::Cancel(entity)); + } + Message::SaveAny(entity) => { + // TODO: This variant and code should be updated to save backups instead of overwriting + // the open file + if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { + let title = tab.title(); + if tab.path_opt.is_some() { + tab.save(); + self.tab_model.text_set(entity, title); + } + } } Message::SaveAsDialog => { if self.dialog_opt.is_none() { @@ -2025,7 +2116,12 @@ impl Application for App { let mut title = tab.title(); //TODO: better way of adding change indicator title.push_str(" \u{2022}"); + let has_path = tab.path_opt.is_some(); self.tab_model.text_set(entity, title); + // Register tab with the auto saver + if has_path { + return self.update_auto_saver(AutoSaveMessage::Register(entity)); + } } } Message::TabClose(entity) => { @@ -2086,17 +2182,24 @@ impl Application for App { entity, ))), self.update_tab(), + self.update_auto_saver(AutoSaveMessage::Cancel(entity)), ]); } - return self.update_tab(); + return Command::batch([ + self.update_tab(), + self.update_auto_saver(AutoSaveMessage::Cancel(entity)), + ]); } Message::TabContextAction(entity, action) => { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { // Close context menu tab.context_menu = None; // Run action's message - return self.update(action.message()); + return Command::batch([ + self.update(action.message()), + self.update_auto_saver(AutoSaveMessage::Cancel(entity)), + ]); } } Message::TabContextMenu(entity, position_opt) => { @@ -2654,6 +2757,18 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => subscription::Subscription::none(), }, + auto_save_subscription( + self.config + .auto_save_secs + // Autosave won't be triggered until the user enables it regardless of passing + // a timeout and starting the subscription. + .unwrap_or(NonZeroU64::new(1).unwrap()), + ) + .map(|update| match update { + AutoSaveMessage::Ready(sender) => Message::AutoSaveSender(sender), + AutoSaveMessage::Save(entity) => Message::SaveAny(entity), + _ => unreachable!(), + }), ]) } } diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..378a55e --- /dev/null +++ b/src/session.rs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use std::{ + any::TypeId, + borrow::Borrow, + collections::HashSet, + future::Future, + hash::{Hash, Hasher}, + num::NonZeroU64, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; +use tokio::time; + +use cosmic::{ + iced_futures::{ + futures::{ + channel::mpsc::{self, channel}, + future::{select, select_all, Either}, + FutureExt, SinkExt, StreamExt, + }, + subscription, Subscription, + }, + widget::segmented_button::Entity, +}; + +const BUF_SIZE: usize = 25; + +/// Subscription for auto save events. +/// +/// See [`AutoSaveMessage`] for emitted events. +pub fn auto_save_subscription(save_secs: NonZeroU64) -> Subscription { + struct AutoSave; + + subscription::channel( + TypeId::of::(), + BUF_SIZE, + move |mut output| async move { + let mut state = State::Init; + let (sender, mut recv) = channel(BUF_SIZE); + let mut timeouts: HashSet = HashSet::new(); + let mut save_secs = save_secs; + + loop { + match state { + State::Init => { + state = output + .send(AutoSaveMessage::Ready(sender.clone())) + .await + // .inspect_err(|e| { + // log::error!("Auto saver failed to send message to app on init: {e}") + // }) + .map(|_| State::Select) + .unwrap_or(State::Exit); + } + State::Select => { + // select_all panics on empty iterators hence the check + if timeouts.is_empty() { + state = recv.next().await.map_or(State::Exit, State::UpdateState); + } else { + // select_all requires IntoIter, so `timeouts` is drained here, + // preserving the allocated memory, and then HashSet is refilled + // with the remaining timeouts. + match select(recv.next(), select_all(timeouts.drain())).await { + Either::Left((message, unfinished)) => { + // Add the unfinished futures back into the hash set + // The futures may have made progress which is why they are moved + // between collections + timeouts.extend(unfinished.into_inner()); + + // Update timeouts or exit (None means the channel is closed) + state = message.map(State::UpdateState).unwrap_or(State::Exit); + } + Either::Right(((entity, _, remaining), _)) => { + state = match output.send(AutoSaveMessage::Save(entity)).await { + Ok(_) => { + // `timeouts` was drained earlier and should be empty so + // `entity` doesn't need to be removed + timeouts.extend(remaining); + State::Select + } + Err(e) => { + log::error!( + "Auto saver failed to send save message to app: {e}" + ); + State::Exit + } + } + } + } + } + } + State::UpdateState(update) => { + match update { + AutoSaveMessage::Cancel(entity) => { + timeouts.remove(&entity); + } + AutoSaveMessage::CancelAll => { + timeouts.clear(); + } + AutoSaveMessage::Register(entity) => { + let timeout = AutoSaveUpdate::new(entity, save_secs); + timeouts.replace(timeout); + } + AutoSaveMessage::RegisterBatch(entities) => { + timeouts.extend( + entities + .into_iter() + .map(|entity| AutoSaveUpdate::new(entity, save_secs)), + ); + } + AutoSaveMessage::UpdateTimeout(timeout) => { + save_secs = timeout; + } + _ => unreachable!(), + } + + state = State::Select; + } + State::Exit => { + // TODO: Is there anything else to do here? + std::future::pending().await + } + } + } + }, + ) +} + +/// Event messages for [`auto_save_subscription`]. +pub enum AutoSaveMessage { + // Messages to send to application: + /// Auto saver is ready to register timeouts. + Ready(mpsc::Sender), + /// Sent when timeout is reached (file is ready to be saved). + Save(Entity), + + // Messages from application: + /// Cancel an [`Entity`]'s timeout. + Cancel(Entity), + /// Cancel all timeouts. + CancelAll, + /// Update or insert a new entity to be saved. + /// + /// Tabs that are not registered are added to be saved after the timeout expires. + /// Updating a tab that's already being tracked refreshes the timeout. + Register(Entity), + /// Register or update multiple tabs at once. + RegisterBatch(Vec), + /// Update timeout after which to trigger auto saves. + UpdateTimeout(NonZeroU64), + // TODO: This can probably handle Session save timeouts too + // Session(..) +} + +struct AutoSaveUpdate { + entity: Entity, + save_in: Pin>, +} + +impl AutoSaveUpdate { + pub fn new(entity: Entity, secs: NonZeroU64) -> Self { + Self { + entity, + // `Sleep` doesn't implement Unpin. Box pinning is the most straightforward + // way to store Sleep and advance each of the timeouts with SelectAll. + save_in: Box::pin(time::sleep(Duration::from_secs(secs.get()))), + } + } +} + +impl Hash for AutoSaveUpdate { + fn hash(&self, state: &mut H) { + self.entity.hash(state) + } +} + +impl PartialEq for AutoSaveUpdate { + fn eq(&self, other: &Self) -> bool { + self.entity == other.entity + } +} + +impl Eq for AutoSaveUpdate {} + +impl Borrow for AutoSaveUpdate { + fn borrow(&self) -> &Entity { + &self.entity + } +} + +impl Future for AutoSaveUpdate { + type Output = Entity; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.as_mut().save_in.poll_unpin(cx) { + Poll::Ready(_) => Poll::Ready(self.entity), + Poll::Pending => Poll::Pending, + } + } +} + +// State machine for auto saver +enum State { + Init, + Select, + UpdateState(AutoSaveMessage), + Exit, +}