diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json new file mode 100644 index 0000000..942a464 --- /dev/null +++ b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json @@ -0,0 +1,6 @@ +[ + "article", + "aside", + "page", + "picture" +] \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json new file mode 100644 index 0000000..f156406 --- /dev/null +++ b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json @@ -0,0 +1,6 @@ +{ + "name": "P", + "maxPostsPerPage": 50, + "semanticSimilarityEnabled": false, + "blogLanguages": [] +} \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json new file mode 100644 index 0000000..942a464 --- /dev/null +++ b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json @@ -0,0 +1,6 @@ +[ + "article", + "aside", + "page", + "picture" +] \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json new file mode 100644 index 0000000..f156406 --- /dev/null +++ b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json @@ -0,0 +1,6 @@ +{ + "name": "P", + "maxPostsPerPage": 50, + "semanticSimilarityEnabled": false, + "blogLanguages": [] +} \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json new file mode 100644 index 0000000..942a464 --- /dev/null +++ b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json @@ -0,0 +1,6 @@ +[ + "article", + "aside", + "page", + "picture" +] \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json new file mode 100644 index 0000000..f156406 --- /dev/null +++ b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json @@ -0,0 +1,6 @@ +{ + "name": "P", + "maxPostsPerPage": 50, + "semanticSimilarityEnabled": false, + "blogLanguages": [] +} \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json new file mode 100644 index 0000000..942a464 --- /dev/null +++ b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json @@ -0,0 +1,6 @@ +[ + "article", + "aside", + "page", + "picture" +] \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json new file mode 100644 index 0000000..f156406 --- /dev/null +++ b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json @@ -0,0 +1,6 @@ +{ + "name": "P", + "maxPostsPerPage": 50, + "semanticSimilarityEnabled": false, + "blogLanguages": [] +} \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json new file mode 100644 index 0000000..942a464 --- /dev/null +++ b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json @@ -0,0 +1,6 @@ +[ + "article", + "aside", + "page", + "picture" +] \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json new file mode 100644 index 0000000..f156406 --- /dev/null +++ b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json @@ -0,0 +1,6 @@ +{ + "name": "P", + "maxPostsPerPage": 50, + "semanticSimilarityEnabled": false, + "blogLanguages": [] +} \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 88c18fe..764a2bd 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -136,6 +136,7 @@ pub struct BdsApp { offline_mode: bool, locale_dropdown_open: bool, project_dropdown_open: bool, + theme_badge: String, // Toasts toasts: Vec, @@ -253,6 +254,7 @@ impl BdsApp { offline_mode: false, locale_dropdown_open: false, project_dropdown_open: false, + theme_badge: String::from("pico"), toasts: Vec::new(), #[cfg(target_os = "macos")] _lifecycle_rx, @@ -296,6 +298,7 @@ impl BdsApp { if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); } + self.enforce_panel_tab_fallback(); self.sync_menu_state(); Task::none() } @@ -305,6 +308,7 @@ impl BdsApp { } else { self.active_tab = None; } + self.enforce_panel_tab_fallback(); self.sync_menu_state(); Task::none() } @@ -312,6 +316,7 @@ impl BdsApp { if self.tabs.iter().any(|t| t.id == id) { self.active_tab = Some(id); } + self.enforce_panel_tab_fallback(); Task::none() } Message::PinTab(id) => { @@ -643,6 +648,7 @@ impl BdsApp { self.offline_mode, self.locale_dropdown_open, self.project_dropdown_open, + &self.theme_badge, self.ui_locale, &self.toasts, ) @@ -692,6 +698,7 @@ impl BdsApp { tab_type: TabType::Post, title: post.title.clone(), is_transient: true, + is_dirty: false, }; let idx = tabs::open_tab(&mut self.tabs, tab); if let Some(t) = self.tabs.get(idx) { @@ -804,10 +811,11 @@ impl BdsApp { fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) { let tab = Tab { - id: format!("singleton-{title}"), + id: tab_type.singleton_id().to_string(), tab_type, title: title.to_string(), is_transient: false, + is_dirty: false, }; let idx = tabs::open_tab(&mut self.tabs, tab); if let Some(t) = self.tabs.get(idx) { @@ -927,6 +935,31 @@ impl BdsApp { 0, ) .unwrap_or_default(); + // Read pico theme from project metadata for status bar badge + if let Some(ref data_dir) = self.data_dir { + if let Ok(meta) = engine::meta::read_project_json(data_dir) { + if let Some(theme) = meta.pico_theme { + self.theme_badge = theme; + } + } + } + } + } + + /// Per layout.allium PanelTabFallback invariant: if the active panel tab + /// becomes unavailable (post_links when no post tab active, git_log when + /// neither post nor media tab active), fall back to Tasks. + fn enforce_panel_tab_fallback(&mut self) { + let active_tab_type = self.active_tab.as_ref().and_then(|id| { + self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type) + }); + let is_post = active_tab_type == Some(&TabType::Post); + let is_post_or_media = is_post || active_tab_type == Some(&TabType::Media); + + match self.panel_tab { + PanelTab::PostLinks if !is_post => self.panel_tab = PanelTab::Tasks, + PanelTab::GitLog if !is_post_or_media => self.panel_tab = PanelTab::Tasks, + _ => {} } } diff --git a/crates/bds-ui/src/state/navigation.rs b/crates/bds-ui/src/state/navigation.rs index 146fe9d..42e5a19 100644 --- a/crates/bds-ui/src/state/navigation.rs +++ b/crates/bds-ui/src/state/navigation.rs @@ -32,19 +32,23 @@ impl SidebarView { Self::Scripts => "activity.scripts", Self::Templates => "activity.templates", Self::Tags => "activity.tags", - Self::Chat => "activity.chat", + Self::Chat => "activity.aiAssistant", Self::Import => "activity.import", - Self::Git => "activity.git", - Self::Settings => "activity.settings", + Self::Git => "activity.sourceControl", + Self::Settings => "common.settings", } } } /// Which tab is selected in the bottom panel. +/// +/// Per layout.allium: tasks, output, post_links, git_log. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PanelTab { Tasks, Output, + PostLinks, + GitLog, } /// A snapshot of a running or completed task shown in the panel. @@ -121,6 +125,6 @@ mod tests { #[test] fn display_returns_i18n_key() { assert_eq!(SidebarView::Posts.to_string(), "activity.posts"); - assert_eq!(SidebarView::Settings.to_string(), "activity.settings"); + assert_eq!(SidebarView::Settings.to_string(), "common.settings"); } } diff --git a/crates/bds-ui/src/state/tabs.rs b/crates/bds-ui/src/state/tabs.rs index 9d7a01a..c23c8f0 100644 --- a/crates/bds-ui/src/state/tabs.rs +++ b/crates/bds-ui/src/state/tabs.rs @@ -1,4 +1,6 @@ /// The kind of content a tab holds. +/// +/// 17 tab types per tabs.allium spec. Each maps 1:1 to an editor route. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TabType { Post, @@ -10,18 +12,57 @@ pub enum TabType { Import, MenuEditor, MetadataDiff, + GitDiff, Scripts, Templates, Documentation, + ApiDocumentation, SiteValidation, TranslationValidation, + FindDuplicates, } impl TabType { - /// Singleton tabs may only appear once in the tab bar. - /// Every type except `Post` and `Media` is a singleton. + /// Singleton tool tabs per tabs.allium: always one instance, id = type name. + /// + /// Singleton types (10): settings, tags, style, menu_editor, + /// documentation, api_documentation, metadata_diff, site_validation, + /// translation_validation, find_duplicates. + /// + /// Entity types (keyed by external ID): post, media, scripts (keyed), + /// templates (keyed), chat, import, git_diff. pub fn is_singleton(&self) -> bool { - !matches!(self, Self::Post | Self::Media) + matches!( + self, + Self::Settings + | Self::Style + | Self::Tags + | Self::MenuEditor + | Self::MetadataDiff + | Self::Documentation + | Self::ApiDocumentation + | Self::SiteValidation + | Self::TranslationValidation + | Self::FindDuplicates + ) + } + + /// Return the canonical string ID used for singleton tabs. + pub fn singleton_id(&self) -> &'static str { + match self { + Self::Settings => "settings", + Self::Style => "style", + Self::Tags => "tags", + Self::MenuEditor => "menu_editor", + Self::MetadataDiff => "metadata_diff", + Self::Documentation => "documentation", + Self::ApiDocumentation => "api_documentation", + Self::SiteValidation => "site_validation", + Self::TranslationValidation => "translation_validation", + Self::FindDuplicates => "find_duplicates", + // Entity types don't have a singleton ID — callers should use external IDs + _ => "", + } } } @@ -32,24 +73,44 @@ pub struct Tab { pub tab_type: TabType, pub title: String, pub is_transient: bool, + /// Only post tabs show dirty state (content differs from saved). + pub is_dirty: bool, } /// Open (or focus) a tab in the tab list. /// -/// * **Singleton** — if a tab with the same `TabType` already exists, return -/// its index instead of inserting a duplicate. -/// * **Transient** — replace an existing transient tab of the same type, or -/// append if none exists. -/// * Otherwise — append unconditionally. +/// Per tabs.allium `OpenTab` rule: +/// +/// * **Dedup** — if a tab with same `(type, id)` already exists, activate it. +/// If the intent was `pin`, also set `is_transient = false`. +/// * **Singleton** — if a tab of the same singleton type exists, return its +/// index (singletons have id == type name, so dedup covers this). +/// * **Transient replacement** — if opening as transient and a transient tab +/// of same type exists, replace it with the new tab. +/// * Otherwise — append a new tab. /// /// Returns the index of the resulting tab. pub fn open_tab(tabs: &mut Vec, new_tab: Tab) -> usize { + // Dedup: exact (type, id) match → reuse + if let Some(idx) = tabs + .iter() + .position(|t| t.tab_type == new_tab.tab_type && t.id == new_tab.id) + { + // If intent is pin, upgrade to permanent + if !new_tab.is_transient { + tabs[idx].is_transient = false; + } + return idx; + } + + // Singleton: only one instance allowed (catches id mismatch edge cases) if new_tab.tab_type.is_singleton() { if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) { return idx; } } + // Transient replacement: replace existing transient of same type if new_tab.is_transient { if let Some(idx) = tabs .iter() @@ -96,17 +157,18 @@ mod tests { tab_type, title: id.to_string(), is_transient: transient, + is_dirty: false, } } #[test] fn singleton_dedup() { let mut tabs = Vec::new(); - let idx1 = open_tab(&mut tabs, make_tab("s1", TabType::Settings, false)); - let idx2 = open_tab(&mut tabs, make_tab("s2", TabType::Settings, false)); + let idx1 = open_tab(&mut tabs, make_tab("settings", TabType::Settings, false)); + let idx2 = open_tab(&mut tabs, make_tab("settings", TabType::Settings, false)); assert_eq!(idx1, idx2); assert_eq!(tabs.len(), 1); - assert_eq!(tabs[0].id, "s1"); + assert_eq!(tabs[0].id, "settings"); } #[test] @@ -158,4 +220,47 @@ mod tests { assert_eq!(i1, 1); assert_eq!(tabs.len(), 2); } + + #[test] + fn entity_dedup_by_type_and_id() { + // Re-opening the same (type, id) should not create a new tab. + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("s1", TabType::Scripts, true)); + open_tab(&mut tabs, make_tab("s2", TabType::Scripts, true)); + // s2 replaced s1 (transient replacement) + assert_eq!(tabs.len(), 1); + assert_eq!(tabs[0].id, "s2"); + + // Re-open s2 — dedup should reuse + let idx = open_tab(&mut tabs, make_tab("s2", TabType::Scripts, false)); + assert_eq!(tabs.len(), 1); + assert_eq!(idx, 0); + // Pin intent should upgrade transient to permanent + assert!(!tabs[0].is_transient); + } + + #[test] + fn scripts_not_singleton() { + // Script tabs are entity tabs (keyed by scriptId), not singletons + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("s1", TabType::Scripts, false)); + open_tab(&mut tabs, make_tab("s2", TabType::Scripts, false)); + assert_eq!(tabs.len(), 2); + } + + #[test] + fn templates_not_singleton() { + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("t1", TabType::Templates, false)); + open_tab(&mut tabs, make_tab("t2", TabType::Templates, false)); + assert_eq!(tabs.len(), 2); + } + + #[test] + fn chat_not_singleton() { + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("c1", TabType::Chat, false)); + open_tab(&mut tabs, make_tab("c2", TabType::Chat, false)); + assert_eq!(tabs.len(), 2); + } } diff --git a/crates/bds-ui/src/views/activity_bar.rs b/crates/bds-ui/src/views/activity_bar.rs index f10e32e..293c3ce 100644 --- a/crates/bds-ui/src/views/activity_bar.rs +++ b/crates/bds-ui/src/views/activity_bar.rs @@ -1,9 +1,11 @@ -use iced::widget::{button, column, container, svg, Column, Space}; +use iced::widget::{button, column, container, svg, text, tooltip, Column, Space}; +use iced::widget::text::Shaping; use iced::{Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; use crate::app::Message; +use crate::i18n::t; use crate::state::navigation::SidebarView; // --------------------------------------------------------------------------- @@ -93,11 +95,14 @@ fn bar_background_style(_theme: &Theme) -> container::Style { pub fn view( active_view: SidebarView, - _locale: UiLocale, + sidebar_visible: bool, + locale: UiLocale, ) -> Element<'static, Message> { let make_btn = |view: SidebarView| -> Element<'static, Message> { let handle = svg::Handle::from_memory(icon_svg(view)); - let is_active = view == active_view; + // Per layout.allium ActivityActiveHighlight invariant: + // button shows active iff its view == active_view AND sidebar is visible + let is_active = view == active_view && sidebar_visible; // Render SVG: active = full opacity, inactive = 0.4 opacity (like bDS 60% opacity) let icon = svg(handle) @@ -117,7 +122,7 @@ pub fn view( .style(if is_active { active_button_style } else { inactive_button_style }); // Active indicator: 2px left border (like bDS/VS Code) - if is_active { + let btn_row: Element<'static, Message> = if is_active { let indicator = container(Space::new(0, 0)) .width(Length::Fixed(2.0)) .height(Length::Fixed(48.0)) @@ -129,7 +134,13 @@ pub fn view( } else { let spacer = Space::with_width(2.0); iced::widget::row![spacer, btn].into() - } + }; + + // Wrap in tooltip per layout.allium ActivityButton.label_key + let tip_text = t(locale, view.i18n_key()); + tooltip(btn_row, text(tip_text).size(12).shaping(Shaping::Advanced), tooltip::Position::Right) + .gap(4) + .into() }; let top_items: Vec> = TOP_ACTIVITIES diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index 7fec68d..c46afe7 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -1,4 +1,4 @@ -use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::widget::{button, column, container, scrollable, text, Space}; use iced::widget::text::Shaping; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; @@ -67,10 +67,14 @@ pub fn view( task_snapshots: &[TaskSnapshot], output_entries: &[OutputEntry], locale: UiLocale, + active_tab_is_post: bool, + active_tab_is_post_or_media: bool, ) -> Element<'static, Message> { let muted = Color::from_rgb(0.50, 0.50, 0.55); - // Tab header + // Tab header — per layout.allium: tasks, output, post_links (only when + // active editor tab is a post), git_log (only when active tab is post or + // media). let tasks_btn = button(text(t(locale, "common.tasks")).size(12).shaping(Shaping::Advanced)) .on_press(Message::SetPanelTab(PanelTab::Tasks)) .padding([4, 8]) @@ -86,15 +90,34 @@ pub fn view( .padding([4, 6]) .style(close_btn_style); - let tab_header = row![ - tasks_btn, - output_btn, - Space::with_width(Length::Fill), - close_btn, - ] - .spacing(4) - .align_y(Alignment::Center) - .padding([4, 8]); + let mut tab_row: Vec> = vec![ + tasks_btn.into(), + output_btn.into(), + ]; + + if active_tab_is_post { + let post_links_btn = button(text(t(locale, "panel.postLinks")).size(12).shaping(Shaping::Advanced)) + .on_press(Message::SetPanelTab(PanelTab::PostLinks)) + .padding([4, 8]) + .style(if panel_tab == PanelTab::PostLinks { tab_active } else { tab_inactive }); + tab_row.push(post_links_btn.into()); + } + + if active_tab_is_post_or_media { + let git_log_btn = button(text(t(locale, "panel.gitLog")).size(12).shaping(Shaping::Advanced)) + .on_press(Message::SetPanelTab(PanelTab::GitLog)) + .padding([4, 8]) + .style(if panel_tab == PanelTab::GitLog { tab_active } else { tab_inactive }); + tab_row.push(git_log_btn.into()); + } + + tab_row.push(Space::with_width(Length::Fill).into()); + tab_row.push(close_btn.into()); + + let tab_header = iced::widget::Row::with_children(tab_row) + .spacing(4) + .align_y(Alignment::Center) + .padding([4, 8]); // Tab content let content: Element<'static, Message> = match panel_tab { @@ -156,6 +179,18 @@ pub fn view( .into() } } + PanelTab::PostLinks => { + // Post Links content populated in M3 (editor integration) + container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) + .padding(8) + .into() + } + PanelTab::GitLog => { + // Git Log content populated in extension bucket A (git integration) + container(text(t(locale, "panel.gitLogPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) + .padding(8) + .into() + } }; container( diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 88588ff..4c39a0d 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -91,6 +91,7 @@ pub fn view( tab_type: TabType::Post, title: p.title.clone(), is_transient: true, + is_dirty: false, })) .padding([3, 6]) .width(Length::Fill) @@ -123,6 +124,7 @@ pub fn view( tab_type: TabType::Media, title: display_name.to_string(), is_transient: true, + is_dirty: false, })) .padding([3, 6]) .width(Length::Fill) @@ -153,7 +155,7 @@ pub fn view( .padding(12); container(scrollable(content)) - .width(Length::Fixed(240.0)) + .width(Length::Fixed(280.0)) .height(Length::Fill) .style(sidebar_style) .into() diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs index b7e9ee3..f31a2b5 100644 --- a/crates/bds-ui/src/views/status_bar.rs +++ b/crates/bds-ui/src/views/status_bar.rs @@ -117,6 +117,8 @@ pub fn view( locale: UiLocale, offline_mode: bool, task_snapshots: &[TaskSnapshot], + theme_badge: &str, + active_post_status: Option<&str>, ) -> Element<'static, Message> { let label_color = Color::from_rgb(0.60, 0.60, 0.65); @@ -157,6 +159,19 @@ pub fn view( // ── Right side ── + // Post status indicator dot (per layout.allium StatusBarRight.post_status) + let post_status_el: Element<'static, Message> = if let Some(status) = active_post_status { + let (dot, color) = match status { + "draft" => ("\u{25CB}", Color::from_rgb(0.60, 0.60, 0.65)), // hollow circle + "published" => ("\u{25CF}", Color::from_rgb(0.30, 0.75, 0.40)), // green filled + "archived" => ("\u{25A0}", Color::from_rgb(0.60, 0.60, 0.65)), // square + _ => ("\u{25CF}", Color::from_rgb(0.60, 0.60, 0.65)), + }; + text(dot).size(11).shaping(Shaping::Advanced).color(color).into() + } else { + Space::with_width(0).into() + }; + // Post + media counts let posts_label = tw(locale, "statusBar.posts", &[("count", &post_count.to_string())]); let media_label = tw(locale, "statusBar.media", &[("count", &media_count.to_string())]); @@ -180,8 +195,10 @@ pub fn view( .style(dropdown_trigger); let right = row![ + post_status_el, text(posts_label).size(11).shaping(Shaping::Advanced).color(label_color), text(media_label).size(11).shaping(Shaping::Advanced).color(label_color), + text(theme_badge.to_string()).size(11).shaping(Shaping::Advanced).color(label_color), airplane_btn, locale_trigger, text("bDS").size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.45, 0.45, 0.50)), diff --git a/crates/bds-ui/src/views/tab_bar.rs b/crates/bds-ui/src/views/tab_bar.rs index c57b8bb..fde3cab 100644 --- a/crates/bds-ui/src/views/tab_bar.rs +++ b/crates/bds-ui/src/views/tab_bar.rs @@ -1,6 +1,6 @@ use iced::widget::{button, container, row, text, Space}; use iced::widget::text::Shaping; -use iced::{Background, Border, Color, Element, Length, Theme}; +use iced::{Background, Border, Color, Element, Font, Length, Theme}; use crate::app::Message; use crate::state::tabs::Tab; @@ -68,28 +68,41 @@ pub fn view( tabs: &[Tab], active_tab: Option<&str>, ) -> Element<'static, Message> { + // Per tabs.allium: "Hidden when no tabs exist." if tabs.is_empty() { - return container(Space::new(0, 0)) - .width(Length::Fill) - .height(Length::Fixed(35.0)) - .style(bar_style) - .into(); + return Space::with_height(0).into(); } let tab_buttons: Vec> = tabs .iter() .map(|tab| { let is_active = active_tab == Some(tab.id.as_str()); - let title_text = if tab.is_transient { - format!("{} \u{25CB}", tab.title) // hollow circle for transient - } else { - tab.title.clone() - }; let tab_id = tab.id.clone(); let close_id = tab.id.clone(); + // Per tabs.allium: transient tabs show italic title + let title_label = if tab.is_transient { + text(tab.title.clone()) + .size(12) + .shaping(Shaping::Advanced) + .font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT }) + } else { + text(tab.title.clone()) + .size(12) + .shaping(Shaping::Advanced) + }; + + // Per tabs.allium DirtyIndicator: dot for dirty post tabs + let dirty_indicator = if tab.is_dirty { + text(" \u{25CF}").size(10).shaping(Shaping::Advanced) + .color(Color::from_rgb(0.90, 0.70, 0.30)) + } else { + text("").size(10) + }; + let label = row![ - text(title_text).size(12).shaping(Shaping::Advanced), + title_label, + dirty_indicator, button(text("\u{2715}").size(10).shaping(Shaping::Advanced)) .on_press(Message::CloseTab(close_id)) .padding(2) diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 0a3707b..45ab911 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -7,7 +7,7 @@ use bds_core::model::{Media, Post, Project}; use crate::app::Message; use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; -use crate::state::tabs::Tab; +use crate::state::tabs::{Tab, TabType}; use crate::state::toast::Toast; use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome}; @@ -66,13 +66,14 @@ pub fn view( offline_mode: bool, locale_dropdown_open: bool, project_dropdown_open: bool, + theme_badge: &str, // i18n locale: UiLocale, // Toasts toasts: &[Toast], ) -> Element<'static, Message> { // Activity bar (leftmost column) - let activity = activity_bar::view(sidebar_view, locale); + let activity = activity_bar::view(sidebar_view, sidebar_visible, locale); // Tab bar let tabs_el = tab_bar::view(tabs, active_tab); @@ -83,8 +84,20 @@ pub fn view( // Right column: tab bar + content + panel let mut right_col = column![tabs_el, content_area]; if panel_visible { + // Determine active tab type for panel tab availability (per layout.allium PanelTabAvailability) + let active_tab_type = active_tab.and_then(|id| tabs.iter().find(|t| t.id == id)).map(|t| &t.tab_type); + let active_tab_is_post = active_tab_type == Some(&TabType::Post); + let active_tab_is_post_or_media = active_tab_is_post || active_tab_type == Some(&TabType::Media); + right_col = right_col.push(separator_h()); - right_col = right_col.push(panel::view(panel_tab, task_snapshots, output_entries, locale)); + right_col = right_col.push(panel::view( + panel_tab, + task_snapshots, + output_entries, + locale, + active_tab_is_post, + active_tab_is_post_or_media, + )); } let right = container(right_col.width(Length::Fill).height(Length::Fill)) .width(Length::Fill) @@ -102,7 +115,19 @@ pub fn view( main_row = main_row.push(right); let main_row = main_row.height(Length::Fill); - // Status bar at bottom + // Status bar at bottom — determine active post status for status dot + let active_post_status: Option = active_tab.and_then(|id| { + tabs.iter().find(|t| t.id == id && t.tab_type == TabType::Post) + }).and_then(|tab| { + sidebar_posts.iter().find(|p| p.id == tab.id).map(|p| { + match p.status { + bds_core::model::PostStatus::Draft => "draft".to_string(), + bds_core::model::PostStatus::Published => "published".to_string(), + bds_core::model::PostStatus::Archived => "archived".to_string(), + } + }) + }); + let status = status_bar::view( active_project_name, post_count, @@ -110,6 +135,8 @@ pub fn view( locale, offline_mode, task_snapshots, + theme_badge, + active_post_status.as_deref(), ); let base_layout: Element<'static, Message> = column![main_row, separator_h(), status] diff --git a/crates/bds-ui/tests/app_smoke.rs b/crates/bds-ui/tests/app_smoke.rs index 4af54ef..68acf0a 100644 --- a/crates/bds-ui/tests/app_smoke.rs +++ b/crates/bds-ui/tests/app_smoke.rs @@ -44,6 +44,7 @@ fn new_message_variants_constructable() { tab_type: TabType::Post, title: "Test".to_string(), is_transient: false, + is_dirty: false, }; let _open = Message::OpenTab(tab); let _close = Message::CloseTab("test".into()); diff --git a/locales/ui/de.json b/locales/ui/de.json index 174ddb1..a4a413e 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -38,10 +38,9 @@ "activity.scripts": "Skripte", "activity.tags": "Schlagwörter", "activity.templates": "Vorlagen", - "activity.chat": "KI-Assistent", + "activity.aiAssistant": "KI-Assistent", "activity.import": "Importieren", - "activity.git": "Versionskontrolle", - "activity.settings": "Einstellungen", + "activity.sourceControl": "Versionskontrolle", "common.save": "Speichern", "common.cancel": "Abbrechen", "common.settings": "Einstellungen", @@ -58,6 +57,10 @@ "app.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen", "app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen", "panel.output": "Ausgabe", + "panel.postLinks": "Beitragsverknüpfungen", + "panel.gitLog": "Git-Verlauf", + "panel.postLinksPlaceholder": "Keine Beitragsverknüpfungen vorhanden", + "panel.gitLogPlaceholder": "Kein Git-Verlauf vorhanden", "panel.closeTitle": "Panel schließen", "panel.noRecentTasks": "Keine aktuellen Aufgaben", "panel.noOutput": "Keine Ausgabe", @@ -83,6 +86,8 @@ "projectSelector.toast.deleteFailed": "Projekt konnte nicht gelöscht werden", "tabBar.closeHint": "Schließen", "tabBar.loading": "Laden...", + "tabBar.unknown": "Unbekannt", + "tabBar.style": "Stil", "sidebar.noPostsYet": "Noch keine Beiträge", "sidebar.noPagesYet": "Noch keine Seiten", "sidebar.noMediaYet": "Noch keine Medien", diff --git a/locales/ui/en.json b/locales/ui/en.json index 97a203f..90e9601 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -38,10 +38,9 @@ "activity.scripts": "Scripts", "activity.tags": "Tags", "activity.templates": "Templates", - "activity.chat": "AI Assistant", + "activity.aiAssistant": "AI Assistant", "activity.import": "Import", - "activity.git": "Source Control", - "activity.settings": "Settings", + "activity.sourceControl": "Source Control", "common.save": "Save", "common.cancel": "Cancel", "common.settings": "Settings", @@ -58,6 +57,10 @@ "app.databaseRebuildFailed": "Database rebuild failed", "app.textReindexFailed": "Text reindex failed", "panel.output": "Output", + "panel.postLinks": "Post Links", + "panel.gitLog": "Git Log", + "panel.postLinksPlaceholder": "No post links to display", + "panel.gitLogPlaceholder": "No git history to display", "panel.closeTitle": "Close panel", "panel.noRecentTasks": "No recent tasks", "panel.noOutput": "No output", @@ -83,6 +86,8 @@ "projectSelector.toast.deleteFailed": "Failed to delete project", "tabBar.closeHint": "Close", "tabBar.loading": "Loading...", + "tabBar.unknown": "Unknown", + "tabBar.style": "Style", "sidebar.noPostsYet": "No posts yet", "sidebar.noPagesYet": "No pages yet", "sidebar.noMediaYet": "No media yet", diff --git a/locales/ui/es.json b/locales/ui/es.json index 2545ec0..e585a4d 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -38,10 +38,9 @@ "activity.scripts": "Scripts", "activity.tags": "Etiquetas", "activity.templates": "Plantillas", - "activity.chat": "Asistente IA", + "activity.aiAssistant": "Asistente IA", "activity.import": "Importar", - "activity.git": "Control de código fuente", - "activity.settings": "Configuración", + "activity.sourceControl": "Control de código fuente", "common.save": "Guardar", "common.cancel": "Cancelar", "common.settings": "Configuración", @@ -58,6 +57,10 @@ "app.databaseRebuildFailed": "La reconstrucción de la base de datos falló", "app.textReindexFailed": "La reindexación de texto falló", "panel.output": "Salida", + "panel.postLinks": "Enlaces de entradas", + "panel.gitLog": "Historial de Git", + "panel.postLinksPlaceholder": "No hay enlaces de entradas para mostrar", + "panel.gitLogPlaceholder": "No hay historial de Git para mostrar", "panel.closeTitle": "Cerrar panel", "panel.noRecentTasks": "No hay tareas recientes", "panel.noOutput": "Sin salida", @@ -83,6 +86,8 @@ "projectSelector.toast.deleteFailed": "No se pudo eliminar el proyecto", "tabBar.closeHint": "Cerrar", "tabBar.loading": "Cargando...", + "tabBar.unknown": "Desconocido", + "tabBar.style": "Estilo", "sidebar.noPostsYet": "Aún no hay entradas", "sidebar.noPagesYet": "Aún no hay páginas", "sidebar.noMediaYet": "Aún no hay medios", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 1412f6d..40845b6 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -38,10 +38,9 @@ "activity.scripts": "Scripts", "activity.tags": "Étiquettes", "activity.templates": "Modèles", - "activity.chat": "Assistant IA", + "activity.aiAssistant": "Assistant IA", "activity.import": "Importation", - "activity.git": "Contrôle de source", - "activity.settings": "Paramètres", + "activity.sourceControl": "Contrôle de source", "common.save": "Enregistrer", "common.cancel": "Annuler", "common.settings": "Paramètres", @@ -58,6 +57,10 @@ "app.databaseRebuildFailed": "Échec de la reconstruction de la base de données", "app.textReindexFailed": "Échec de la réindexation du texte", "panel.output": "Sortie", + "panel.postLinks": "Liens d'articles", + "panel.gitLog": "Historique Git", + "panel.postLinksPlaceholder": "Aucun lien d'article à afficher", + "panel.gitLogPlaceholder": "Aucun historique Git à afficher", "panel.closeTitle": "Fermer le panneau", "panel.noRecentTasks": "Aucune tâche récente", "panel.noOutput": "Aucune sortie", @@ -83,6 +86,8 @@ "projectSelector.toast.deleteFailed": "Impossible de supprimer le projet", "tabBar.closeHint": "Fermer", "tabBar.loading": "Chargement...", + "tabBar.unknown": "Inconnu", + "tabBar.style": "Style", "sidebar.noPostsYet": "Aucun article pour le moment", "sidebar.noPagesYet": "Aucune page pour le moment", "sidebar.noMediaYet": "Aucun média pour le moment", diff --git a/locales/ui/it.json b/locales/ui/it.json index f5141ac..08f9501 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -38,10 +38,9 @@ "activity.scripts": "Script", "activity.tags": "Tag", "activity.templates": "Modelli", - "activity.chat": "Assistente IA", + "activity.aiAssistant": "Assistente IA", "activity.import": "Importa", - "activity.git": "Controllo sorgente", - "activity.settings": "Impostazioni", + "activity.sourceControl": "Controllo sorgente", "common.save": "Salva", "common.cancel": "Annulla", "common.settings": "Impostazioni", @@ -58,6 +57,10 @@ "app.databaseRebuildFailed": "Ricostruzione database non riuscita", "app.textReindexFailed": "Reindicizzazione testo non riuscita", "panel.output": "Uscita", + "panel.postLinks": "Link ai post", + "panel.gitLog": "Cronologia Git", + "panel.postLinksPlaceholder": "Nessun link ai post da visualizzare", + "panel.gitLogPlaceholder": "Nessuna cronologia Git da visualizzare", "panel.closeTitle": "Chiudi pannello", "panel.noRecentTasks": "Nessuna attività recente", "panel.noOutput": "Nessun output", @@ -83,6 +86,8 @@ "projectSelector.toast.deleteFailed": "Eliminazione progetto non riuscita", "tabBar.closeHint": "Chiudi", "tabBar.loading": "Caricamento...", + "tabBar.unknown": "Sconosciuto", + "tabBar.style": "Stile", "sidebar.noPostsYet": "Nessun post", "sidebar.noPagesYet": "Nessuna pagina", "sidebar.noMediaYet": "Nessun media",