From 333d723e111199767c4d1a228249b7e6160ca415 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Sun, 3 Mar 2024 02:46:28 -0500 Subject: [PATCH 1/6] State machine for auto saver --- src/main.rs | 2 + src/session.rs | 185 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/session.rs diff --git a/src/main.rs b/src/main.rs index 92a3175..eaf0a08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,8 @@ mod project; use self::search::ProjectSearchResult; mod search; +mod session; + use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..f473d50 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use std::{ + any::TypeId, + collections::{HashMap, HashSet}, + future::Future, + hash::{Hash, Hasher}, + num::{NonZeroU32, NonZeroU64}, + pin::{pin, Pin}, + task::{Context, Poll}, + time::Duration, +}; +use tokio::time; + +use cosmic::{ + iced_futures::{ + futures::{ + channel::mpsc::{self, channel, TryRecvError}, + future::{select, select_all, Either, SelectAll}, + pin_mut, FutureExt, SinkExt, StreamExt, + }, + subscription, Subscription, + }, + widget::segmented_button::Entity, +}; + +use crate::Message; + +pub fn auto_save_subscription() -> Subscription { + struct AutoSave; + + subscription::channel(TypeId::of::(), 100, |mut output| async move { + let mut state = State::Init; + let (sender, mut recv) = channel(100); + let mut timeouts: HashSet = HashSet::new(); + + loop { + match state { + State::Init => { + state = output + .send(AutoSaveEvent::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::UpdateTimeouts); + } else { + // select_all requires IntoIter, so `timeouts` is drained here then the + // HashSet is rebuilt from the remaining timeouts + let futures: Vec<_> = timeouts.drain().collect(); + match select(recv.next(), select_all(futures)).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::UpdateTimeouts).unwrap_or(State::Exit); + } + Either::Right(((entity, _, remaining), _)) => { + state = match output.send(AutoSaveEvent::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::UpdateTimeouts(update) => { + match update { + AutoSaveEvent::Update(timeout) => { + timeouts.replace(timeout); + } + AutoSaveEvent::Remove(entity) => { + // TODO: Borrow + timeouts.remove(&AutoSaveUpdate::new(entity, 1.try_into().unwrap())); + } + _ => unreachable!(), + } + + state = State::Select; + } + State::Exit => { + // TODO: Is there anything else to do here? + std::future::pending().await + } + } + } + }) +} + +pub enum AutoSaveEvent { + // Messages to send to application: + Ready(mpsc::Sender), + /// Sent when timeout is reached (file is ready to be saved) + Save(Entity), + + // Messages from application: + + /// Update or insert a new entity to be saved + Update(AutoSaveUpdate), + /// Remove entity before timeout is reached + Remove(Entity), + // TODO: This can probably handle Session save timeouts too + // Session(..) +} + +pub struct AutoSaveUpdate { + entity: Entity, + save_at: Pin>, +} + +impl AutoSaveUpdate { + pub fn new(entity: Entity, secs: NonZeroU64) -> Self { + Self { + entity, + save_at: Box::pin(time::sleep(Duration::from_secs(secs.get()))), + } + } +} + +impl Hash for AutoSaveUpdate { + fn hash(&self, state: &mut H) { + self.entity.hash(state) + } +} + +impl Eq for AutoSaveUpdate {} + +impl PartialEq for AutoSaveUpdate { + fn eq(&self, other: &Self) -> bool { + self.entity == other.entity + } +} + +impl Future for AutoSaveUpdate { + type Output = Entity; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // let mut save_at = pin!(self.save_at); + match self.as_mut().save_at.poll_unpin(cx) { + Poll::Ready(_) => Poll::Ready(self.entity), + Poll::Pending => Poll::Pending, + } + } +} + +// State machine for auto saver +enum State { + Init, + Select, + UpdateTimeouts(AutoSaveEvent), + Exit, +} + +impl From, TryRecvError>> for State { + fn from(value: Result, TryRecvError>) -> Self { + match value { + Ok(Some(event)) => State::UpdateTimeouts(event), + Ok(None) => State::Exit, + Err(e) => { + // TODO: Retry or exit? + log::error!("Auto saver failed to receive message from app: {e}"); + State::Exit + } + } + } +} From b0a73617450bc0323b794546dca16426e30bf796 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Mon, 4 Mar 2024 22:13:35 -0500 Subject: [PATCH 2/6] Add auto saver sub to app's main Subscription --- src/main.rs | 18 ++++++++++++++++++ src/session.rs | 23 ++++++++++++++--------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index eaf0a08..ec62870 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,7 @@ use self::search::ProjectSearchResult; mod search; mod session; +use session::{auto_save_subscription, AutoSaveEvent, AutoSaveUpdate}; use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; @@ -305,6 +306,7 @@ impl PartialEq for WatcherWrapper { #[derive(Clone, Debug)] pub enum Message { AppTheme(AppTheme), + AutoSaveSender(futures::channel::mpsc::Sender), Config(Config), ConfigState(ConfigState), CloseFile, @@ -347,6 +349,7 @@ pub enum Message { Quit, Redo, Save, + SaveAny(segmented_button::Entity), SaveAsDialog, SaveAsResult(segmented_button::Entity, DialogResult), SelectAll, @@ -432,6 +435,7 @@ pub struct App { project_search_value: String, project_search_result: Option, watcher_opt: Option, + auto_save_sender: Option>, modifiers: Modifiers, } @@ -1275,6 +1279,7 @@ impl Application for App { project_search_value: String::new(), project_search_result: None, watcher_opt: None, + auto_save_sender: None, modifiers: Modifiers::empty(), }; @@ -1423,6 +1428,9 @@ impl Application for App { self.config.app_theme = app_theme; return self.save_config(); } + Message::AutoSaveSender(sender) => { + self.auto_save_sender = Some(sender); + } Message::Config(config) => { if config != self.config { log::info!("update config"); @@ -1923,6 +1931,11 @@ impl Application for App { self.tab_model.text_set(self.tab_model.active(), title); } } + Message::SaveAny(entity) => { + if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { + tab.save(); + } + } Message::SaveAsDialog => { if self.dialog_opt.is_none() { let entity = self.tab_model.active(); @@ -2656,6 +2669,11 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => subscription::Subscription::none(), }, + auto_save_subscription().map(|update| match update { + AutoSaveEvent::Ready(sender) => Message::AutoSaveSender(sender), + AutoSaveEvent::Save(entity) => Message::SaveAny(entity), + _ => unreachable!(), + }), ]) } } diff --git a/src/session.rs b/src/session.rs index f473d50..cb38ada 100644 --- a/src/session.rs +++ b/src/session.rs @@ -88,7 +88,7 @@ pub fn auto_save_subscription() -> Subscription { AutoSaveEvent::Update(timeout) => { timeouts.replace(timeout); } - AutoSaveEvent::Remove(entity) => { + AutoSaveEvent::Cancel(entity) => { // TODO: Borrow timeouts.remove(&AutoSaveUpdate::new(entity, 1.try_into().unwrap())); } @@ -108,30 +108,35 @@ pub fn auto_save_subscription() -> Subscription { pub enum AutoSaveEvent { // 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) + /// Sent when timeout is reached (file is ready to be saved). Save(Entity), // Messages from application: - - /// Update or insert a new entity to be saved + /// 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. Update(AutoSaveUpdate), - /// Remove entity before timeout is reached - Remove(Entity), + /// Cancel an [`Entity`]'s timeout. + Cancel(Entity), // TODO: This can probably handle Session save timeouts too // Session(..) } pub struct AutoSaveUpdate { entity: Entity, - save_at: Pin>, + save_in: Pin>, } impl AutoSaveUpdate { pub fn new(entity: Entity, secs: NonZeroU64) -> Self { Self { entity, - save_at: Box::pin(time::sleep(Duration::from_secs(secs.get()))), + // `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()))), } } } @@ -155,7 +160,7 @@ impl Future for AutoSaveUpdate { fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { // let mut save_at = pin!(self.save_at); - match self.as_mut().save_at.poll_unpin(cx) { + match self.as_mut().save_in.poll_unpin(cx) { Poll::Ready(_) => Poll::Ready(self.entity), Poll::Pending => Poll::Pending, } From 93eebfe78c44d34048f58280e54ef32466cf2dd0 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Wed, 6 Mar 2024 02:37:13 -0500 Subject: [PATCH 3/6] Register unsaved tabs with the autosaver The app must register modified tabs to be autosaver as well as remove cancelled tabs. With this commit, the autosaver is functional. Todo: * Config option for the save timeout * Remove the asterisk for saved tabs * Clean up and dedupe --- src/config.rs | 5 ++++- src/main.rs | 38 +++++++++++++++++++++++++++++++++++++- src/session.rs | 6 +++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8756395..ccce8d8 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,8 @@ impl Default for Config { tab_width: 4, vim_bindings: false, word_wrap: true, + // TODO: Set this back to None before PR + auto_save_secs: Some(NonZeroU64::new(3).unwrap()) } } } diff --git a/src/main.rs b/src/main.rs index ec62870..7cfdb3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -726,6 +726,25 @@ impl App { ]) } + // Send a message to the auto saver if enabled + fn update_auto_saver(&mut self, message: AutoSaveEvent) -> 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"; @@ -1930,6 +1949,10 @@ 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 + let entity = self.tab_model.active(); + return self.update_auto_saver(AutoSaveEvent::Cancel(entity)); } Message::SaveAny(entity) => { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { @@ -2040,7 +2063,16 @@ 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 { + if let Some(secs) = self.config.auto_save_secs { + return self.update_auto_saver(AutoSaveEvent::Update( + AutoSaveUpdate::new(entity, secs), + )); + } + } } } Message::TabClose(entity) => { @@ -2101,6 +2133,7 @@ impl Application for App { entity, ))), self.update_tab(), + self.update_auto_saver(AutoSaveEvent::Cancel(entity)), ]); } @@ -2111,7 +2144,10 @@ impl Application for App { // 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(AutoSaveEvent::Cancel(entity)), + ]); } } Message::TabContextMenu(entity, position_opt) => { diff --git a/src/session.rs b/src/session.rs index cb38ada..58752c2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -40,9 +40,9 @@ pub fn auto_save_subscription() -> Subscription { state = output .send(AutoSaveEvent::Ready(sender.clone())) .await - .inspect_err(|e| { - log::error!("Auto saver failed to send message to app on init: {e}") - }) + // .inspect_err(|e| { + // log::error!("Auto saver failed to send message to app on init: {e}") + // }) .map(|_| State::Select) .unwrap_or(State::Exit); } From f6a80d9c5e06b5cce4c17b06d921823ff7a543a5 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Wed, 6 Mar 2024 22:52:06 -0500 Subject: [PATCH 4/6] Improve registering timeouts for autosaver * New message to update the base timeout for the autosaver * Improve ergonomics for registering timeouts from the app --- src/main.rs | 20 +++++++++----------- src/session.rs | 36 +++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7cfdb3c..a108f15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,7 +62,7 @@ use self::search::ProjectSearchResult; mod search; mod session; -use session::{auto_save_subscription, AutoSaveEvent, AutoSaveUpdate}; +use session::{auto_save_subscription, AutoSaveEvent}; use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; @@ -2067,11 +2067,7 @@ impl Application for App { self.tab_model.text_set(entity, title); // Register tab with the auto saver if has_path { - if let Some(secs) = self.config.auto_save_secs { - return self.update_auto_saver(AutoSaveEvent::Update( - AutoSaveUpdate::new(entity, secs), - )); - } + return self.update_auto_saver(AutoSaveEvent::Register(entity)); } } } @@ -2705,11 +2701,13 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => subscription::Subscription::none(), }, - auto_save_subscription().map(|update| match update { - AutoSaveEvent::Ready(sender) => Message::AutoSaveSender(sender), - AutoSaveEvent::Save(entity) => Message::SaveAny(entity), - _ => unreachable!(), - }), + auto_save_subscription(self.config.auto_save_secs.unwrap()).map( + |update| match update { + AutoSaveEvent::Ready(sender) => Message::AutoSaveSender(sender), + AutoSaveEvent::Save(entity) => Message::SaveAny(entity), + _ => unreachable!(), + }, + ), ]) } } diff --git a/src/session.rs b/src/session.rs index 58752c2..5f2f8e8 100644 --- a/src/session.rs +++ b/src/session.rs @@ -26,13 +26,14 @@ use cosmic::{ use crate::Message; -pub fn auto_save_subscription() -> Subscription { +pub fn auto_save_subscription(save_secs: NonZeroU64) -> Subscription { struct AutoSave; - subscription::channel(TypeId::of::(), 100, |mut output| async move { + subscription::channel(TypeId::of::(), 100, move |mut output| async move { let mut state = State::Init; let (sender, mut recv) = channel(100); let mut timeouts: HashSet = HashSet::new(); + let mut save_secs = save_secs; loop { match state { @@ -49,7 +50,7 @@ pub fn auto_save_subscription() -> Subscription { State::Select => { // select_all panics on empty iterators hence the check if timeouts.is_empty() { - state = recv.next().await.map_or(State::Exit, State::UpdateTimeouts); + state = recv.next().await.map_or(State::Exit, State::UpdateState); } else { // select_all requires IntoIter, so `timeouts` is drained here then the // HashSet is rebuilt from the remaining timeouts @@ -62,7 +63,7 @@ pub fn auto_save_subscription() -> Subscription { timeouts.extend(unfinished.into_inner()); // Update timeouts or exit (None means the channel is closed) - state = message.map(State::UpdateTimeouts).unwrap_or(State::Exit); + state = message.map(State::UpdateState).unwrap_or(State::Exit); } Either::Right(((entity, _, remaining), _)) => { state = match output.send(AutoSaveEvent::Save(entity)).await { @@ -83,15 +84,19 @@ pub fn auto_save_subscription() -> Subscription { } } } - State::UpdateTimeouts(update) => { + State::UpdateState(update) => { match update { - AutoSaveEvent::Update(timeout) => { - timeouts.replace(timeout); - } AutoSaveEvent::Cancel(entity) => { // TODO: Borrow timeouts.remove(&AutoSaveUpdate::new(entity, 1.try_into().unwrap())); } + AutoSaveEvent::Register(entity) => { + let timeout = AutoSaveUpdate::new(entity, save_secs); + timeouts.replace(timeout); + } + AutoSaveEvent::UpdateTimeout(timeout) => { + save_secs = timeout; + } _ => unreachable!(), } @@ -114,18 +119,20 @@ pub enum AutoSaveEvent { Save(Entity), // Messages from application: + /// Cancel an [`Entity`]'s timeout. + Cancel(Entity), /// 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. - Update(AutoSaveUpdate), - /// Cancel an [`Entity`]'s timeout. - Cancel(Entity), + Register(Entity), + /// Update timeout after which to trigger auto saves. + UpdateTimeout(NonZeroU64), // TODO: This can probably handle Session save timeouts too // Session(..) } -pub struct AutoSaveUpdate { +struct AutoSaveUpdate { entity: Entity, save_in: Pin>, } @@ -159,7 +166,6 @@ impl Future for AutoSaveUpdate { type Output = Entity; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - // let mut save_at = pin!(self.save_at); match self.as_mut().save_in.poll_unpin(cx) { Poll::Ready(_) => Poll::Ready(self.entity), Poll::Pending => Poll::Pending, @@ -171,14 +177,14 @@ impl Future for AutoSaveUpdate { enum State { Init, Select, - UpdateTimeouts(AutoSaveEvent), + UpdateState(AutoSaveEvent), Exit, } impl From, TryRecvError>> for State { fn from(value: Result, TryRecvError>) -> Self { match value { - Ok(Some(event)) => State::UpdateTimeouts(event), + Ok(Some(event)) => State::UpdateState(event), Ok(None) => State::Exit, Err(e) => { // TODO: Retry or exit? From bd6656d31d2e0760fc13e1866765422dca2b394b Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Thu, 7 Mar 2024 02:50:40 -0500 Subject: [PATCH 5/6] Add config option for auto saver timeout --- i18n/en/cosmic_edit.ftl | 5 +++++ src/config.rs | 3 +-- src/main.rs | 41 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 7 deletions(-) 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 ccce8d8..de66efc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,8 +57,7 @@ impl Default for Config { tab_width: 4, vim_bindings: false, word_wrap: true, - // TODO: Set this back to None before PR - auto_save_secs: Some(NonZeroU64::new(3).unwrap()) + auto_save_secs: None } } } diff --git a/src/main.rs b/src/main.rs index a108f15..4713022 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}, @@ -307,6 +308,7 @@ impl PartialEq for WatcherWrapper { pub enum Message { AppTheme(AppTheme), AutoSaveSender(futures::channel::mpsc::Sender), + AutoSaveTimeout(Option), Config(Config), ConfigState(ConfigState), CloseFile, @@ -1148,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( @@ -1198,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() } @@ -1450,6 +1467,16 @@ impl Application for App { Message::AutoSaveSender(sender) => { self.auto_save_sender = Some(sender); } + Message::AutoSaveTimeout(timeout) => { + self.config.auto_save_secs = timeout; + if let Some(timeout) = timeout { + return Command::batch([ + self.save_config(), + self.update_auto_saver(AutoSaveEvent::UpdateTimeout(timeout)), + ]); + } + return self.save_config(); + } Message::Config(config) => { if config != self.config { log::info!("update config"); @@ -2133,7 +2160,10 @@ impl Application for App { ]); } - return self.update_tab(); + return Command::batch([ + self.update_tab(), + self.update_auto_saver(AutoSaveEvent::Cancel(entity)), + ]); } Message::TabContextAction(entity, action) => { if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { @@ -2701,13 +2731,14 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => subscription::Subscription::none(), }, - auto_save_subscription(self.config.auto_save_secs.unwrap()).map( - |update| match update { + match self.config.auto_save_secs { + Some(secs) => auto_save_subscription(secs).map(|update| match update { AutoSaveEvent::Ready(sender) => Message::AutoSaveSender(sender), AutoSaveEvent::Save(entity) => Message::SaveAny(entity), _ => unreachable!(), - }, - ), + }), + None => subscription::Subscription::none(), + }, ]) } } From 556f310c968dbaf6142d2099dfd99ddb16198249 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Fri, 8 Mar 2024 01:44:22 -0500 Subject: [PATCH 6/6] Auto saver clean up and bug fixes * Update tab names after auto save * Register tabs when auto saver is enabled via settings * Unregister tabs when auto saver is disabled --- src/main.rs | 72 ++++++++++++----- src/session.rs | 206 ++++++++++++++++++++++++++----------------------- 2 files changed, 161 insertions(+), 117 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4713022..31da3e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,7 +63,7 @@ use self::search::ProjectSearchResult; mod search; mod session; -use session::{auto_save_subscription, AutoSaveEvent}; +use session::{auto_save_subscription, AutoSaveMessage}; use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; @@ -307,7 +307,7 @@ impl PartialEq for WatcherWrapper { #[derive(Clone, Debug)] pub enum Message { AppTheme(AppTheme), - AutoSaveSender(futures::channel::mpsc::Sender), + AutoSaveSender(futures::channel::mpsc::Sender), AutoSaveTimeout(Option), Config(Config), ConfigState(ConfigState), @@ -437,7 +437,7 @@ pub struct App { project_search_value: String, project_search_result: Option, watcher_opt: Option, - auto_save_sender: Option>, + auto_save_sender: Option>, modifiers: Modifiers, } @@ -729,7 +729,7 @@ impl App { } // Send a message to the auto saver if enabled - fn update_auto_saver(&mut self, message: AutoSaveEvent) -> Command { + 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 @@ -1470,12 +1470,32 @@ impl Application for App { 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(AutoSaveEvent::UpdateTimeout(timeout)), + self.update_auto_saver(AutoSaveMessage::UpdateTimeout(timeout)), + self.update_auto_saver(AutoSaveMessage::RegisterBatch(entities)), ]); } - return self.save_config(); + return Command::batch([ + self.save_config(), + self.update_auto_saver(AutoSaveMessage::CancelAll), + ]); } Message::Config(config) => { if config != self.config { @@ -1977,13 +1997,19 @@ impl Application for App { self.tab_model.text_set(self.tab_model.active(), title); } - // Remove saved tab from auto saver + // Remove saved tab from auto saver to avoid double saves let entity = self.tab_model.active(); - return self.update_auto_saver(AutoSaveEvent::Cancel(entity)); + 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) { - tab.save(); + let title = tab.title(); + if tab.path_opt.is_some() { + tab.save(); + self.tab_model.text_set(entity, title); + } } } Message::SaveAsDialog => { @@ -2094,7 +2120,7 @@ impl Application for App { self.tab_model.text_set(entity, title); // Register tab with the auto saver if has_path { - return self.update_auto_saver(AutoSaveEvent::Register(entity)); + return self.update_auto_saver(AutoSaveMessage::Register(entity)); } } } @@ -2156,13 +2182,13 @@ impl Application for App { entity, ))), self.update_tab(), - self.update_auto_saver(AutoSaveEvent::Cancel(entity)), + self.update_auto_saver(AutoSaveMessage::Cancel(entity)), ]); } return Command::batch([ self.update_tab(), - self.update_auto_saver(AutoSaveEvent::Cancel(entity)), + self.update_auto_saver(AutoSaveMessage::Cancel(entity)), ]); } Message::TabContextAction(entity, action) => { @@ -2172,7 +2198,7 @@ impl Application for App { // Run action's message return Command::batch([ self.update(action.message()), - self.update_auto_saver(AutoSaveEvent::Cancel(entity)), + self.update_auto_saver(AutoSaveMessage::Cancel(entity)), ]); } } @@ -2731,14 +2757,18 @@ impl Application for App { Some(dialog) => dialog.subscription(), None => subscription::Subscription::none(), }, - match self.config.auto_save_secs { - Some(secs) => auto_save_subscription(secs).map(|update| match update { - AutoSaveEvent::Ready(sender) => Message::AutoSaveSender(sender), - AutoSaveEvent::Save(entity) => Message::SaveAny(entity), - _ => unreachable!(), - }), - 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 index 5f2f8e8..378a55e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -2,11 +2,12 @@ use std::{ any::TypeId, - collections::{HashMap, HashSet}, + borrow::Borrow, + collections::HashSet, future::Future, hash::{Hash, Hasher}, - num::{NonZeroU32, NonZeroU64}, - pin::{pin, Pin}, + num::NonZeroU64, + pin::Pin, task::{Context, Poll}, time::Duration, }; @@ -15,117 +16,138 @@ use tokio::time; use cosmic::{ iced_futures::{ futures::{ - channel::mpsc::{self, channel, TryRecvError}, - future::{select, select_all, Either, SelectAll}, - pin_mut, FutureExt, SinkExt, StreamExt, + channel::mpsc::{self, channel}, + future::{select, select_all, Either}, + FutureExt, SinkExt, StreamExt, }, subscription, Subscription, }, widget::segmented_button::Entity, }; -use crate::Message; +const BUF_SIZE: usize = 25; -pub fn auto_save_subscription(save_secs: NonZeroU64) -> Subscription { +/// 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::(), 100, move |mut output| async move { - let mut state = State::Init; - let (sender, mut recv) = channel(100); - let mut timeouts: HashSet = HashSet::new(); - let mut save_secs = save_secs; - - loop { - match state { - State::Init => { - state = output - .send(AutoSaveEvent::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 then the - // HashSet is rebuilt from the remaining timeouts - let futures: Vec<_> = timeouts.drain().collect(); - match select(recv.next(), select_all(futures)).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(AutoSaveEvent::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!( + 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::Exit + } } } } } } - } - State::UpdateState(update) => { - match update { - AutoSaveEvent::Cancel(entity) => { - // TODO: Borrow - timeouts.remove(&AutoSaveUpdate::new(entity, 1.try_into().unwrap())); - } - AutoSaveEvent::Register(entity) => { - let timeout = AutoSaveUpdate::new(entity, save_secs); - timeouts.replace(timeout); - } - AutoSaveEvent::UpdateTimeout(timeout) => { - save_secs = timeout; + 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!(), } - _ => unreachable!(), - } - state = State::Select; - } - State::Exit => { - // TODO: Is there anything else to do here? - std::future::pending().await + state = State::Select; + } + State::Exit => { + // TODO: Is there anything else to do here? + std::future::pending().await + } } } - } - }) + }, + ) } -pub enum AutoSaveEvent { +/// Event messages for [`auto_save_subscription`]. +pub enum AutoSaveMessage { // Messages to send to application: /// Auto saver is ready to register timeouts. - Ready(mpsc::Sender), + 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 @@ -154,14 +176,20 @@ impl Hash for AutoSaveUpdate { } } -impl Eq for AutoSaveUpdate {} - 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; @@ -177,20 +205,6 @@ impl Future for AutoSaveUpdate { enum State { Init, Select, - UpdateState(AutoSaveEvent), + UpdateState(AutoSaveMessage), Exit, } - -impl From, TryRecvError>> for State { - fn from(value: Result, TryRecvError>) -> Self { - match value { - Ok(Some(event)) => State::UpdateState(event), - Ok(None) => State::Exit, - Err(e) => { - // TODO: Retry or exit? - log::error!("Auto saver failed to receive message from app: {e}"); - State::Exit - } - } - } -}