fix: more gaps closed

This commit is contained in:
2026-04-08 21:06:06 +02:00
parent 6d377c0d8c
commit b8b51c794a
4 changed files with 603 additions and 202 deletions

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use iced::{Element, Subscription, Task};
@@ -154,6 +154,350 @@ pub enum Message {
InitMenuBar,
}
enum PersistedPostState {
Canonical(Post),
Translation(bds_core::model::PostTranslation),
}
fn persist_post_editor_state_impl(
db: &Database,
data_dir: &Path,
state: &PostEditorState,
) -> Result<PersistedPostState, String> {
if state.active_language != state.canonical_language {
let translation = engine::post::upsert_translation(
db.conn(),
data_dir,
&state.post_id,
&state.active_language,
&state.title,
if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) },
Some(&state.content),
)
.map_err(|e| e.to_string())?;
Ok(PersistedPostState::Translation(translation))
} else {
let post = engine::post::update_post(
db.conn(),
data_dir,
&state.post_id,
Some(&state.title),
Some(&state.slug),
Some(if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) }),
Some(&state.content),
Some(state.tags.clone()),
Some(state.categories.clone()),
Some(if state.author.is_empty() { None } else { Some(state.author.as_str()) }),
Some(if state.language.is_empty() { None } else { Some(state.language.as_str()) }),
Some(if state.template_slug.is_empty() { None } else { Some(state.template_slug.as_str()) }),
Some(state.do_not_translate),
)
.map_err(|e| e.to_string())?;
Ok(PersistedPostState::Canonical(post))
}
}
enum PersistedMediaState {
Canonical { media: Media, tags: Vec<String> },
Translation,
}
fn persist_media_editor_state_impl(
db: &Database,
data_dir: &Path,
state: &MediaEditorState,
) -> Result<PersistedMediaState, String> {
if state.active_language != state.canonical_language {
engine::media::upsert_media_translation(
db.conn(),
data_dir,
&state.media_id,
&state.active_language,
if state.title.is_empty() { None } else { Some(state.title.as_str()) },
if state.alt.is_empty() { None } else { Some(state.alt.as_str()) },
if state.caption.is_empty() { None } else { Some(state.caption.as_str()) },
)
.map_err(|e| e.to_string())?;
Ok(PersistedMediaState::Translation)
} else {
let tags = state
.tags_input
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(|tag| tag.to_string())
.collect::<Vec<_>>();
let media = engine::media::update_media(
db.conn(),
data_dir,
&state.media_id,
Some(if state.title.is_empty() { None } else { Some(state.title.as_str()) }),
Some(if state.alt.is_empty() { None } else { Some(state.alt.as_str()) }),
Some(if state.caption.is_empty() { None } else { Some(state.caption.as_str()) }),
Some(if state.author.is_empty() { None } else { Some(state.author.as_str()) }),
Some(if state.language.is_empty() { None } else { Some(state.language.as_str()) }),
Some(tags.clone()),
)
.map_err(|e| e.to_string())?;
Ok(PersistedMediaState::Canonical { media, tags })
}
}
fn save_template_editor_state_impl(
db: &Database,
project_id: &str,
state: &TemplateEditorState,
) -> Result<Template, String> {
engine::template::update_template(
db.conn(),
&state.template_id,
project_id,
Some(&state.title),
Some(&state.slug),
Some(state.kind.clone()),
Some(state.enabled),
Some(&state.content),
)
.map_err(|e| e.to_string())
}
fn save_script_editor_state_impl(
db: &Database,
project_id: &str,
state: &ScriptEditorState,
) -> Result<Script, String> {
engine::script::update_script(
db.conn(),
&state.script_id,
project_id,
Some(&state.title),
Some(&state.slug),
Some(state.kind.clone()),
Some(&state.entrypoint),
Some(state.enabled),
Some(&state.content),
)
.map_err(|e| e.to_string())
}
fn save_editor_settings_state_impl(
db: &Database,
state: &SettingsViewState,
) -> Result<(), String> {
let now = bds_core::util::now_unix_ms();
[
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.default_mode", &state.default_mode, now),
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.diff_view_style", &state.diff_view_style, now),
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { "true" } else { "false" }, now),
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { "true" } else { "false" }, now),
]
.into_iter()
.collect::<Result<Vec<_>, _>>()
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::{persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, PersistedMediaState, PersistedPostState};
use crate::views::media_editor::MediaEditorState;
use crate::views::post_editor::PostEditorState;
use crate::views::script_editor::ScriptEditorState;
use crate::views::settings_view::SettingsViewState;
use crate::views::template_editor::TemplateEditorState;
use bds_core::db::fts::ensure_fts_tables;
use bds_core::db::queries::project::insert_project;
use bds_core::db::Database;
use bds_core::engine::{media, post, script, template};
use bds_core::model::{Project, ScriptKind, TemplateKind};
use tempfile::TempDir;
fn make_project() -> Project {
Project {
id: "p1".to_string(),
name: "Test Project".to_string(),
slug: "test-project".to_string(),
description: Some("desc".to_string()),
data_path: None,
is_active: true,
created_at: 1000,
updated_at: 1000,
}
}
fn tiny_png_bytes() -> &'static [u8] {
&[
0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, b'I', b'H', b'D', b'R',
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0D, b'I', b'D', b'A',
b'T', 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0xF0,
0x1F, 0x00, 0x05, 0x00, 0x01, 0xFF, 0x89, 0x99,
0x3D, 0x1D, 0x00, 0x00, 0x00, 0x00, b'I', b'E',
b'N', b'D', 0xAE, 0x42, 0x60, 0x82,
]
}
fn setup() -> (Database, Project, TempDir) {
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
let project = make_project();
insert_project(db.conn(), &project).unwrap();
let tempdir = TempDir::new().unwrap();
std::fs::create_dir_all(tempdir.path().join("meta")).unwrap();
std::fs::write(tempdir.path().join("meta/project.json"), "{}\n").unwrap();
std::fs::write(tempdir.path().join("meta/publishing.json"), "{}\n").unwrap();
(db, project, tempdir)
}
#[test]
fn post_editor_save_flow_persists_changes() {
let (db, project, tmp) = setup();
let created = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Original",
Some("Body"),
vec!["rust".to_string()],
vec!["article".to_string()],
Some("Alice"),
Some("en"),
None,
).unwrap();
let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string(), "de".to_string()], &[], Vec::new(), Vec::new(), Vec::new());
editor.title = "Updated Post".to_string();
editor.content = "Updated body".to_string();
editor.tags = vec!["rust".to_string(), "lua".to_string()];
let result = persist_post_editor_state_impl(&db, tmp.path(), &editor).unwrap();
match result {
PersistedPostState::Canonical(post) => assert_eq!(post.title, "Updated Post"),
PersistedPostState::Translation(_) => panic!("expected canonical post save"),
}
let saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
assert_eq!(saved.title, "Updated Post");
assert_eq!(saved.content.as_deref(), Some("Updated body"));
assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]);
}
#[test]
fn media_editor_save_flow_persists_changes() {
let (db, project, tmp) = setup();
let source = tmp.path().join("tiny.png");
std::fs::write(&source, tiny_png_bytes()).unwrap();
let imported = media::import_media(
db.conn(),
tmp.path(),
&project.id,
&source,
"tiny.png",
Some("Tiny"),
Some("Alt"),
None,
None,
Some("en"),
vec!["photo".to_string()],
).unwrap();
let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap();
let mut editor = MediaEditorState::from_media(&media_record, &["en".to_string()], &[]);
editor.title = "Tiny Updated".to_string();
editor.tags_input = "photo, lua".to_string();
let result = persist_media_editor_state_impl(&db, tmp.path(), &editor).unwrap();
match result {
PersistedMediaState::Canonical { media, tags } => {
assert_eq!(media.title.as_deref(), Some("Tiny Updated"));
assert_eq!(tags, vec!["photo".to_string(), "lua".to_string()]);
}
PersistedMediaState::Translation => panic!("expected canonical media save"),
}
let saved = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap();
assert_eq!(saved.title.as_deref(), Some("Tiny Updated"));
assert_eq!(saved.tags, vec!["photo".to_string(), "lua".to_string()]);
assert!(tmp.path().join(saved.sidecar_path).exists());
}
#[test]
fn template_editor_save_flow_persists_changes() {
let (db, project, _tmp) = setup();
let created = template::create_template(
db.conn(),
&project.id,
"Post Template",
TemplateKind::Post,
"<article>{{ title }}</article>",
).unwrap();
let template_record = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
let mut editor = TemplateEditorState::from_template(&template_record);
editor.title = "Updated Template".to_string();
editor.content = "<main>{{ title }}</main>".to_string();
let saved_template = save_template_editor_state_impl(&db, &project.id, &editor).unwrap();
assert_eq!(saved_template.title, "Updated Template");
let saved = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
assert_eq!(saved.title, "Updated Template");
assert_eq!(saved.content.as_deref(), Some("<main>{{ title }}</main>"));
}
#[test]
fn script_editor_save_flow_persists_changes() {
let (db, project, _tmp) = setup();
let created = script::create_script(
db.conn(),
&project.id,
"Utility Script",
ScriptKind::Utility,
"function main()\n return 'ok'\nend",
Some("main"),
).unwrap();
let script_record = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
let mut editor = ScriptEditorState::from_script(&script_record);
editor.title = "Updated Script".to_string();
editor.content = "function main()\n return 'lua'\nend".to_string();
editor.entrypoint = "main".to_string();
let saved_script = save_script_editor_state_impl(&db, &project.id, &editor).unwrap();
assert_eq!(saved_script.title, "Updated Script");
let saved = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
assert_eq!(saved.title, "Updated Script");
assert_eq!(saved.content.as_deref(), Some("function main()\n return 'lua'\nend"));
}
#[test]
fn settings_editor_save_flow_persists_values() {
let (db, _project, _tmp) = setup();
let settings = SettingsViewState {
default_mode: "markdown".to_string(),
diff_view_style: "side-by-side".to_string(),
wrap_long_lines: false,
hide_unchanged_regions: true,
..SettingsViewState::default()
};
save_editor_settings_state_impl(&db, &settings).unwrap();
let wrap = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.wrap_long_lines").unwrap();
let hide = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.hide_unchanged_regions").unwrap();
let diff = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.diff_view_style").unwrap();
assert_eq!(wrap.value, "false");
assert_eq!(hide.value, "true");
assert_eq!(diff.value, "side-by-side");
}
}
// ───────────────────────────────────────────────────────────
// App State
// ───────────────────────────────────────────────────────────
@@ -2469,16 +2813,7 @@ impl BdsApp {
let Some(ref db) = self.db else { return Task::none() };
let Some(ref project) = self.active_project else { return Task::none() };
match engine::template::update_template(
db.conn(),
&state.template_id,
&project.id,
Some(&state.title),
Some(&state.slug),
Some(state.kind.clone()),
Some(state.enabled),
Some(&state.content),
) {
match save_template_editor_state_impl(db, &project.id, state) {
Ok(tmpl) => {
let s = self.template_editors.get_mut(template_id).unwrap();
s.is_dirty = false;
@@ -2501,17 +2836,7 @@ impl BdsApp {
let Some(ref db) = self.db else { return Task::none() };
let Some(ref project) = self.active_project else { return Task::none() };
match engine::script::update_script(
db.conn(),
&state.script_id,
&project.id,
Some(&state.title),
Some(&state.slug),
Some(state.kind.clone()),
Some(&state.entrypoint),
Some(state.enabled),
Some(&state.content),
) {
match save_script_editor_state_impl(db, &project.id, state) {
Ok(script) => {
let s = self.script_editors.get_mut(script_id).unwrap();
s.is_dirty = false;
@@ -2541,88 +2866,43 @@ impl BdsApp {
.as_ref()
.ok_or_else(|| "project data directory unavailable".to_string())?;
if state.active_language != state.canonical_language {
let translation = engine::post::upsert_translation(
db.conn(),
data_dir,
&state.post_id,
&state.active_language,
&state.title,
if state.excerpt.is_empty() {
None
} else {
Some(state.excerpt.as_str())
},
Some(&state.content),
)
.map_err(|e| e.to_string())?;
if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.is_dirty = false;
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
draft.title = translation.title.clone();
draft.excerpt = translation.excerpt.clone().unwrap_or_default();
draft.content = translation.content.clone().unwrap_or_default();
draft.status = translation.status.clone();
draft.is_dirty = false;
match persist_post_editor_state_impl(db, data_dir, &state)? {
PersistedPostState::Translation(translation) => {
if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.is_dirty = false;
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
draft.title = translation.title.clone();
draft.excerpt = translation.excerpt.clone().unwrap_or_default();
draft.content = translation.content.clone().unwrap_or_default();
draft.status = translation.status.clone();
draft.is_dirty = false;
}
}
}
} else {
let post = engine::post::update_post(
db.conn(),
data_dir,
&state.post_id,
Some(&state.title),
Some(&state.slug),
Some(if state.excerpt.is_empty() {
None
} else {
Some(state.excerpt.as_str())
}),
Some(&state.content),
Some(state.tags.clone()),
Some(state.categories.clone()),
Some(if state.author.is_empty() {
None
} else {
Some(state.author.as_str())
}),
Some(if state.language.is_empty() {
None
} else {
Some(state.language.as_str())
}),
Some(if state.template_slug.is_empty() {
None
} else {
Some(state.template_slug.as_str())
}),
Some(state.do_not_translate),
)
.map_err(|e| e.to_string())?;
if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.title = post.title.clone();
editor.slug = post.slug.clone();
editor.excerpt = post.excerpt.clone().unwrap_or_default();
editor.author = post.author.clone().unwrap_or_default();
editor.language = post.language.clone().unwrap_or_default();
editor.template_slug = post.template_slug.clone().unwrap_or_default();
editor.do_not_translate = post.do_not_translate;
editor.tags = post.tags.clone();
editor.categories = post.categories.clone();
editor.status = post.status.clone();
editor.updated_at = post.updated_at;
editor.published_at = post.published_at;
editor.is_dirty = false;
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) {
tab.is_dirty = false;
if !post.title.is_empty() {
tab.title = post.title.clone();
PersistedPostState::Canonical(post) => {
if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.title = post.title.clone();
editor.slug = post.slug.clone();
editor.excerpt = post.excerpt.clone().unwrap_or_default();
editor.author = post.author.clone().unwrap_or_default();
editor.language = post.language.clone().unwrap_or_default();
editor.template_slug = post.template_slug.clone().unwrap_or_default();
editor.do_not_translate = post.do_not_translate;
editor.tags = post.tags.clone();
editor.categories = post.categories.clone();
editor.status = post.status.clone();
editor.updated_at = post.updated_at;
editor.published_at = post.published_at;
editor.is_dirty = false;
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) {
tab.is_dirty = false;
if !post.title.is_empty() {
tab.title = post.title.clone();
}
}
self.refresh_post_relationships(post_id);
}
self.refresh_post_relationships(post_id);
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
@@ -2643,83 +2923,24 @@ impl BdsApp {
.as_ref()
.ok_or_else(|| "project data directory unavailable".to_string())?;
if state.active_language != state.canonical_language {
engine::media::upsert_media_translation(
db.conn(),
data_dir,
&state.media_id,
&state.active_language,
if state.title.is_empty() {
None
} else {
Some(state.title.as_str())
},
if state.alt.is_empty() {
None
} else {
Some(state.alt.as_str())
},
if state.caption.is_empty() {
None
} else {
Some(state.caption.as_str())
},
)
.map_err(|e| e.to_string())?;
if let Some(editor) = self.media_editors.get_mut(media_id) {
editor.is_dirty = false;
match persist_media_editor_state_impl(db, data_dir, &state)? {
PersistedMediaState::Translation => {
if let Some(editor) = self.media_editors.get_mut(media_id) {
editor.is_dirty = false;
}
}
} else {
let tags = state
.tags_input
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(|tag| tag.to_string())
.collect::<Vec<_>>();
let media = engine::media::update_media(
db.conn(),
data_dir,
&state.media_id,
Some(if state.title.is_empty() {
None
} else {
Some(state.title.as_str())
}),
Some(if state.alt.is_empty() {
None
} else {
Some(state.alt.as_str())
}),
Some(if state.caption.is_empty() {
None
} else {
Some(state.caption.as_str())
}),
Some(if state.author.is_empty() {
None
} else {
Some(state.author.as_str())
}),
Some(if state.language.is_empty() {
None
} else {
Some(state.language.as_str())
}),
Some(tags.clone()),
)
.map_err(|e| e.to_string())?;
if let Some(editor) = self.media_editors.get_mut(media_id) {
editor.title = media.title.clone().unwrap_or_default();
editor.alt = media.alt.clone().unwrap_or_default();
editor.caption = media.caption.clone().unwrap_or_default();
editor.author = media.author.clone().unwrap_or_default();
editor.language = media.language.clone().unwrap_or_default();
editor.tags = tags;
editor.tags_input = editor.tags.join(", ");
editor.updated_at = media.updated_at;
editor.is_dirty = false;
PersistedMediaState::Canonical { media, tags } => {
if let Some(editor) = self.media_editors.get_mut(media_id) {
editor.title = media.title.clone().unwrap_or_default();
editor.alt = media.alt.clone().unwrap_or_default();
editor.caption = media.caption.clone().unwrap_or_default();
editor.author = media.author.clone().unwrap_or_default();
editor.language = media.language.clone().unwrap_or_default();
editor.tags = tags;
editor.tags_input = editor.tags.join(", ");
editor.updated_at = media.updated_at;
editor.is_dirty = false;
}
}
}
@@ -3155,16 +3376,7 @@ impl BdsApp {
SettingsMsg::HideUnchangedRegionsChanged(b) => { state.hide_unchanged_regions = b; }
SettingsMsg::SaveEditor => {
if let Some(db) = &self.db {
let now = bds_core::util::now_unix_ms();
let result = [
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.default_mode", &state.default_mode, now),
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.diff_view_style", &state.diff_view_style, now),
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { "true" } else { "false" }, now),
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { "true" } else { "false" }, now),
]
.into_iter()
.collect::<Result<Vec<_>, _>>();
match result {
match save_editor_settings_state_impl(db, state) {
Ok(_) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
}

View File

@@ -35,3 +35,35 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
})
.into()
}
pub fn tab_placeholder(
title: impl Into<String>,
subtitle: Option<String>,
) -> Element<'static, Message> {
let title = title.into();
let subtitle = subtitle.unwrap_or_else(|| "This tab is routed correctly, but its content surface is not implemented yet.".to_string());
container(
column![
text(title)
.size(24)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.85, 0.85, 0.90)),
text(subtitle)
.size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.55, 0.55, 0.60)),
]
.spacing(12)
.align_x(iced::Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
..container::Style::default()
})
.into()
}

View File

@@ -343,17 +343,23 @@ fn route_content_area<'a>(
script_editors: &'a HashMap<String, ScriptEditorState>,
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
_dashboard_state: Option<&'a DashboardState>,
dashboard_state: Option<&'a DashboardState>,
) -> Element<'a, Message> {
let Some(tab_id) = active_tab else {
return welcome::view(locale);
};
let Some(tab) = tabs.iter().find(|t| t.id == tab_id) else {
return welcome::view(locale);
};
match tab.tab_type {
TabType::Post => {
match route_kind(
tabs,
active_tab,
post_editors,
media_editors,
template_editors,
script_editors,
tags_view_state,
settings_state,
dashboard_state,
) {
ContentRoute::Dashboard(state) => crate::views::dashboard::view(state, locale),
ContentRoute::Welcome => welcome::view(locale),
ContentRoute::Loading => loading_view(locale),
ContentRoute::Post(tab_id) => {
if let Some(state) = post_editors.get(tab_id) {
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
post_editor::view(state, locale, wrap)
@@ -361,42 +367,187 @@ fn route_content_area<'a>(
loading_view(locale)
}
}
TabType::Media => {
ContentRoute::Media(tab_id) => {
if let Some(state) = media_editors.get(tab_id) {
media_editor::view(state, locale, data_dir)
} else {
loading_view(locale)
}
}
TabType::Templates => {
ContentRoute::Templates(tab_id) => {
if let Some(state) = template_editors.get(tab_id) {
template_editor::view(state, locale)
} else {
loading_view(locale)
}
}
TabType::Scripts => {
ContentRoute::Scripts(tab_id) => {
if let Some(state) = script_editors.get(tab_id) {
script_editor::view(state, locale)
} else {
loading_view(locale)
}
}
TabType::Tags => {
ContentRoute::Tags => {
if let Some(state) = tags_view_state {
tags_view::view(state, locale)
} else {
loading_view(locale)
}
}
TabType::Settings => {
ContentRoute::Settings => {
if let Some(state) = settings_state {
settings_view::view(state, locale)
} else {
loading_view(locale)
}
}
_ => welcome::view(locale),
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
}
}
#[derive(Debug, Clone, Copy)]
enum ContentRoute<'a> {
Dashboard(&'a DashboardState),
Welcome,
Loading,
Post(&'a str),
Media(&'a str),
Templates(&'a str),
Scripts(&'a str),
Tags,
Settings,
Placeholder(&'a str),
}
fn route_kind<'a>(
tabs: &'a [Tab],
active_tab: Option<&'a str>,
post_editors: &'a HashMap<String, PostEditorState>,
media_editors: &'a HashMap<String, MediaEditorState>,
template_editors: &'a HashMap<String, TemplateEditorState>,
script_editors: &'a HashMap<String, ScriptEditorState>,
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
) -> ContentRoute<'a> {
let Some(tab_id) = active_tab else {
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
};
let Some(tab) = tabs.iter().find(|t| t.id == tab_id) else {
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
};
match tab.tab_type {
TabType::Post => {
if post_editors.contains_key(tab_id) { ContentRoute::Post(tab_id) } else { ContentRoute::Loading }
}
TabType::Media => {
if media_editors.contains_key(tab_id) { ContentRoute::Media(tab_id) } else { ContentRoute::Loading }
}
TabType::Templates => {
if template_editors.contains_key(tab_id) { ContentRoute::Templates(tab_id) } else { ContentRoute::Loading }
}
TabType::Scripts => {
if script_editors.contains_key(tab_id) { ContentRoute::Scripts(tab_id) } else { ContentRoute::Loading }
}
TabType::Tags => {
if tags_view_state.is_some() { ContentRoute::Tags } else { ContentRoute::Loading }
}
TabType::Settings => {
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
}
TabType::Style
| TabType::Chat
| TabType::Import
| TabType::MenuEditor
| TabType::MetadataDiff
| TabType::GitDiff
| TabType::Documentation
| TabType::ApiDocumentation
| TabType::SiteValidation
| TabType::TranslationValidation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::tabs::{Tab, TabType};
fn tab(id: &str, tab_type: TabType, title: &str) -> Tab {
Tab {
id: id.to_string(),
tab_type,
title: title.to_string(),
is_transient: false,
is_dirty: false,
}
}
#[test]
fn unsupported_tool_tabs_do_not_fall_back_to_welcome_route() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let unsupported = [
TabType::Style,
TabType::Chat,
TabType::Import,
TabType::MenuEditor,
TabType::MetadataDiff,
TabType::GitDiff,
TabType::Documentation,
TabType::ApiDocumentation,
TabType::SiteValidation,
TabType::TranslationValidation,
TabType::FindDuplicates,
];
for tab_type in unsupported {
let tabs = vec![tab("tool", tab_type.clone(), "Tool")];
let route = route_kind(
&tabs,
Some("tool"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
None,
None,
None,
);
match route {
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
_ => panic!("expected placeholder route for {tab_type:?}"),
}
}
}
#[test]
fn dashboard_is_used_when_available_and_no_tab_is_active() {
let dashboard = DashboardState::new("Test Project".to_string());
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let route = route_kind(
&[],
None,
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
None,
None,
Some(&dashboard),
);
match route {
ContentRoute::Dashboard(state) => assert_eq!(state.project_name, "Test Project"),
_ => panic!("expected dashboard route"),
}
}
}

View File

@@ -12,6 +12,12 @@ use "./i18n.allium" as i18n
value SettingsView {
search_query: String? -- filters sections by keyword match
active_sections: List<String> -- visible sections after search filter
project_section: SettingsProjectSection?
editor_section: SettingsEditorSection?
categories: List<SettingsCategoryRow>
ai_section: SettingsAISection?
publishing_section: SettingsPublishingSection?
mcp_section: SettingsMCPSection?
}
value SettingsProjectSection {