Fix post autosave blocking editor interactions
This commit is contained in:
@@ -188,6 +188,12 @@ pub enum Message {
|
|||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
TaskTick,
|
TaskTick,
|
||||||
|
PostAutoSaveFinished {
|
||||||
|
post_id: String,
|
||||||
|
active_language: String,
|
||||||
|
edit_revision: u64,
|
||||||
|
result: Result<Option<PersistedPostState>, String>,
|
||||||
|
},
|
||||||
DomainEventsTick,
|
DomainEventsTick,
|
||||||
CancelTask(TaskSource, TaskId),
|
CancelTask(TaskSource, TaskId),
|
||||||
RemoteTaskCancelled(Result<(), String>),
|
RemoteTaskCancelled(Result<(), String>),
|
||||||
@@ -527,7 +533,9 @@ struct ImageDropRequest {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PersistedPostState {
|
#[derive(Debug, Clone)]
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub enum PersistedPostState {
|
||||||
Canonical(Box<Post>),
|
Canonical(Box<Post>),
|
||||||
Translation(Box<bds_core::model::PostTranslation>),
|
Translation(Box<bds_core::model::PostTranslation>),
|
||||||
}
|
}
|
||||||
@@ -1111,6 +1119,8 @@ pub struct BdsApp {
|
|||||||
|
|
||||||
// Editor states (keyed by entity id)
|
// Editor states (keyed by entity id)
|
||||||
post_editors: HashMap<String, PostEditorState>,
|
post_editors: HashMap<String, PostEditorState>,
|
||||||
|
post_autosaves_in_flight: HashSet<String>,
|
||||||
|
post_persistence_lock: Arc<Mutex<()>>,
|
||||||
media_editors: HashMap<String, MediaEditorState>,
|
media_editors: HashMap<String, MediaEditorState>,
|
||||||
template_editors: HashMap<String, TemplateEditorState>,
|
template_editors: HashMap<String, TemplateEditorState>,
|
||||||
script_editors: HashMap<String, ScriptEditorState>,
|
script_editors: HashMap<String, ScriptEditorState>,
|
||||||
@@ -1307,6 +1317,8 @@ impl BdsApp {
|
|||||||
embedded_style_preview: None,
|
embedded_style_preview: None,
|
||||||
main_window_id: None,
|
main_window_id: None,
|
||||||
post_editors: HashMap::new(),
|
post_editors: HashMap::new(),
|
||||||
|
post_autosaves_in_flight: HashSet::new(),
|
||||||
|
post_persistence_lock: Arc::new(Mutex::new(())),
|
||||||
media_editors: HashMap::new(),
|
media_editors: HashMap::new(),
|
||||||
template_editors: HashMap::new(),
|
template_editors: HashMap::new(),
|
||||||
script_editors: HashMap::new(),
|
script_editors: HashMap::new(),
|
||||||
@@ -1410,6 +1422,8 @@ impl BdsApp {
|
|||||||
embedded_style_preview: None,
|
embedded_style_preview: None,
|
||||||
main_window_id: None,
|
main_window_id: None,
|
||||||
post_editors: HashMap::new(),
|
post_editors: HashMap::new(),
|
||||||
|
post_autosaves_in_flight: HashSet::new(),
|
||||||
|
post_persistence_lock: Arc::new(Mutex::new(())),
|
||||||
media_editors: HashMap::new(),
|
media_editors: HashMap::new(),
|
||||||
template_editors: HashMap::new(),
|
template_editors: HashMap::new(),
|
||||||
script_editors: HashMap::new(),
|
script_editors: HashMap::new(),
|
||||||
@@ -2652,20 +2666,44 @@ impl BdsApp {
|
|||||||
| Message::EmbeddedStylePreviewReady(_)) => self.handle_preview_message(message),
|
| Message::EmbeddedStylePreviewReady(_)) => self.handle_preview_message(message),
|
||||||
|
|
||||||
// ── Tasks ──
|
// ── Tasks ──
|
||||||
|
Message::PostAutoSaveFinished {
|
||||||
|
post_id,
|
||||||
|
active_language,
|
||||||
|
edit_revision,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
self.post_autosaves_in_flight.remove(&post_id);
|
||||||
|
match result {
|
||||||
|
Ok(Some(persisted))
|
||||||
|
if self
|
||||||
|
.post_editors
|
||||||
|
.get(&post_id)
|
||||||
|
.is_some_and(|state| state.edit_revision() == edit_revision) =>
|
||||||
|
{
|
||||||
|
self.apply_persisted_post_state(&post_id, &active_language, persisted);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => self.notify_operation_failed("common.autoSave", error),
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::TaskTick => {
|
Message::TaskTick => {
|
||||||
self.task_manager.evict_expired();
|
self.task_manager.evict_expired();
|
||||||
self.refresh_task_snapshots();
|
self.refresh_task_snapshots();
|
||||||
self.process_chat_events();
|
self.process_chat_events();
|
||||||
self.persist_due_chat_surface_state();
|
self.persist_due_chat_surface_state();
|
||||||
let _ = engine::embedding::EmbeddingService::flush_due();
|
let _ = engine::embedding::EmbeddingService::flush_due();
|
||||||
if !self.search_index_rebuild_running {
|
let post_autosave = if self.search_index_rebuild_running {
|
||||||
self.auto_save_due_post_editors();
|
Task::none()
|
||||||
}
|
} else {
|
||||||
|
self.auto_save_due_post_editors()
|
||||||
|
};
|
||||||
let actions = std::mem::take(&mut *self.script_menu_actions.lock().unwrap());
|
let actions = std::mem::take(&mut *self.script_menu_actions.lock().unwrap());
|
||||||
let mut tasks = actions
|
let mut tasks = actions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|action| self.dispatch_menu_action(action))
|
.map(|action| self.dispatch_menu_action(action))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
tasks.push(post_autosave);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let Some(receiver) = &self.lifecycle_receiver {
|
if let Some(receiver) = &self.lifecycle_receiver {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
@@ -5630,24 +5668,62 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auto_save_due_post_editors(&mut self) {
|
fn auto_save_due_post_editors(&mut self) -> Task<Message> {
|
||||||
|
let Some(data_dir) = self.data_dir.clone() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
let now = bds_core::util::now_unix_ms();
|
let now = bds_core::util::now_unix_ms();
|
||||||
let due_ids: Vec<String> = self
|
let due = self
|
||||||
.post_editors
|
.post_editors
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&(_post_id, state)| {
|
.filter(|&(post_id, state)| {
|
||||||
state.is_dirty
|
state.is_dirty
|
||||||
&& state.last_edit_at_ms > 0
|
&& state.last_edit_at_ms > 0
|
||||||
&& now - state.last_edit_at_ms >= POST_AUTO_SAVE_DELAY_MS
|
&& now - state.last_edit_at_ms >= POST_AUTO_SAVE_DELAY_MS
|
||||||
|
&& !self.post_autosaves_in_flight.contains(post_id)
|
||||||
})
|
})
|
||||||
.map(|(post_id, _state)| post_id.clone())
|
.map(|(post_id, state)| {
|
||||||
.collect();
|
(
|
||||||
|
post_id.clone(),
|
||||||
|
state.active_language.clone(),
|
||||||
|
state.edit_revision(),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for post_id in due_ids {
|
let mut tasks = Vec::with_capacity(due.len());
|
||||||
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
for (post_id, active_language, edit_revision, state) in due {
|
||||||
self.notify_operation_failed("common.autoSave", error);
|
self.post_autosaves_in_flight.insert(post_id.clone());
|
||||||
}
|
let db_path = self.db_path.clone();
|
||||||
|
let data_dir = data_dir.clone();
|
||||||
|
let persistence_lock = Arc::clone(&self.post_persistence_lock);
|
||||||
|
let result_post_id = post_id.clone();
|
||||||
|
let result_active_language = active_language.clone();
|
||||||
|
tasks.push(Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let _guard = persistence_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "post persistence lock poisoned".to_string())?;
|
||||||
|
if state.edit_revision() != edit_revision {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||||
|
persist_post_editor_state_impl(&db, &data_dir, &state).map(Some)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| Err(format!("autosave task panicked: {error}")))
|
||||||
|
},
|
||||||
|
move |result| Message::PostAutoSaveFinished {
|
||||||
|
post_id: result_post_id.clone(),
|
||||||
|
active_language: result_active_language.clone(),
|
||||||
|
edit_revision,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
Task::batch(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn operation_failed_text(&self, operation_key: &str, error: impl std::fmt::Display) -> String {
|
fn operation_failed_text(&self, operation_key: &str, error: impl std::fmt::Display) -> String {
|
||||||
@@ -7410,7 +7486,7 @@ impl BdsApp {
|
|||||||
fn apply_persisted_post_state(
|
fn apply_persisted_post_state(
|
||||||
&mut self,
|
&mut self,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
state: &PostEditorState,
|
active_language: &str,
|
||||||
persisted: PersistedPostState,
|
persisted: PersistedPostState,
|
||||||
) {
|
) {
|
||||||
match persisted {
|
match persisted {
|
||||||
@@ -7418,7 +7494,7 @@ impl BdsApp {
|
|||||||
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
||||||
editor.is_dirty = false;
|
editor.is_dirty = false;
|
||||||
editor.last_edit_at_ms = 0;
|
editor.last_edit_at_ms = 0;
|
||||||
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
|
if let Some(draft) = editor.translation_drafts.get_mut(active_language) {
|
||||||
draft.title = translation.title.clone();
|
draft.title = translation.title.clone();
|
||||||
draft.excerpt = translation.excerpt.clone().unwrap_or_default();
|
draft.excerpt = translation.excerpt.clone().unwrap_or_default();
|
||||||
draft.content = translation.content.clone().unwrap_or_default();
|
draft.content = translation.content.clone().unwrap_or_default();
|
||||||
@@ -7474,8 +7550,13 @@ impl BdsApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
||||||
|
|
||||||
|
let _guard = self
|
||||||
|
.post_persistence_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "post persistence lock poisoned".to_string())?;
|
||||||
let persisted = persist_post_editor_state_impl(db, data_dir, &state)?;
|
let persisted = persist_post_editor_state_impl(db, data_dir, &state)?;
|
||||||
self.apply_persisted_post_state(post_id, &state, persisted);
|
drop(_guard);
|
||||||
|
self.apply_persisted_post_state(post_id, &state.active_language, persisted);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7490,8 +7571,13 @@ impl BdsApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "database unavailable".to_string())?;
|
.ok_or_else(|| "database unavailable".to_string())?;
|
||||||
|
|
||||||
|
let _guard = self
|
||||||
|
.post_persistence_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "post persistence lock poisoned".to_string())?;
|
||||||
let persisted = persist_post_editor_preview_state_impl(db, &state)?;
|
let persisted = persist_post_editor_preview_state_impl(db, &state)?;
|
||||||
self.apply_persisted_post_state(post_id, &state, persisted);
|
drop(_guard);
|
||||||
|
self.apply_persisted_post_state(post_id, &state.active_language, persisted);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13166,7 +13252,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_tick_autosaves_dirty_post_editor() {
|
fn task_tick_schedules_post_autosave_without_blocking() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
ai::save_endpoint(
|
ai::save_endpoint(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
@@ -13219,15 +13305,16 @@ mod tests {
|
|||||||
});
|
});
|
||||||
app.active_tab = Some(created.id.clone());
|
app.active_tab = Some(created.id.clone());
|
||||||
|
|
||||||
let _ = app.update(Message::TaskTick);
|
let _task = app.update(Message::TaskTick);
|
||||||
|
|
||||||
let saved = bds_core::db::queries::post::get_post_by_id(
|
let not_yet_saved = bds_core::db::queries::post::get_post_by_id(
|
||||||
app.db.as_ref().unwrap().conn(),
|
app.db.as_ref().unwrap().conn(),
|
||||||
&created.id,
|
&created.id,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(saved.title, "Autosaved");
|
assert_eq!(not_yet_saved.title, "Original");
|
||||||
assert!(!app.post_editors.get(&created.id).unwrap().is_dirty);
|
assert!(app.post_editors.get(&created.id).unwrap().is_dirty);
|
||||||
|
assert!(app.post_autosaves_in_flight.contains(&created.id));
|
||||||
assert!(
|
assert!(
|
||||||
app.task_manager
|
app.task_manager
|
||||||
.snapshots()
|
.snapshots()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use iced::widget::text::{Shaping, Wrapping};
|
use iced::widget::text::{Shaping, Wrapping};
|
||||||
use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input};
|
use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input};
|
||||||
@@ -70,6 +72,7 @@ pub struct PostEditorState {
|
|||||||
pub published_at: Option<i64>,
|
pub published_at: Option<i64>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
pub last_edit_at_ms: i64,
|
pub last_edit_at_ms: i64,
|
||||||
|
edit_revision: Arc<AtomicU64>,
|
||||||
pub metadata_expanded: bool,
|
pub metadata_expanded: bool,
|
||||||
pub excerpt_expanded: bool,
|
pub excerpt_expanded: bool,
|
||||||
pub editor_mode: String,
|
pub editor_mode: String,
|
||||||
@@ -120,6 +123,7 @@ impl Clone for PostEditorState {
|
|||||||
published_at: self.published_at,
|
published_at: self.published_at,
|
||||||
is_dirty: self.is_dirty,
|
is_dirty: self.is_dirty,
|
||||||
last_edit_at_ms: self.last_edit_at_ms,
|
last_edit_at_ms: self.last_edit_at_ms,
|
||||||
|
edit_revision: Arc::clone(&self.edit_revision),
|
||||||
metadata_expanded: self.metadata_expanded,
|
metadata_expanded: self.metadata_expanded,
|
||||||
excerpt_expanded: self.excerpt_expanded,
|
excerpt_expanded: self.excerpt_expanded,
|
||||||
editor_mode: self.editor_mode.clone(),
|
editor_mode: self.editor_mode.clone(),
|
||||||
@@ -188,6 +192,7 @@ impl PostEditorState {
|
|||||||
published_at: post.published_at,
|
published_at: post.published_at,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
last_edit_at_ms: 0,
|
last_edit_at_ms: 0,
|
||||||
|
edit_revision: Arc::new(AtomicU64::new(0)),
|
||||||
metadata_expanded: title.is_empty(),
|
metadata_expanded: title.is_empty(),
|
||||||
excerpt_expanded: false,
|
excerpt_expanded: false,
|
||||||
editor_mode: normalize_editor_mode(default_mode),
|
editor_mode: normalize_editor_mode(default_mode),
|
||||||
@@ -212,6 +217,11 @@ impl PostEditorState {
|
|||||||
pub fn mark_dirty(&mut self) {
|
pub fn mark_dirty(&mut self) {
|
||||||
self.is_dirty = true;
|
self.is_dirty = true;
|
||||||
self.last_edit_at_ms = bds_core::util::now_unix_ms();
|
self.last_edit_at_ms = bds_core::util::now_unix_ms();
|
||||||
|
self.edit_revision.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_revision(&self) -> u64 {
|
||||||
|
self.edit_revision.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_view_state(&mut self, previous: &Self) {
|
pub fn restore_view_state(&mut self, previous: &Self) {
|
||||||
|
|||||||
Reference in New Issue
Block a user