Restore project workspace state
This commit is contained in:
@@ -6,7 +6,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
|
|
||||||
## Available Features
|
## Available Features
|
||||||
|
|
||||||
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, relative-dated entity lists with row deletion, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
|
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, relative-dated entity lists with row deletion, dialogs, tasks, embedded Wry previews, a live Pico CSS theme editor, and per-project restart restoration of the active activity, shell visibility, and open editor tabs.
|
||||||
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
|
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
|
||||||
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub mod tag;
|
|||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod template_rebuild;
|
pub mod template_rebuild;
|
||||||
|
pub mod ui_state;
|
||||||
pub mod validate_media;
|
pub mod validate_media;
|
||||||
pub mod validate_site;
|
pub mod validate_site;
|
||||||
pub mod validate_translations;
|
pub mod validate_translations;
|
||||||
|
|||||||
146
crates/bds-core/src/engine/ui_state.rs
Normal file
146
crates/bds-core/src/engine/ui_state.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db::DbConnection as Connection;
|
||||||
|
use crate::db::queries::{project, setting};
|
||||||
|
use crate::engine::{EngineError, EngineResult};
|
||||||
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
|
const KEY_SUFFIX: &str = "ui_state";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ProjectUiState {
|
||||||
|
pub sidebar_view: String,
|
||||||
|
pub sidebar_visible: bool,
|
||||||
|
pub sidebar_width: f32,
|
||||||
|
pub panel_visible: bool,
|
||||||
|
pub panel_tab: String,
|
||||||
|
pub tabs: Vec<PersistedTab>,
|
||||||
|
pub active_tab: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProjectUiState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sidebar_view: "posts".to_string(),
|
||||||
|
sidebar_visible: true,
|
||||||
|
sidebar_width: 280.0,
|
||||||
|
panel_visible: false,
|
||||||
|
panel_tab: "tasks".to_string(),
|
||||||
|
tabs: Vec::new(),
|
||||||
|
active_tab: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PersistedTab {
|
||||||
|
pub tab_type: String,
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub is_transient: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(conn: &Connection, project_id: &str) -> EngineResult<Option<ProjectUiState>> {
|
||||||
|
match setting::get_setting_by_key(conn, &key(project_id)) {
|
||||||
|
Ok(setting) => Ok(Some(serde_json::from_str(&setting.value)?)),
|
||||||
|
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||||
|
Err(error) => Err(EngineError::Db(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(conn: &Connection, project_id: &str, state: &ProjectUiState) -> EngineResult<()> {
|
||||||
|
match project::get_project_by_id(conn, project_id) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(diesel::result::Error::NotFound) => {
|
||||||
|
return Err(EngineError::NotFound(format!("project {project_id}")));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(EngineError::Db(error)),
|
||||||
|
}
|
||||||
|
let serialized = serde_json::to_string(state)?;
|
||||||
|
setting::set_setting_value(conn, &key(project_id), &serialized, now_unix_ms())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(project_id: &str) -> String {
|
||||||
|
format!("project:{project_id}:{KEY_SUFFIX}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
|
fn setup() -> Database {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
insert_project(db.conn(), &make_test_project("p1", "one")).unwrap();
|
||||||
|
insert_project(db.conn(), &make_test_project("p2", "two")).unwrap();
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_independent_project_sessions() {
|
||||||
|
let db = setup();
|
||||||
|
let one = ProjectUiState {
|
||||||
|
sidebar_view: "media".into(),
|
||||||
|
sidebar_visible: false,
|
||||||
|
sidebar_width: 412.0,
|
||||||
|
panel_visible: true,
|
||||||
|
panel_tab: "output".into(),
|
||||||
|
tabs: vec![PersistedTab {
|
||||||
|
tab_type: "post".into(),
|
||||||
|
id: "post-1".into(),
|
||||||
|
title: "Post one".into(),
|
||||||
|
is_transient: false,
|
||||||
|
}],
|
||||||
|
active_tab: Some("post-1".into()),
|
||||||
|
};
|
||||||
|
let two = ProjectUiState {
|
||||||
|
sidebar_view: "scripts".into(),
|
||||||
|
tabs: vec![PersistedTab {
|
||||||
|
tab_type: "scripts".into(),
|
||||||
|
id: "script-2".into(),
|
||||||
|
title: "Script two".into(),
|
||||||
|
is_transient: true,
|
||||||
|
}],
|
||||||
|
active_tab: Some("script-2".into()),
|
||||||
|
..ProjectUiState::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
save(db.conn(), "p1", &one).unwrap();
|
||||||
|
save(db.conn(), "p2", &two).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(load(db.conn(), "p1").unwrap(), Some(one));
|
||||||
|
assert_eq!(load(db.conn(), "p2").unwrap(), Some(two));
|
||||||
|
assert_eq!(load(db.conn(), "missing").unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_newer_fields_use_safe_defaults() {
|
||||||
|
let db = setup();
|
||||||
|
setting::set_setting_value(db.conn(), &key("p1"), r#"{"sidebar_view":"media"}"#, 1)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let state = load(db.conn(), "p1").unwrap().unwrap();
|
||||||
|
assert_eq!(state.sidebar_view, "media");
|
||||||
|
assert!(state.sidebar_visible);
|
||||||
|
assert_eq!(state.sidebar_width, 280.0);
|
||||||
|
assert!(state.tabs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_project_removes_its_session() {
|
||||||
|
let db = setup();
|
||||||
|
save(db.conn(), "p1", &ProjectUiState::default()).unwrap();
|
||||||
|
|
||||||
|
crate::engine::project::delete_project(db.conn(), "p1", None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(load(db.conn(), "p1").unwrap(), None);
|
||||||
|
assert!(matches!(
|
||||||
|
save(db.conn(), "p1", &ProjectUiState::default()),
|
||||||
|
Err(EngineError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -914,6 +914,21 @@ fn format_bytes(size: i64) -> String {
|
|||||||
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project_languages(data_dir: Option<&Path>) -> (String, Vec<String>) {
|
||||||
|
let metadata = data_dir.and_then(|path| engine::meta::read_project_json(path).ok());
|
||||||
|
let main_language = metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metadata| metadata.main_language.clone())
|
||||||
|
.unwrap_or_else(|| "en".to_string());
|
||||||
|
let mut blog_languages = metadata
|
||||||
|
.map(|metadata| metadata.blog_languages)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !blog_languages.contains(&main_language) {
|
||||||
|
blog_languages.insert(0, main_language.clone());
|
||||||
|
}
|
||||||
|
(main_language, blog_languages)
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────
|
||||||
// App State
|
// App State
|
||||||
// ───────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────
|
||||||
@@ -1105,6 +1120,7 @@ impl BdsApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.data_path.as_ref())
|
.and_then(|p| p.data_path.as_ref())
|
||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
|
let (content_language, blog_languages) = project_languages(data_dir.as_deref());
|
||||||
|
|
||||||
// If no projects exist, ensure the default project per spec
|
// If no projects exist, ensure the default project per spec
|
||||||
let init_task = if projects.is_empty() {
|
let init_task = if projects.is_empty() {
|
||||||
@@ -1145,8 +1161,7 @@ impl BdsApp {
|
|||||||
.map(|(handler, receiver)| (Some(handler), Some(receiver)))
|
.map(|(handler, receiver)| (Some(handler), Some(receiver)))
|
||||||
.unwrap_or((None, None));
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
(
|
let mut app = Self {
|
||||||
Self {
|
|
||||||
db,
|
db,
|
||||||
db_path,
|
db_path,
|
||||||
active_project: active_project.clone(),
|
active_project: active_project.clone(),
|
||||||
@@ -1197,8 +1212,8 @@ impl BdsApp {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
lifecycle_receiver,
|
lifecycle_receiver,
|
||||||
ui_locale: locale,
|
ui_locale: locale,
|
||||||
content_language: "en".to_string(),
|
content_language,
|
||||||
blog_languages: Vec::new(),
|
blog_languages,
|
||||||
offline_mode,
|
offline_mode,
|
||||||
locale_dropdown_open: false,
|
locale_dropdown_open: false,
|
||||||
project_dropdown_open: false,
|
project_dropdown_open: false,
|
||||||
@@ -1239,16 +1254,17 @@ impl BdsApp {
|
|||||||
git_diffs: HashMap::new(),
|
git_diffs: HashMap::new(),
|
||||||
git_file_history: Vec::new(),
|
git_file_history: Vec::new(),
|
||||||
git_file_history_target: None,
|
git_file_history_target: None,
|
||||||
},
|
};
|
||||||
init_task,
|
let restored_ui_task = app.restore_project_ui_state();
|
||||||
)
|
(app, Task::batch([init_task, restored_ui_task]))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self {
|
fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self {
|
||||||
let chat_conversations = engine::chat::list_conversations(db.conn()).unwrap_or_default();
|
let chat_conversations = engine::chat::list_conversations(db.conn()).unwrap_or_default();
|
||||||
let offline_mode = engine::settings::airplane_mode(db.conn()).unwrap_or(true);
|
let offline_mode = engine::settings::airplane_mode(db.conn()).unwrap_or(true);
|
||||||
Self {
|
let (content_language, blog_languages) = project_languages(Some(&data_dir));
|
||||||
|
let mut app = Self {
|
||||||
db: Some(db),
|
db: Some(db),
|
||||||
db_path: data_dir.join("bds.db"),
|
db_path: data_dir.join("bds.db"),
|
||||||
active_project: Some(project.clone()),
|
active_project: Some(project.clone()),
|
||||||
@@ -1299,8 +1315,8 @@ impl BdsApp {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
lifecycle_receiver: None,
|
lifecycle_receiver: None,
|
||||||
ui_locale: UiLocale::En,
|
ui_locale: UiLocale::En,
|
||||||
content_language: "en".to_string(),
|
content_language,
|
||||||
blog_languages: Vec::new(),
|
blog_languages,
|
||||||
offline_mode,
|
offline_mode,
|
||||||
locale_dropdown_open: false,
|
locale_dropdown_open: false,
|
||||||
project_dropdown_open: false,
|
project_dropdown_open: false,
|
||||||
@@ -1340,7 +1356,9 @@ impl BdsApp {
|
|||||||
git_diffs: HashMap::new(),
|
git_diffs: HashMap::new(),
|
||||||
git_file_history: Vec::new(),
|
git_file_history: Vec::new(),
|
||||||
git_file_history_target: None,
|
git_file_history_target: None,
|
||||||
}
|
};
|
||||||
|
let _ = app.restore_project_ui_state();
|
||||||
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
@@ -1363,7 +1381,7 @@ impl BdsApp {
|
|||||||
// When switching to/from Posts/Pages, re-query with correct filter
|
// When switching to/from Posts/Pages, re-query with correct filter
|
||||||
let needs_post_refresh = old_view != new_view
|
let needs_post_refresh = old_view != new_view
|
||||||
&& matches!(new_view, SidebarView::Posts | SidebarView::Pages);
|
&& matches!(new_view, SidebarView::Posts | SidebarView::Pages);
|
||||||
if needs_post_refresh {
|
let task = if needs_post_refresh {
|
||||||
self.refresh_sidebar_posts()
|
self.refresh_sidebar_posts()
|
||||||
} else if new_view == SidebarView::Git && new_visible {
|
} else if new_view == SidebarView::Git && new_visible {
|
||||||
self.refresh_git()
|
self.refresh_git()
|
||||||
@@ -1372,14 +1390,18 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
} else {
|
} else {
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
};
|
||||||
|
self.persist_project_ui_state();
|
||||||
|
task
|
||||||
}
|
}
|
||||||
Message::ToggleSidebar => {
|
Message::ToggleSidebar => {
|
||||||
self.sidebar_visible = !self.sidebar_visible;
|
self.sidebar_visible = !self.sidebar_visible;
|
||||||
|
self.persist_project_ui_state();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::TogglePanel => {
|
Message::TogglePanel => {
|
||||||
self.panel_visible = !self.panel_visible;
|
self.panel_visible = !self.panel_visible;
|
||||||
|
self.persist_project_ui_state();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::CreatePost => self.create_sidebar_post(false),
|
Message::CreatePost => self.create_sidebar_post(false),
|
||||||
@@ -1459,6 +1481,7 @@ impl BdsApp {
|
|||||||
self.active_tab = None;
|
self.active_tab = None;
|
||||||
}
|
}
|
||||||
self.refresh_chat_conversations();
|
self.refresh_chat_conversations();
|
||||||
|
self.persist_project_ui_state();
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "chat.deleted"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "chat.deleted"));
|
||||||
}
|
}
|
||||||
Err(error) => self.notify(ToastLevel::Error, &error),
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
||||||
@@ -1663,6 +1686,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
Message::SidebarResizeEnd => {
|
Message::SidebarResizeEnd => {
|
||||||
self.sidebar_dragging = false;
|
self.sidebar_dragging = false;
|
||||||
|
self.persist_project_ui_state();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,6 +1704,7 @@ impl BdsApp {
|
|||||||
self.git_diffs.remove(&id);
|
self.git_diffs.remove(&id);
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
self.sync_embedded_previews()
|
self.sync_embedded_previews()
|
||||||
}
|
}
|
||||||
Message::SelectTab(id) => {
|
Message::SelectTab(id) => {
|
||||||
@@ -1690,6 +1715,7 @@ impl BdsApp {
|
|||||||
self.active_tab = Some(id.clone());
|
self.active_tab = Some(id.clone());
|
||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
|
self.persist_project_ui_state();
|
||||||
let semantic_task = self
|
let semantic_task = self
|
||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1701,6 +1727,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
// ── Project management ──
|
// ── Project management ──
|
||||||
Message::ProjectsLoaded(projects) => {
|
Message::ProjectsLoaded(projects) => {
|
||||||
|
let needs_ui_state_restore = self.active_project.is_none();
|
||||||
self.projects = projects;
|
self.projects = projects;
|
||||||
if let Some(ref db) = self.db {
|
if let Some(ref db) = self.db {
|
||||||
self.active_project = engine::project::get_active_project(db.conn())
|
self.active_project = engine::project::get_active_project(db.conn())
|
||||||
@@ -1731,15 +1758,14 @@ impl BdsApp {
|
|||||||
self.add_output(&message);
|
self.add_output(&message);
|
||||||
}
|
}
|
||||||
// Extract content language from project metadata
|
// Extract content language from project metadata
|
||||||
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
(self.content_language, self.blog_languages) =
|
||||||
let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string());
|
project_languages(Some(&data_dir));
|
||||||
self.content_language = main_lang.clone();
|
|
||||||
self.blog_languages = meta.blog_languages;
|
|
||||||
if !self.blog_languages.contains(&main_lang) {
|
|
||||||
self.blog_languages.insert(0, main_lang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let restored_ui_task = if needs_ui_state_restore {
|
||||||
|
self.restore_project_ui_state()
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
let sidebar_task = self.refresh_counts();
|
let sidebar_task = self.refresh_counts();
|
||||||
let documentation_task = self.reload_changed_documentation();
|
let documentation_task = self.reload_changed_documentation();
|
||||||
let menu_editor_task = if self
|
let menu_editor_task = if self
|
||||||
@@ -1758,10 +1784,14 @@ impl BdsApp {
|
|||||||
Task::done(Message::EmbeddingBackfill),
|
Task::done(Message::EmbeddingBackfill),
|
||||||
documentation_task,
|
documentation_task,
|
||||||
menu_editor_task,
|
menu_editor_task,
|
||||||
|
restored_ui_task,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
Message::SwitchProject(project_id) => {
|
Message::SwitchProject(project_id) => {
|
||||||
self.project_dropdown_open = false;
|
self.project_dropdown_open = false;
|
||||||
|
self.flush_active_post_editor();
|
||||||
|
self.persist_project_ui_state();
|
||||||
|
let mut switched = false;
|
||||||
if let Some(ref db) = self.db {
|
if let Some(ref db) = self.db {
|
||||||
if let (Some(outgoing), Some(data_dir)) = (&self.active_project, &self.data_dir)
|
if let (Some(outgoing), Some(data_dir)) = (&self.active_project, &self.data_dir)
|
||||||
{
|
{
|
||||||
@@ -1799,17 +1829,8 @@ impl BdsApp {
|
|||||||
.and_then(|p| p.data_path.as_ref())
|
.and_then(|p| p.data_path.as_ref())
|
||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
// Per metadata.allium StartupSync
|
// Per metadata.allium StartupSync
|
||||||
if let Some(data_dir) = self.data_dir.clone()
|
(self.content_language, self.blog_languages) =
|
||||||
&& let Ok(meta) = engine::meta::read_project_json(&data_dir)
|
project_languages(self.data_dir.as_deref());
|
||||||
{
|
|
||||||
let main_lang =
|
|
||||||
meta.main_language.unwrap_or_else(|| "en".to_string());
|
|
||||||
self.content_language = main_lang.clone();
|
|
||||||
self.blog_languages = meta.blog_languages;
|
|
||||||
if !self.blog_languages.contains(&main_lang) {
|
|
||||||
self.blog_languages.insert(0, main_lang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.tabs.iter().any(|tab| tab.tab_type == TabType::Style) {
|
if self.tabs.iter().any(|tab| tab.tab_type == TabType::Style) {
|
||||||
self.style_view_state = self.hydrate_style_state();
|
self.style_view_state = self.hydrate_style_state();
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1847,7 @@ impl BdsApp {
|
|||||||
&[("name", &name)],
|
&[("name", &name)],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
switched = true;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.notify(
|
self.notify(
|
||||||
@@ -1835,12 +1857,35 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let restored_ui_task = if switched {
|
||||||
|
self.restore_project_ui_state()
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
|
let documentation_task = if switched {
|
||||||
|
self.reload_changed_documentation()
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
|
let menu_editor_task = if switched
|
||||||
|
&& self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.any(|tab| tab.tab_type == TabType::MenuEditor)
|
||||||
|
{
|
||||||
|
Task::done(Message::MenuEditor(MenuEditorMsg::Reload))
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
Task::batch([
|
Task::batch([
|
||||||
self.refresh_counts(),
|
self.refresh_counts(),
|
||||||
self.refresh_git_if_visible(),
|
self.refresh_git_if_visible(),
|
||||||
Task::done(Message::EmbeddingBackfill),
|
Task::done(Message::EmbeddingBackfill),
|
||||||
self.sync_embedded_previews(),
|
self.sync_embedded_previews(),
|
||||||
|
restored_ui_task,
|
||||||
|
documentation_task,
|
||||||
|
menu_editor_task,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
Message::RequestCreateProject => {
|
Message::RequestCreateProject => {
|
||||||
@@ -1851,6 +1896,9 @@ impl BdsApp {
|
|||||||
"dialog.openProject",
|
"dialog.openProject",
|
||||||
)),
|
)),
|
||||||
Message::CreateProject { name, data_path } => {
|
Message::CreateProject { name, data_path } => {
|
||||||
|
self.flush_active_post_editor();
|
||||||
|
self.persist_project_ui_state();
|
||||||
|
let mut restored_ui_task = Task::none();
|
||||||
if let Some(ref db) = self.db {
|
if let Some(ref db) = self.db {
|
||||||
let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string());
|
let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string());
|
||||||
match engine::project::create_project(db.conn(), &name, path_str.as_deref()) {
|
match engine::project::create_project(db.conn(), &name, path_str.as_deref()) {
|
||||||
@@ -1862,6 +1910,9 @@ impl BdsApp {
|
|||||||
self.active_project = Some(project.clone());
|
self.active_project = Some(project.clone());
|
||||||
self.preview_session = None;
|
self.preview_session = None;
|
||||||
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
||||||
|
(self.content_language, self.blog_languages) =
|
||||||
|
project_languages(self.data_dir.as_deref());
|
||||||
|
restored_ui_task = self.restore_project_ui_state();
|
||||||
let msg = tw(
|
let msg = tw(
|
||||||
self.ui_locale,
|
self.ui_locale,
|
||||||
"projectSelector.toast.created",
|
"projectSelector.toast.created",
|
||||||
@@ -1877,7 +1928,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.refresh_git_if_visible()
|
Task::batch([self.refresh_git_if_visible(), restored_ui_task])
|
||||||
}
|
}
|
||||||
Message::DeleteProject(project_id) => {
|
Message::DeleteProject(project_id) => {
|
||||||
if let Some(ref db) = self.db {
|
if let Some(ref db) = self.db {
|
||||||
@@ -1920,6 +1971,8 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ProjectFolderPicked(path) => {
|
Message::ProjectFolderPicked(path) => {
|
||||||
|
self.flush_active_post_editor();
|
||||||
|
self.persist_project_ui_state();
|
||||||
if let (Some(path), Some(db)) = (path, self.db.as_ref()) {
|
if let (Some(path), Some(db)) = (path, self.db.as_ref()) {
|
||||||
match engine::project::open_project(db.conn(), &path) {
|
match engine::project::open_project(db.conn(), &path) {
|
||||||
Ok(project) => {
|
Ok(project) => {
|
||||||
@@ -1929,10 +1982,23 @@ impl BdsApp {
|
|||||||
self.reset_git_for_project_change();
|
self.reset_git_for_project_change();
|
||||||
self.active_project = Some(project.clone());
|
self.active_project = Some(project.clone());
|
||||||
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
||||||
|
(self.content_language, self.blog_languages) =
|
||||||
|
project_languages(self.data_dir.as_deref());
|
||||||
self.preview_session = None;
|
self.preview_session = None;
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
self.hide_embedded_style_preview();
|
self.hide_embedded_style_preview();
|
||||||
self.style_view_state = None;
|
self.style_view_state = None;
|
||||||
|
let restored_ui_task = self.restore_project_ui_state();
|
||||||
|
let documentation_task = self.reload_changed_documentation();
|
||||||
|
let menu_editor_task = if self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.any(|tab| tab.tab_type == TabType::MenuEditor)
|
||||||
|
{
|
||||||
|
Task::done(Message::MenuEditor(MenuEditorMsg::Reload))
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
self.notify(
|
self.notify(
|
||||||
ToastLevel::Success,
|
ToastLevel::Success,
|
||||||
&tw(
|
&tw(
|
||||||
@@ -1945,6 +2011,9 @@ impl BdsApp {
|
|||||||
return Task::batch([
|
return Task::batch([
|
||||||
self.refresh_counts(),
|
self.refresh_counts(),
|
||||||
self.refresh_git_if_visible(),
|
self.refresh_git_if_visible(),
|
||||||
|
restored_ui_task,
|
||||||
|
documentation_task,
|
||||||
|
menu_editor_task,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
Err(error) => self.notify(
|
Err(error) => self.notify(
|
||||||
@@ -2168,6 +2237,9 @@ impl BdsApp {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if !imported.is_empty() {
|
||||||
|
self.persist_project_ui_state();
|
||||||
|
}
|
||||||
self.refresh_sidebar_media()
|
self.refresh_sidebar_media()
|
||||||
}
|
}
|
||||||
Message::MediaReplacementPicked { media_id, path } => {
|
Message::MediaReplacementPicked { media_id, path } => {
|
||||||
@@ -2577,7 +2649,10 @@ impl BdsApp {
|
|||||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "blogmark.noProject"));
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "blogmark.noProject"));
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
|
let mut project_restore_task = Task::none();
|
||||||
if self.active_project.as_ref().map(|project| &project.id) != Some(&target.id) {
|
if self.active_project.as_ref().map(|project| &project.id) != Some(&target.id) {
|
||||||
|
self.flush_active_post_editor();
|
||||||
|
self.persist_project_ui_state();
|
||||||
if let Some(db) = self.db.as_ref()
|
if let Some(db) = self.db.as_ref()
|
||||||
&& let Err(error) =
|
&& let Err(error) =
|
||||||
engine::project::set_active_project(db.conn(), &target.id)
|
engine::project::set_active_project(db.conn(), &target.id)
|
||||||
@@ -2585,20 +2660,15 @@ impl BdsApp {
|
|||||||
self.notify(ToastLevel::Error, &error.to_string());
|
self.notify(ToastLevel::Error, &error.to_string());
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
self.reset_git_for_project_change();
|
||||||
self.active_project = Some(target.clone());
|
self.active_project = Some(target.clone());
|
||||||
self.data_dir = Some(data_dir.clone());
|
self.data_dir = Some(data_dir.clone());
|
||||||
self.preview_session = None;
|
self.preview_session = None;
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
self.hide_embedded_style_preview();
|
self.hide_embedded_style_preview();
|
||||||
self.style_view_state = None;
|
(self.content_language, self.blog_languages) =
|
||||||
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
project_languages(Some(&data_dir));
|
||||||
let main_language = meta.main_language.unwrap_or_else(|| "en".into());
|
project_restore_task = self.restore_project_ui_state();
|
||||||
self.content_language = main_language.clone();
|
|
||||||
self.blog_languages = meta.blog_languages;
|
|
||||||
if !self.blog_languages.contains(&main_language) {
|
|
||||||
self.blog_languages.insert(0, main_language);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
}
|
}
|
||||||
let db_path = self.db_path.clone();
|
let db_path = self.db_path.clone();
|
||||||
@@ -2612,7 +2682,7 @@ impl BdsApp {
|
|||||||
t(self.ui_locale, "dialog.selectFolder"),
|
t(self.ui_locale, "dialog.selectFolder"),
|
||||||
);
|
);
|
||||||
self.refresh_task_snapshots();
|
self.refresh_task_snapshots();
|
||||||
Task::perform(
|
let import_task = Task::perform(
|
||||||
async move {
|
async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if !task_manager.wait_until_runnable(task_id) {
|
if !task_manager.wait_until_runnable(task_id) {
|
||||||
@@ -2642,7 +2712,8 @@ impl BdsApp {
|
|||||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||||
},
|
},
|
||||||
move |result| Message::BlogmarkImported { task_id, result },
|
move |result| Message::BlogmarkImported { task_id, result },
|
||||||
)
|
);
|
||||||
|
Task::batch([project_restore_task, import_task])
|
||||||
}
|
}
|
||||||
Message::BlogmarkImported { task_id, result } => match result {
|
Message::BlogmarkImported { task_id, result } => match result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
@@ -2683,6 +2754,7 @@ impl BdsApp {
|
|||||||
// ── Panel ──
|
// ── Panel ──
|
||||||
Message::SetPanelTab(tab) => {
|
Message::SetPanelTab(tab) => {
|
||||||
self.panel_tab = tab;
|
self.panel_tab = tab;
|
||||||
|
self.persist_project_ui_state();
|
||||||
if tab == PanelTab::GitLog {
|
if tab == PanelTab::GitLog {
|
||||||
self.refresh_git_file_history()
|
self.refresh_git_file_history()
|
||||||
} else {
|
} else {
|
||||||
@@ -3818,10 +3890,12 @@ impl BdsApp {
|
|||||||
match destination {
|
match destination {
|
||||||
"toggle_sidebar" => {
|
"toggle_sidebar" => {
|
||||||
self.sidebar_visible = !self.sidebar_visible;
|
self.sidebar_visible = !self.sidebar_visible;
|
||||||
|
self.persist_project_ui_state();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
"toggle_panel" => {
|
"toggle_panel" => {
|
||||||
self.panel_visible = !self.panel_visible;
|
self.panel_visible = !self.panel_visible;
|
||||||
|
self.persist_project_ui_state();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
"toggle_assistant_sidebar" => {
|
"toggle_assistant_sidebar" => {
|
||||||
@@ -3831,6 +3905,7 @@ impl BdsApp {
|
|||||||
self.sidebar_view = SidebarView::Chat;
|
self.sidebar_view = SidebarView::Chat;
|
||||||
self.sidebar_visible = true;
|
self.sidebar_visible = true;
|
||||||
}
|
}
|
||||||
|
self.persist_project_ui_state();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -3850,6 +3925,7 @@ impl BdsApp {
|
|||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
self.sidebar_visible = true;
|
self.sidebar_visible = true;
|
||||||
|
self.persist_project_ui_state();
|
||||||
if destination == "settings" && entity_id.is_none() {
|
if destination == "settings" && entity_id.is_none() {
|
||||||
self.open_singleton_tab(TabType::Settings, "common.settings");
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
||||||
if self.settings_state.is_none() {
|
if self.settings_state.is_none() {
|
||||||
@@ -3906,6 +3982,7 @@ impl BdsApp {
|
|||||||
self.active_tab = Some(tab.id.clone());
|
self.active_tab = Some(tab.id.clone());
|
||||||
self.load_editor_for_tab(&tab);
|
self.load_editor_for_tab(&tab);
|
||||||
}
|
}
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_ui_locale(&mut self, locale: UiLocale) {
|
fn apply_ui_locale(&mut self, locale: UiLocale) {
|
||||||
@@ -4474,6 +4551,7 @@ impl BdsApp {
|
|||||||
self.active_tab = Some(t.id.clone());
|
self.active_tab = Some(t.id.clone());
|
||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_sidebar_post(&mut self, is_page: bool) -> Task<Message> {
|
fn create_sidebar_post(&mut self, is_page: bool) -> Task<Message> {
|
||||||
@@ -4515,6 +4593,7 @@ impl BdsApp {
|
|||||||
self.load_editor_for_tab(&tab);
|
self.load_editor_for_tab(&tab);
|
||||||
}
|
}
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
self.refresh_counts()
|
self.refresh_counts()
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -4555,6 +4634,7 @@ impl BdsApp {
|
|||||||
self.load_editor_for_tab(&tab);
|
self.load_editor_for_tab(&tab);
|
||||||
}
|
}
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
self.refresh_counts()
|
self.refresh_counts()
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -4594,6 +4674,7 @@ impl BdsApp {
|
|||||||
self.load_editor_for_tab(&tab);
|
self.load_editor_for_tab(&tab);
|
||||||
}
|
}
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
self.refresh_counts()
|
self.refresh_counts()
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -4627,6 +4708,7 @@ impl BdsApp {
|
|||||||
self.load_editor_for_tab(&tab);
|
self.load_editor_for_tab(&tab);
|
||||||
}
|
}
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||||
}
|
}
|
||||||
@@ -5593,6 +5675,211 @@ impl BdsApp {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persisted_project_ui_state(&self) -> engine::ui_state::ProjectUiState {
|
||||||
|
engine::ui_state::ProjectUiState {
|
||||||
|
sidebar_view: self.sidebar_view.as_str().to_string(),
|
||||||
|
sidebar_visible: self.sidebar_visible,
|
||||||
|
sidebar_width: self.sidebar_width,
|
||||||
|
panel_visible: self.panel_visible,
|
||||||
|
panel_tab: self.panel_tab.as_str().to_string(),
|
||||||
|
tabs: self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| engine::ui_state::PersistedTab {
|
||||||
|
tab_type: tab.tab_type.as_str().to_string(),
|
||||||
|
id: tab.id.clone(),
|
||||||
|
title: tab.title.clone(),
|
||||||
|
is_transient: tab.is_transient,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
active_tab: self.active_tab.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_project_ui_state(&mut self) {
|
||||||
|
let Some(project_id) = self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.map(|project| project.id.clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let state = self.persisted_project_ui_state();
|
||||||
|
let result = self
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| engine::EngineError::Validation("database unavailable".to_string()))
|
||||||
|
.and_then(|db| engine::ui_state::save(db.conn(), &project_id, &state));
|
||||||
|
if let Err(error) = result {
|
||||||
|
let message = self.operation_failed_text("common.workspaceState", error);
|
||||||
|
self.add_output(&message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_project_ui_state(&mut self) -> Task<Message> {
|
||||||
|
let Some(project_id) = self
|
||||||
|
.active_project
|
||||||
|
.as_ref()
|
||||||
|
.map(|project| project.id.clone())
|
||||||
|
else {
|
||||||
|
self.reset_project_ui_state();
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let loaded = self
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| engine::EngineError::Validation("database unavailable".to_string()))
|
||||||
|
.and_then(|db| engine::ui_state::load(db.conn(), &project_id));
|
||||||
|
|
||||||
|
self.reset_project_ui_state();
|
||||||
|
let state = match loaded {
|
||||||
|
Ok(Some(state)) => state,
|
||||||
|
Ok(None) => return Task::none(),
|
||||||
|
Err(error) => {
|
||||||
|
let message = self.operation_failed_text("common.workspaceState", error);
|
||||||
|
self.add_output(&message);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.sidebar_view =
|
||||||
|
SidebarView::from_persisted(&state.sidebar_view).unwrap_or(SidebarView::Posts);
|
||||||
|
self.sidebar_visible = state.sidebar_visible;
|
||||||
|
self.sidebar_width = state.sidebar_width.clamp(200.0, 500.0);
|
||||||
|
self.panel_visible = state.panel_visible;
|
||||||
|
self.panel_tab = PanelTab::from_persisted(&state.panel_tab).unwrap_or(PanelTab::Tasks);
|
||||||
|
for persisted in state.tabs {
|
||||||
|
if let Some(tab) = self.restore_tab(&project_id, persisted) {
|
||||||
|
tabs::open_tab(&mut self.tabs, tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.active_tab = state
|
||||||
|
.active_tab
|
||||||
|
.filter(|id| self.tabs.iter().any(|tab| tab.id == *id));
|
||||||
|
let restored_tabs = self.tabs.clone();
|
||||||
|
for tab in &restored_tabs {
|
||||||
|
self.load_editor_for_tab(tab);
|
||||||
|
}
|
||||||
|
self.enforce_panel_tab_fallback();
|
||||||
|
self.sync_menu_state();
|
||||||
|
self.reload_restored_git_diffs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_restored_git_diffs(&mut self) -> Task<Message> {
|
||||||
|
let active_tab = self.active_tab.clone();
|
||||||
|
let git_tabs = self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.filter(|tab| tab.tab_type == TabType::GitDiff)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for tab in git_tabs {
|
||||||
|
if let Some(hash) = tab.id.strip_prefix("git-diff:commit:") {
|
||||||
|
let subject = tab
|
||||||
|
.title
|
||||||
|
.split_once(' ')
|
||||||
|
.map(|(_, subject)| subject)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
tasks.push(self.open_git_commit_diff(hash.to_string(), subject));
|
||||||
|
} else if let Some(path) = tab.id.strip_prefix("git-diff:") {
|
||||||
|
tasks.push(self.open_git_file_diff(path.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.active_tab = active_tab.filter(|id| self.tabs.iter().any(|tab| tab.id == *id));
|
||||||
|
self.enforce_panel_tab_fallback();
|
||||||
|
self.persist_project_ui_state();
|
||||||
|
Task::batch(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_project_ui_state(&mut self) {
|
||||||
|
self.sidebar_view = SidebarView::Posts;
|
||||||
|
self.sidebar_visible = true;
|
||||||
|
self.sidebar_width = 280.0;
|
||||||
|
self.sidebar_dragging = false;
|
||||||
|
self.tabs.clear();
|
||||||
|
self.active_tab = None;
|
||||||
|
self.panel_visible = false;
|
||||||
|
self.panel_tab = PanelTab::Tasks;
|
||||||
|
self.post_editors.clear();
|
||||||
|
self.media_editors.clear();
|
||||||
|
self.template_editors.clear();
|
||||||
|
self.script_editors.clear();
|
||||||
|
self.import_editors.clear();
|
||||||
|
self.chat_editors.clear();
|
||||||
|
self.tags_view_state = None;
|
||||||
|
self.settings_state = None;
|
||||||
|
self.style_view_state = None;
|
||||||
|
self.dashboard_state = None;
|
||||||
|
self.site_validation_state = SiteValidationState::default();
|
||||||
|
self.duplicates_state = DuplicatesState::default();
|
||||||
|
self.metadata_diff_state = MetadataDiffState::default();
|
||||||
|
self.menu_editor_state = MenuEditorState::default();
|
||||||
|
self.translation_validation_state = Default::default();
|
||||||
|
self.git_diffs.clear();
|
||||||
|
self.git_file_history.clear();
|
||||||
|
self.git_file_history_target = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_tab(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
persisted: engine::ui_state::PersistedTab,
|
||||||
|
) -> Option<Tab> {
|
||||||
|
let tab_type = TabType::from_persisted(&persisted.tab_type)?;
|
||||||
|
let is_transient = persisted.is_transient
|
||||||
|
&& !tab_type.is_singleton()
|
||||||
|
&& !matches!(tab_type, TabType::Chat | TabType::Import);
|
||||||
|
let db = self.db.as_ref()?;
|
||||||
|
let title = match tab_type {
|
||||||
|
TabType::Post => {
|
||||||
|
let post =
|
||||||
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &persisted.id).ok()?;
|
||||||
|
(post.project_id == project_id).then_some(post.title)?
|
||||||
|
}
|
||||||
|
TabType::Media => {
|
||||||
|
let media =
|
||||||
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &persisted.id).ok()?;
|
||||||
|
(media.project_id == project_id)
|
||||||
|
.then_some(media.title.unwrap_or(media.original_name))?
|
||||||
|
}
|
||||||
|
TabType::Templates => {
|
||||||
|
let template =
|
||||||
|
bds_core::db::queries::template::get_template_by_id(db.conn(), &persisted.id)
|
||||||
|
.ok()?;
|
||||||
|
(template.project_id == project_id).then_some(template.title)?
|
||||||
|
}
|
||||||
|
TabType::Scripts => {
|
||||||
|
let script =
|
||||||
|
bds_core::db::queries::script::get_script_by_id(db.conn(), &persisted.id)
|
||||||
|
.ok()?;
|
||||||
|
(script.project_id == project_id).then_some(script.title)?
|
||||||
|
}
|
||||||
|
TabType::Import => {
|
||||||
|
let definition =
|
||||||
|
engine::wordpress_import::get_definition(db.conn(), &persisted.id).ok()?;
|
||||||
|
(definition.project_id == project_id).then_some(definition.name)?
|
||||||
|
}
|
||||||
|
TabType::Chat => {
|
||||||
|
engine::chat::get_conversation(db.conn(), &persisted.id)
|
||||||
|
.ok()?
|
||||||
|
.title
|
||||||
|
}
|
||||||
|
TabType::GitDiff => persisted.title,
|
||||||
|
_ => {
|
||||||
|
let key = tab_type.i18n_key()?;
|
||||||
|
t(self.ui_locale, key)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(Tab {
|
||||||
|
id: persisted.id,
|
||||||
|
tab_type,
|
||||||
|
title,
|
||||||
|
is_transient,
|
||||||
|
is_dirty: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn open_tab(&mut self, tab: Tab) -> Task<Message> {
|
fn open_tab(&mut self, tab: Tab) -> Task<Message> {
|
||||||
self.flush_active_post_editor();
|
self.flush_active_post_editor();
|
||||||
let index = tabs::open_tab(&mut self.tabs, tab);
|
let index = tabs::open_tab(&mut self.tabs, tab);
|
||||||
@@ -5606,6 +5893,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
let mut tasks = vec![self.sync_embedded_previews()];
|
let mut tasks = vec![self.sync_embedded_previews()];
|
||||||
if let Some(post_id) = semantic_post_id {
|
if let Some(post_id) = semantic_post_id {
|
||||||
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
|
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
|
||||||
@@ -6859,6 +7147,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
fn delete_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
||||||
@@ -10136,6 +10425,190 @@ mod tests {
|
|||||||
assert_eq!(app.guide_documentation.signature, 42);
|
assert_eq!(app.guide_documentation.signature, 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_ui_state_survives_switches_and_app_restart() {
|
||||||
|
use crate::state::navigation::PanelTab;
|
||||||
|
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let p1_dir = temp.path().join("one");
|
||||||
|
let p2_dir = temp.path().join("two");
|
||||||
|
let db_path = temp.path().join("app.db");
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
|
let p1 = bds_core::engine::project::create_project(
|
||||||
|
db.conn(),
|
||||||
|
"One",
|
||||||
|
Some(p1_dir.to_str().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p2 = bds_core::engine::project::create_project(
|
||||||
|
db.conn(),
|
||||||
|
"Two",
|
||||||
|
Some(p2_dir.to_str().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
bds_core::engine::project::set_active_project(db.conn(), &p1.id).unwrap();
|
||||||
|
let p1_post = post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
&p1_dir,
|
||||||
|
&p1.id,
|
||||||
|
"One post",
|
||||||
|
Some("One body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p2_post = post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
&p2_dir,
|
||||||
|
&p2.id,
|
||||||
|
"Two post",
|
||||||
|
Some("Two body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut app = BdsApp::new_for_tests(db, p1.clone(), p1_dir.clone());
|
||||||
|
app.projects = vec![p1.clone(), p2.clone()];
|
||||||
|
let _ = app.update(Message::OpenTab(Tab {
|
||||||
|
id: p1_post.id.clone(),
|
||||||
|
tab_type: TabType::Post,
|
||||||
|
title: p1_post.title.clone(),
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: false,
|
||||||
|
}));
|
||||||
|
let _ = app.update(Message::SetActiveView(SidebarView::Media));
|
||||||
|
let _ = app.update(Message::ToggleSidebar);
|
||||||
|
app.sidebar_width = 412.0;
|
||||||
|
let _ = app.update(Message::SidebarResizeEnd);
|
||||||
|
let _ = app.update(Message::TogglePanel);
|
||||||
|
let _ = app.update(Message::SetPanelTab(PanelTab::Output));
|
||||||
|
|
||||||
|
let _ = app.update(Message::SwitchProject(p2.id.clone()));
|
||||||
|
assert!(app.tabs.is_empty());
|
||||||
|
assert_eq!(app.sidebar_view, SidebarView::Posts);
|
||||||
|
assert!(app.sidebar_visible);
|
||||||
|
assert!(!app.panel_visible);
|
||||||
|
|
||||||
|
let _ = app.update(Message::OpenTab(Tab {
|
||||||
|
id: p2_post.id.clone(),
|
||||||
|
tab_type: TabType::Post,
|
||||||
|
title: p2_post.title.clone(),
|
||||||
|
is_transient: true,
|
||||||
|
is_dirty: false,
|
||||||
|
}));
|
||||||
|
let _ = app.update(Message::SetActiveView(SidebarView::Scripts));
|
||||||
|
|
||||||
|
let _ = app.update(Message::SwitchProject(p1.id.clone()));
|
||||||
|
assert_eq!(app.sidebar_view, SidebarView::Media);
|
||||||
|
assert!(!app.sidebar_visible);
|
||||||
|
assert_eq!(app.sidebar_width, 412.0);
|
||||||
|
assert!(app.panel_visible);
|
||||||
|
assert_eq!(app.panel_tab, PanelTab::Output);
|
||||||
|
assert_eq!(app.tabs.len(), 1);
|
||||||
|
assert_eq!(app.active_tab.as_deref(), Some(p1_post.id.as_str()));
|
||||||
|
assert_eq!(app.tabs[0].title, "One post");
|
||||||
|
assert!(app.post_editors.contains_key(&p1_post.id));
|
||||||
|
assert!(!app.post_editors.contains_key(&p2_post.id));
|
||||||
|
|
||||||
|
let _ = app.update(Message::SwitchProject(p2.id.clone()));
|
||||||
|
assert_eq!(app.sidebar_view, SidebarView::Scripts);
|
||||||
|
assert!(app.sidebar_visible);
|
||||||
|
assert_eq!(app.tabs.len(), 1);
|
||||||
|
assert_eq!(app.active_tab.as_deref(), Some(p2_post.id.as_str()));
|
||||||
|
assert!(app.tabs[0].is_transient);
|
||||||
|
assert!(app.post_editors.contains_key(&p2_post.id));
|
||||||
|
assert!(!app.post_editors.contains_key(&p1_post.id));
|
||||||
|
|
||||||
|
drop(app);
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let restarted = BdsApp::new_for_tests(db, p2, p2_dir);
|
||||||
|
assert_eq!(restarted.sidebar_view, SidebarView::Scripts);
|
||||||
|
assert!(restarted.sidebar_visible);
|
||||||
|
assert_eq!(restarted.tabs.len(), 1);
|
||||||
|
assert_eq!(restarted.active_tab.as_deref(), Some(p2_post.id.as_str()));
|
||||||
|
assert_eq!(restarted.tabs[0].title, "Two post");
|
||||||
|
assert!(restarted.post_editors.contains_key(&p2_post.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_ui_restore_discards_stale_unknown_and_cross_project_tabs() {
|
||||||
|
let (db, project, temp) = setup();
|
||||||
|
let other = Project {
|
||||||
|
id: "p2".to_string(),
|
||||||
|
name: "Other".to_string(),
|
||||||
|
slug: "other".to_string(),
|
||||||
|
is_active: false,
|
||||||
|
..project.clone()
|
||||||
|
};
|
||||||
|
insert_project(db.conn(), &other).unwrap();
|
||||||
|
let other_post = post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
temp.path(),
|
||||||
|
&other.id,
|
||||||
|
"Other post",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
bds_core::engine::ui_state::save(
|
||||||
|
db.conn(),
|
||||||
|
&project.id,
|
||||||
|
&bds_core::engine::ui_state::ProjectUiState {
|
||||||
|
tabs: vec![
|
||||||
|
bds_core::engine::ui_state::PersistedTab {
|
||||||
|
tab_type: "post".into(),
|
||||||
|
id: other_post.id.clone(),
|
||||||
|
title: "Wrong project".into(),
|
||||||
|
is_transient: false,
|
||||||
|
},
|
||||||
|
bds_core::engine::ui_state::PersistedTab {
|
||||||
|
tab_type: "post".into(),
|
||||||
|
id: "missing".into(),
|
||||||
|
title: "Missing".into(),
|
||||||
|
is_transient: false,
|
||||||
|
},
|
||||||
|
bds_core::engine::ui_state::PersistedTab {
|
||||||
|
tab_type: "future_type".into(),
|
||||||
|
id: "future".into(),
|
||||||
|
title: "Future".into(),
|
||||||
|
is_transient: false,
|
||||||
|
},
|
||||||
|
bds_core::engine::ui_state::PersistedTab {
|
||||||
|
tab_type: "settings".into(),
|
||||||
|
id: "settings".into(),
|
||||||
|
title: "Stale title".into(),
|
||||||
|
is_transient: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
active_tab: Some(other_post.id),
|
||||||
|
..bds_core::engine::ui_state::ProjectUiState::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
||||||
|
|
||||||
|
assert_eq!(app.tabs.len(), 1);
|
||||||
|
assert_eq!(app.tabs[0].tab_type, TabType::Settings);
|
||||||
|
assert_eq!(app.tabs[0].title, t(UiLocale::En, "common.settings"));
|
||||||
|
assert!(!app.tabs[0].is_transient);
|
||||||
|
assert_eq!(app.active_tab, None);
|
||||||
|
assert!(app.post_editors.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn menu_editor_persists_pages_new_categories_and_reload_roundtrip() {
|
fn menu_editor_persists_pages_new_categories_and_reload_roundtrip() {
|
||||||
let (db, project, temp) = setup();
|
let (db, project, temp) = setup();
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ impl BdsApp {
|
|||||||
if operation == GitOperation::Commit {
|
if operation == GitOperation::Commit {
|
||||||
self.git_state.commit_message.clear();
|
self.git_state.commit_message.clear();
|
||||||
self.close_git_diff_tabs();
|
self.close_git_diff_tabs();
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
self.notify(
|
self.notify(
|
||||||
ToastLevel::Success,
|
ToastLevel::Success,
|
||||||
@@ -340,7 +341,7 @@ impl BdsApp {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_git_file_diff(&mut self, path: String) -> Task<Message> {
|
pub(super) fn open_git_file_diff(&mut self, path: String) -> Task<Message> {
|
||||||
let tab = crate::views::git::file_tab(&path);
|
let tab = crate::views::git::file_tab(&path);
|
||||||
let tab_id = tab.id.clone();
|
let tab_id = tab.id.clone();
|
||||||
self.activate_git_tab(tab);
|
self.activate_git_tab(tab);
|
||||||
@@ -373,7 +374,7 @@ impl BdsApp {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_git_commit_diff(&mut self, hash: String, subject: String) -> Task<Message> {
|
pub(super) fn open_git_commit_diff(&mut self, hash: String, subject: String) -> Task<Message> {
|
||||||
let tab = crate::views::git::commit_tab(&hash, &subject);
|
let tab = crate::views::git::commit_tab(&hash, &subject);
|
||||||
let tab_id = tab.id.clone();
|
let tab_id = tab.id.clone();
|
||||||
self.activate_git_tab(tab);
|
self.activate_git_tab(tab);
|
||||||
@@ -462,6 +463,7 @@ impl BdsApp {
|
|||||||
self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone());
|
self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone());
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close_git_diff_tabs(&mut self) {
|
fn close_git_diff_tabs(&mut self) {
|
||||||
@@ -559,6 +561,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
|
self.persist_project_ui_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn reset_git_for_project_change(&mut self) {
|
pub(super) fn reset_git_for_project_change(&mut self) {
|
||||||
|
|||||||
@@ -23,6 +23,37 @@ impl fmt::Display for SidebarView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SidebarView {
|
impl SidebarView {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Posts => "posts",
|
||||||
|
Self::Pages => "pages",
|
||||||
|
Self::Media => "media",
|
||||||
|
Self::Scripts => "scripts",
|
||||||
|
Self::Templates => "templates",
|
||||||
|
Self::Tags => "tags",
|
||||||
|
Self::Chat => "chat",
|
||||||
|
Self::Import => "import",
|
||||||
|
Self::Git => "git",
|
||||||
|
Self::Settings => "settings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persisted(value: &str) -> Option<Self> {
|
||||||
|
Some(match value {
|
||||||
|
"posts" => Self::Posts,
|
||||||
|
"pages" => Self::Pages,
|
||||||
|
"media" => Self::Media,
|
||||||
|
"scripts" => Self::Scripts,
|
||||||
|
"templates" => Self::Templates,
|
||||||
|
"tags" => Self::Tags,
|
||||||
|
"chat" => Self::Chat,
|
||||||
|
"import" => Self::Import,
|
||||||
|
"git" => Self::Git,
|
||||||
|
"settings" => Self::Settings,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the `activity.*` i18n key for this sidebar view.
|
/// Returns the `activity.*` i18n key for this sidebar view.
|
||||||
pub fn i18n_key(&self) -> &'static str {
|
pub fn i18n_key(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -51,6 +82,27 @@ pub enum PanelTab {
|
|||||||
GitLog,
|
GitLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PanelTab {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Tasks => "tasks",
|
||||||
|
Self::Output => "output",
|
||||||
|
Self::PostLinks => "post_links",
|
||||||
|
Self::GitLog => "git_log",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persisted(value: &str) -> Option<Self> {
|
||||||
|
Some(match value {
|
||||||
|
"tasks" => Self::Tasks,
|
||||||
|
"output" => Self::Output,
|
||||||
|
"post_links" => Self::PostLinks,
|
||||||
|
"git_log" => Self::GitLog,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A snapshot of a running or completed task shown in the panel.
|
/// A snapshot of a running or completed task shown in the panel.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TaskSnapshot {
|
pub struct TaskSnapshot {
|
||||||
@@ -127,4 +179,32 @@ mod tests {
|
|||||||
assert_eq!(SidebarView::Posts.to_string(), "activity.posts");
|
assert_eq!(SidebarView::Posts.to_string(), "activity.posts");
|
||||||
assert_eq!(SidebarView::Settings.to_string(), "common.settings");
|
assert_eq!(SidebarView::Settings.to_string(), "common.settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_navigation_names_round_trip() {
|
||||||
|
for view in [
|
||||||
|
SidebarView::Posts,
|
||||||
|
SidebarView::Pages,
|
||||||
|
SidebarView::Media,
|
||||||
|
SidebarView::Scripts,
|
||||||
|
SidebarView::Templates,
|
||||||
|
SidebarView::Tags,
|
||||||
|
SidebarView::Chat,
|
||||||
|
SidebarView::Import,
|
||||||
|
SidebarView::Git,
|
||||||
|
SidebarView::Settings,
|
||||||
|
] {
|
||||||
|
assert_eq!(SidebarView::from_persisted(view.as_str()), Some(view));
|
||||||
|
}
|
||||||
|
for tab in [
|
||||||
|
PanelTab::Tasks,
|
||||||
|
PanelTab::Output,
|
||||||
|
PanelTab::PostLinks,
|
||||||
|
PanelTab::GitLog,
|
||||||
|
] {
|
||||||
|
assert_eq!(PanelTab::from_persisted(tab.as_str()), Some(tab));
|
||||||
|
}
|
||||||
|
assert_eq!(SidebarView::from_persisted("unknown"), None);
|
||||||
|
assert_eq!(PanelTab::from_persisted("unknown"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,55 @@ pub enum TabType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TabType {
|
impl TabType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Post => "post",
|
||||||
|
Self::Media => "media",
|
||||||
|
Self::Settings => "settings",
|
||||||
|
Self::Style => "style",
|
||||||
|
Self::Tags => "tags",
|
||||||
|
Self::Chat => "chat",
|
||||||
|
Self::Import => "import",
|
||||||
|
Self::MenuEditor => "menu_editor",
|
||||||
|
Self::MetadataDiff => "metadata_diff",
|
||||||
|
Self::GitDiff => "git_diff",
|
||||||
|
Self::Scripts => "scripts",
|
||||||
|
Self::Templates => "templates",
|
||||||
|
Self::Documentation => "documentation",
|
||||||
|
Self::ApiDocumentation => "api_documentation",
|
||||||
|
Self::CliDocumentation => "cli_documentation",
|
||||||
|
Self::McpDocumentation => "mcp_documentation",
|
||||||
|
Self::SiteValidation => "site_validation",
|
||||||
|
Self::TranslationValidation => "translation_validation",
|
||||||
|
Self::FindDuplicates => "find_duplicates",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persisted(value: &str) -> Option<Self> {
|
||||||
|
Some(match value {
|
||||||
|
"post" => Self::Post,
|
||||||
|
"media" => Self::Media,
|
||||||
|
"settings" => Self::Settings,
|
||||||
|
"style" => Self::Style,
|
||||||
|
"tags" => Self::Tags,
|
||||||
|
"chat" => Self::Chat,
|
||||||
|
"import" => Self::Import,
|
||||||
|
"menu_editor" => Self::MenuEditor,
|
||||||
|
"metadata_diff" => Self::MetadataDiff,
|
||||||
|
"git_diff" => Self::GitDiff,
|
||||||
|
"scripts" => Self::Scripts,
|
||||||
|
"templates" => Self::Templates,
|
||||||
|
"documentation" => Self::Documentation,
|
||||||
|
"api_documentation" => Self::ApiDocumentation,
|
||||||
|
"cli_documentation" => Self::CliDocumentation,
|
||||||
|
"mcp_documentation" => Self::McpDocumentation,
|
||||||
|
"site_validation" => Self::SiteValidation,
|
||||||
|
"translation_validation" => Self::TranslationValidation,
|
||||||
|
"find_duplicates" => Self::FindDuplicates,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Singleton tool tabs per tabs.allium: always one instance, id = type name.
|
/// Singleton tool tabs per tabs.allium: always one instance, id = type name.
|
||||||
///
|
///
|
||||||
/// Singleton types (12): settings, tags, style, menu_editor,
|
/// Singleton types (12): settings, tags, style, menu_editor,
|
||||||
@@ -288,4 +337,32 @@ mod tests {
|
|||||||
open_tab(&mut tabs, make_tab("c2", TabType::Chat, false));
|
open_tab(&mut tabs, make_tab("c2", TabType::Chat, false));
|
||||||
assert_eq!(tabs.len(), 2);
|
assert_eq!(tabs.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_tab_type_names_round_trip() {
|
||||||
|
for tab_type in [
|
||||||
|
TabType::Post,
|
||||||
|
TabType::Media,
|
||||||
|
TabType::Settings,
|
||||||
|
TabType::Style,
|
||||||
|
TabType::Tags,
|
||||||
|
TabType::Chat,
|
||||||
|
TabType::Import,
|
||||||
|
TabType::MenuEditor,
|
||||||
|
TabType::MetadataDiff,
|
||||||
|
TabType::GitDiff,
|
||||||
|
TabType::Scripts,
|
||||||
|
TabType::Templates,
|
||||||
|
TabType::Documentation,
|
||||||
|
TabType::ApiDocumentation,
|
||||||
|
TabType::CliDocumentation,
|
||||||
|
TabType::McpDocumentation,
|
||||||
|
TabType::SiteValidation,
|
||||||
|
TabType::TranslationValidation,
|
||||||
|
TabType::FindDuplicates,
|
||||||
|
] {
|
||||||
|
assert_eq!(TabType::from_persisted(tab_type.as_str()), Some(tab_type));
|
||||||
|
}
|
||||||
|
assert_eq!(TabType::from_persisted("unknown"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ activity-aiAssistant = KI-Assistent
|
|||||||
activity-import = Importieren
|
activity-import = Importieren
|
||||||
activity-sourceControl = Versionskontrolle
|
activity-sourceControl = Versionskontrolle
|
||||||
common-save = Speichern
|
common-save = Speichern
|
||||||
|
common-workspaceState = Arbeitsbereichszustand
|
||||||
common-open = Öffnen
|
common-open = Öffnen
|
||||||
common-cancel = Abbrechen
|
common-cancel = Abbrechen
|
||||||
common-delete = Löschen
|
common-delete = Löschen
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ activity-aiAssistant = AI Assistant
|
|||||||
activity-import = Import
|
activity-import = Import
|
||||||
activity-sourceControl = Source Control
|
activity-sourceControl = Source Control
|
||||||
common-save = Save
|
common-save = Save
|
||||||
|
common-workspaceState = Workspace state
|
||||||
common-open = Open
|
common-open = Open
|
||||||
common-cancel = Cancel
|
common-cancel = Cancel
|
||||||
common-delete = Delete
|
common-delete = Delete
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ activity-aiAssistant = Asistente IA
|
|||||||
activity-import = Importar
|
activity-import = Importar
|
||||||
activity-sourceControl = Control de código fuente
|
activity-sourceControl = Control de código fuente
|
||||||
common-save = Guardar
|
common-save = Guardar
|
||||||
|
common-workspaceState = Estado del espacio de trabajo
|
||||||
common-open = Abrir
|
common-open = Abrir
|
||||||
common-cancel = Cancelar
|
common-cancel = Cancelar
|
||||||
common-delete = Eliminar
|
common-delete = Eliminar
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ activity-aiAssistant = Assistant IA
|
|||||||
activity-import = Importation
|
activity-import = Importation
|
||||||
activity-sourceControl = Contrôle de source
|
activity-sourceControl = Contrôle de source
|
||||||
common-save = Enregistrer
|
common-save = Enregistrer
|
||||||
|
common-workspaceState = État de l’espace de travail
|
||||||
common-open = Ouvrir
|
common-open = Ouvrir
|
||||||
common-cancel = Annuler
|
common-cancel = Annuler
|
||||||
common-delete = Supprimer
|
common-delete = Supprimer
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ activity-aiAssistant = Assistente IA
|
|||||||
activity-import = Importa
|
activity-import = Importa
|
||||||
activity-sourceControl = Controllo sorgente
|
activity-sourceControl = Controllo sorgente
|
||||||
common-save = Salva
|
common-save = Salva
|
||||||
|
common-workspaceState = Stato dell’area di lavoro
|
||||||
common-open = Apri
|
common-open = Apri
|
||||||
common-cancel = Annulla
|
common-cancel = Annulla
|
||||||
common-delete = Elimina
|
common-delete = Elimina
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ value ShellVisibility {
|
|||||||
sidebar_visible: Boolean
|
sidebar_visible: Boolean
|
||||||
panel_visible: Boolean
|
panel_visible: Boolean
|
||||||
assistant_sidebar_visible: Boolean
|
assistant_sidebar_visible: Boolean
|
||||||
|
-- Existing shell fields are stored per project with the tab session;
|
||||||
|
-- see tabs.allium ProjectUiSession. RuDS currently has no assistant sidebar,
|
||||||
|
-- so only the sidebar and bottom-panel fields participate there.
|
||||||
}
|
}
|
||||||
|
|
||||||
surface ShellVisibilitySurface {
|
surface ShellVisibilitySurface {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ entity Project {
|
|||||||
-- the SQLite database, the per-project embeddings index
|
-- the SQLite database, the per-project embeddings index
|
||||||
-- (projects/{id}/embeddings.usearch + .meta.json sidecar), the
|
-- (projects/{id}/embeddings.usearch + .meta.json sidecar), the
|
||||||
-- downloaded embedding-model cache, the project registry, and
|
-- downloaded embedding-model cache, the project registry, and
|
||||||
-- window/UI state.
|
-- window/UI state (project:{id}:ui_state in the SQLite settings table).
|
||||||
-- OS-specific base (resolved via :filename.basedir, app name "bds"):
|
-- OS-specific base (resolved via :filename.basedir, app name "bds"):
|
||||||
-- macOS: ~/Library/Application Support/bds (:user_config)
|
-- macOS: ~/Library/Application Support/bds (:user_config)
|
||||||
-- Linux: $XDG_CONFIG_HOME/bds (default ~/.config/bds)
|
-- Linux: $XDG_CONFIG_HOME/bds (default ~/.config/bds)
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ entity PostMediaLink {
|
|||||||
|
|
||||||
entity Setting {
|
entity Setting {
|
||||||
key: String -- Primary key
|
key: String -- Primary key
|
||||||
|
-- Per-project UI sessions use project:{id}:ui_state.
|
||||||
value: String -- Serialized value
|
value: String -- Serialized value
|
||||||
|
-- UI session values are version-tolerant JSON.
|
||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ surface TabRuntimeSurface {
|
|||||||
provides:
|
provides:
|
||||||
TabOpening(tab_type, intent)
|
TabOpening(tab_type, intent)
|
||||||
ActiveTabChanged(active_tab)
|
ActiveTabChanged(active_tab)
|
||||||
|
ProjectUiStateChanged(project_id)
|
||||||
|
ProjectActivated(project_id)
|
||||||
|
ApplicationClosing(project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ─── Tab types ────────────────────────────────────────────────
|
-- ─── Tab types ────────────────────────────────────────────────
|
||||||
@@ -247,5 +250,74 @@ invariant DirtyIndicator {
|
|||||||
|
|
||||||
-- ─── Tab state persistence ───────────────────────────────────
|
-- ─── Tab state persistence ───────────────────────────────────
|
||||||
|
|
||||||
-- Tab state (list + activeTabId) can be serialized/restored
|
value ProjectUiSession {
|
||||||
-- for session continuity across project switches.
|
project_id: String
|
||||||
|
active_view: String
|
||||||
|
sidebar_visible: Boolean
|
||||||
|
sidebar_width: Integer
|
||||||
|
panel_visible: Boolean
|
||||||
|
panel_tab: String
|
||||||
|
tabs: List<Tab>
|
||||||
|
active_tab_id: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
surface ProjectUiSessionSurface {
|
||||||
|
context session: ProjectUiSession
|
||||||
|
|
||||||
|
exposes:
|
||||||
|
session.project_id
|
||||||
|
session.active_view
|
||||||
|
session.sidebar_visible
|
||||||
|
session.sidebar_width
|
||||||
|
session.panel_visible
|
||||||
|
session.panel_tab
|
||||||
|
session.tabs
|
||||||
|
session.active_tab_id when session.active_tab_id != null
|
||||||
|
}
|
||||||
|
|
||||||
|
rule PersistProjectUiSession {
|
||||||
|
when: ProjectUiStateChanged(project_id)
|
||||||
|
let session = current_project_ui_session(project_id)
|
||||||
|
ensures: DatabaseSettingWritten(
|
||||||
|
key: format("project:{project_id}:ui_state", project_id: project_id),
|
||||||
|
value: serialize_json(session)
|
||||||
|
)
|
||||||
|
-- Persist after every shell/tab mutation, so the database already
|
||||||
|
-- represents the last visible state if the process is closed.
|
||||||
|
}
|
||||||
|
|
||||||
|
rule PersistProjectUiSessionOnClose {
|
||||||
|
when: ApplicationClosing(project_id)
|
||||||
|
let session = current_project_ui_session(project_id)
|
||||||
|
ensures: DatabaseProjectUiSession(project_id) = session
|
||||||
|
}
|
||||||
|
|
||||||
|
rule RestoreProjectUiSession {
|
||||||
|
when: ProjectActivated(project_id)
|
||||||
|
let session = DatabaseProjectUiSession(project_id)
|
||||||
|
if session != null:
|
||||||
|
ensures: active_view = valid_sidebar_view_or_default(session.active_view, posts)
|
||||||
|
ensures: sidebar_visible = session.sidebar_visible
|
||||||
|
ensures: sidebar_width = clamp(session.sidebar_width, 200, 500)
|
||||||
|
ensures: panel_visible = session.panel_visible
|
||||||
|
ensures: panel_tab = valid_panel_tab_or_default(session.panel_tab, tasks)
|
||||||
|
ensures: tabs = existing_project_tabs(session.tabs, project_id)
|
||||||
|
-- Stale, unknown, and cross-project entity references are discarded.
|
||||||
|
ensures: active_tab = valid_restored_active_tab_or_null(session.active_tab_id, tabs)
|
||||||
|
ensures: EditorsHydratedFromCurrentRecords(tabs, project_id)
|
||||||
|
-- Titles and editor content are resolved afresh; unsaved editor bodies
|
||||||
|
-- are never serialized as UI session state.
|
||||||
|
else:
|
||||||
|
ensures: active_view = posts
|
||||||
|
ensures: sidebar_visible = true
|
||||||
|
ensures: sidebar_width = 280
|
||||||
|
ensures: panel_visible = false
|
||||||
|
ensures: panel_tab = tasks
|
||||||
|
ensures: tabs = empty
|
||||||
|
ensures: active_tab = null
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant ProjectUiSessionIsolation {
|
||||||
|
-- Each project has an independent database session. Activating one project
|
||||||
|
-- never retains tabs, editor state, or shell state from another project.
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ entity TuiState {
|
|||||||
config {
|
config {
|
||||||
-- Same preference sections as the settings backends; matches the
|
-- Same preference sections as the settings backends; matches the
|
||||||
-- GUI settings nav minus the GUI-only Style tab.
|
-- GUI settings nav minus the GUI-only Style tab.
|
||||||
tui_settings_sections: List<String> = [
|
tui_settings_sections: Set<String> = {
|
||||||
"project", "editor", "content", "ai",
|
"project", "editor", "content", "ai",
|
||||||
"technology", "publishing", "data", "mcp"
|
"technology", "publishing", "data", "mcp"
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
surface TuiSurface {
|
surface TuiSurface {
|
||||||
|
|||||||
Reference in New Issue
Block a user