Restore project workspace state

This commit is contained in:
2026-07-22 19:57:26 +02:00
parent 0753b290ad
commit fdf47da40a
17 changed files with 1004 additions and 142 deletions

View File

@@ -33,6 +33,7 @@ pub mod tag;
pub mod task;
pub mod template;
pub mod template_rebuild;
pub mod ui_state;
pub mod validate_media;
pub mod validate_site;
pub mod validate_translations;

View 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(_))
));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,7 @@ impl BdsApp {
if operation == GitOperation::Commit {
self.git_state.commit_message.clear();
self.close_git_diff_tabs();
self.persist_project_ui_state();
}
self.notify(
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_id = tab.id.clone();
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_id = tab.id.clone();
self.activate_git_tab(tab);
@@ -462,6 +463,7 @@ impl BdsApp {
self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone());
self.enforce_panel_tab_fallback();
self.sync_menu_state();
self.persist_project_ui_state();
}
fn close_git_diff_tabs(&mut self) {
@@ -559,6 +561,7 @@ impl BdsApp {
}
self.enforce_panel_tab_fallback();
self.sync_menu_state();
self.persist_project_ui_state();
}
pub(super) fn reset_git_for_project_change(&mut self) {

View File

@@ -23,6 +23,37 @@ impl fmt::Display for 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.
pub fn i18n_key(&self) -> &'static str {
match self {
@@ -51,6 +82,27 @@ pub enum PanelTab {
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.
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
@@ -127,4 +179,32 @@ mod tests {
assert_eq!(SidebarView::Posts.to_string(), "activity.posts");
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);
}
}

View File

@@ -25,6 +25,55 @@ pub enum 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 types (12): settings, tags, style, menu_editor,
@@ -288,4 +337,32 @@ mod tests {
open_tab(&mut tabs, make_tab("c2", TabType::Chat, false));
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);
}
}