feat: finalizations for M0-M2

This commit is contained in:
2026-04-05 07:41:33 +02:00
parent e46294a022
commit ee61ad56ea
48 changed files with 1296 additions and 498 deletions

View File

@@ -15,6 +15,7 @@ use crate::state::navigation::{
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
};
use crate::state::tabs::{self, Tab, TabType};
use crate::state::toast::{Toast, ToastLevel};
use crate::views::workspace;
// ───────────────────────────────────────────────────────────
@@ -67,6 +68,11 @@ pub enum Message {
ToggleLocaleDropdown,
ToggleProjectDropdown,
// Toast
ShowToast(ToastLevel, String),
DismissToast(u64),
ExpireToasts,
// Blog actions (dispatched to engine)
RebuildDatabase,
ReindexText,
@@ -131,9 +137,14 @@ pub struct BdsApp {
locale_dropdown_open: bool,
project_dropdown_open: bool,
// macOS lifecycle receiver
// Toasts
toasts: Vec<Toast>,
// macOS lifecycle receiver and retained delegate
#[cfg(target_os = "macos")]
_lifecycle_rx: std::sync::mpsc::Receiver<crate::platform::macos::LifecycleEvent>,
#[cfg(target_os = "macos")]
_lifecycle_delegate: Option<objc2::rc::Retained<crate::platform::macos::BdsAppDelegate>>,
}
// ───────────────────────────────────────────────────────────
@@ -156,8 +167,8 @@ impl BdsApp {
let _ = std::fs::create_dir_all(parent);
}
let db = Database::open(&db_path).ok();
if let Some(ref db) = db {
let mut db = Database::open(&db_path).ok();
if let Some(ref mut db) = db {
let _ = db.migrate();
}
@@ -215,6 +226,8 @@ impl BdsApp {
#[cfg(target_os = "macos")]
let (_lifecycle_tx, _lifecycle_rx) = crate::platform::macos::lifecycle_channel();
#[cfg(target_os = "macos")]
let _lifecycle_delegate = crate::platform::macos::install_delegate(_lifecycle_tx);
(
Self {
@@ -242,8 +255,11 @@ impl BdsApp {
offline_mode: false,
locale_dropdown_open: false,
project_dropdown_open: false,
toasts: Vec::new(),
#[cfg(target_os = "macos")]
_lifecycle_rx,
#[cfg(target_os = "macos")]
_lifecycle_delegate,
},
init_task,
)
@@ -282,6 +298,7 @@ impl BdsApp {
if let Some(t) = self.tabs.get(idx) {
self.active_tab = Some(t.id.clone());
}
self.sync_menu_state();
Task::none()
}
Message::CloseTab(id) => {
@@ -290,6 +307,7 @@ impl BdsApp {
} else {
self.active_tab = None;
}
self.sync_menu_state();
Task::none()
}
Message::SelectTab(id) => {
@@ -306,7 +324,6 @@ impl BdsApp {
// ── Project management ──
Message::ProjectsLoaded(projects) => {
self.projects = projects;
// Re-resolve active
if let Some(ref db) = self.db {
self.active_project = engine::project::get_active_project(db.conn())
.ok()
@@ -318,6 +335,7 @@ impl BdsApp {
.map(PathBuf::from);
}
self.refresh_counts();
self.sync_menu_state();
Task::none()
}
Message::SwitchProject(project_id) => {
@@ -332,24 +350,25 @@ impl BdsApp {
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
}
Err(_) => {
self.add_output(&t(self.ui_locale, "projectSelector.toast.switchFailed"));
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.switchFailed"));
}
}
}
self.sync_menu_state();
Task::none()
}
Message::ProjectSwitched(result) => {
match result {
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
Err(msg) => self.add_output(&msg),
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
Err(msg) => self.notify(ToastLevel::Error, &msg),
}
Task::none()
}
Message::RequestCreateProject => {
crate::platform::dialog::pick_folder()
crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder"))
}
Message::CreateProject { name, data_path } => {
if let Some(ref db) = self.db {
@@ -365,10 +384,10 @@ impl BdsApp {
self.active_project = Some(project.clone());
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]);
self.add_output(&msg);
self.notify(ToastLevel::Success, &msg);
}
Err(_) => {
self.add_output(&t(self.ui_locale, "projectSelector.toast.createFailed"));
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.createFailed"));
}
}
}
@@ -376,8 +395,8 @@ impl BdsApp {
}
Message::ProjectCreated(result) => {
match result {
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
Err(msg) => self.add_output(&msg),
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
Err(msg) => self.notify(ToastLevel::Error, &msg),
}
Task::none()
}
@@ -392,7 +411,7 @@ impl BdsApp {
self.projects.retain(|p| p.id != project_id);
}
Err(_) => {
self.add_output(&t(self.ui_locale, "projectSelector.toast.deleteFailed"));
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.deleteFailed"));
}
}
}
@@ -400,7 +419,7 @@ impl BdsApp {
}
Message::ProjectDeleted(result) => {
if let Err(msg) = result {
self.add_output(&msg);
self.notify(ToastLevel::Error, &msg);
}
Task::none()
}
@@ -449,6 +468,7 @@ impl BdsApp {
// ── Settings ──
Message::SetOfflineMode(mode) => {
self.offline_mode = mode;
self.sync_menu_state();
Task::none()
}
Message::SetUiLocale(locale) => {
@@ -564,11 +584,11 @@ impl BdsApp {
match &result {
Ok(detail) => {
self.task_manager.complete(task_id);
self.add_output(&format!("{label}: {detail}"));
self.notify(ToastLevel::Success, &format!("{label}: {detail}"));
}
Err(err) => {
self.task_manager.fail(task_id, err.clone());
self.add_output(&format!("{label} failed: {err}"));
self.notify(ToastLevel::Error, &format!("{label} failed: {err}"));
}
}
self.refresh_counts();
@@ -576,6 +596,20 @@ impl BdsApp {
Task::none()
}
// ── Toast ──
Message::ShowToast(level, msg) => {
self.toasts.push(Toast::new(level, msg));
Task::none()
}
Message::DismissToast(id) => {
self.toasts.retain(|t| t.id != id);
Task::none()
}
Message::ExpireToasts => {
self.toasts.retain(|t| !t.is_expired());
Task::none()
}
Message::Noop => Task::none(),
Message::InitMenuBar => {
#[cfg(target_os = "macos")]
@@ -608,6 +642,7 @@ impl BdsApp {
self.locale_dropdown_open,
self.project_dropdown_open,
self.ui_locale,
&self.toasts,
)
}
@@ -617,7 +652,14 @@ impl BdsApp {
let task_tick = iced::time::every(std::time::Duration::from_millis(500))
.map(|_| Message::TaskTick);
Subscription::batch([menu_sub, task_tick])
let toast_tick = if !self.toasts.is_empty() {
iced::time::every(std::time::Duration::from_millis(250))
.map(|_| Message::ExpireToasts)
} else {
Subscription::none()
};
Subscription::batch([menu_sub, task_tick, toast_tick])
}
// ── Private helpers ──
@@ -661,7 +703,10 @@ impl BdsApp {
}
Task::none()
}
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(),
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(
t(self.ui_locale, "dialog.importMedia"),
t(self.ui_locale, "dialog.imageFilter"),
),
MenuAction::Save => Task::none(), // Disabled in M2
MenuAction::OpenInBrowser => Task::none(),
MenuAction::OpenDataFolder => {
@@ -704,10 +749,9 @@ impl BdsApp {
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
MenuAction::FillMissingTranslations => {
if self.offline_mode {
self.add_output(&t(self.ui_locale, "engine.fillMissingTranslationsOffline"));
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsOffline"));
} else {
// AI translation not yet available — inform user
self.add_output(&t(self.ui_locale, "engine.fillMissingTranslationsNoAi"));
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsNoAi"));
}
Task::none()
}
@@ -718,9 +762,8 @@ impl BdsApp {
}
MenuAction::UploadSite => {
if self.offline_mode {
self.add_output(&t(self.ui_locale, "engine.uploadOffline"));
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadOffline"));
} else if let Some(data_dir) = &self.data_dir {
// Check publishing credentials
let pub_prefs = engine::meta::read_publishing_json(data_dir).ok();
let has_creds = pub_prefs
.as_ref()
@@ -730,10 +773,9 @@ impl BdsApp {
})
.unwrap_or(false);
if !has_creds {
self.add_output(&t(self.ui_locale, "engine.uploadMissingCredentials"));
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadMissingCredentials"));
} else {
self.add_output(&t(self.ui_locale, "engine.uploadStarted"));
// SSH upload requires async SSH library (deferred to publishing milestone)
self.notify(ToastLevel::Info, &t(self.ui_locale, "engine.uploadStarted"));
}
}
Task::none()
@@ -802,6 +844,12 @@ impl BdsApp {
});
}
/// Show a toast notification AND log to output panel.
fn notify(&mut self, level: ToastLevel, text: &str) {
self.toasts.push(Toast::new(level, text.to_string()));
self.add_output(text);
}
/// Spawn a blocking engine operation on a background thread via TaskManager.
///
/// Returns `Task::none()` if no active project/db/data_dir.
@@ -879,4 +927,38 @@ impl BdsApp {
.unwrap_or_default();
}
}
/// Synchronise menu enabled/disabled state with current app state.
///
/// Called after state-changing operations (project switch, tab open/close,
/// offline toggle) so that menu items reflect what's actually possible.
fn sync_menu_state(&self) {
let has_project = self.active_project.is_some();
let has_tab = self.active_tab.is_some();
// File group: need active project for most, need open tab for Save
self.menu_registry.set_enabled(MenuAction::NewPost, has_project);
self.menu_registry.set_enabled(MenuAction::ImportMedia, has_project);
self.menu_registry.set_enabled(MenuAction::Save, has_tab);
self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_tab);
self.menu_registry.set_enabled(MenuAction::OpenDataFolder, has_project);
// Edit: Find/Replace need an open tab
self.menu_registry.set_enabled(MenuAction::Find, has_tab);
self.menu_registry.set_enabled(MenuAction::Replace, has_tab);
// Blog group: need active project
self.menu_registry.set_enabled(MenuAction::PublishSelected, has_project && has_tab);
self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_tab);
self.menu_registry.set_enabled(MenuAction::EditMenu, has_project);
self.menu_registry.set_enabled(MenuAction::RebuildDatabase, has_project);
self.menu_registry.set_enabled(MenuAction::ReindexText, has_project);
self.menu_registry.set_enabled(MenuAction::MetadataDiff, has_project);
self.menu_registry.set_enabled(MenuAction::RegenerateCalendar, has_project);
self.menu_registry.set_enabled(MenuAction::ValidateTranslations, has_project);
self.menu_registry.set_enabled(MenuAction::FillMissingTranslations, has_project && !self.offline_mode);
self.menu_registry.set_enabled(MenuAction::GenerateSitemap, has_project);
self.menu_registry.set_enabled(MenuAction::ValidateSite, has_project);
self.menu_registry.set_enabled(MenuAction::UploadSite, has_project && !self.offline_mode);
}
}

View File

@@ -3,11 +3,11 @@ use iced::Task;
use crate::app::Message;
/// Pick a folder using the native file dialog.
pub fn pick_folder() -> Task<Message> {
pub fn pick_folder(title: String) -> Task<Message> {
Task::perform(
async {
async move {
rfd::AsyncFileDialog::new()
.set_title("Select Folder")
.set_title(&title)
.pick_folder()
.await
.map(|h| h.path().to_path_buf())
@@ -17,13 +17,13 @@ pub fn pick_folder() -> Task<Message> {
}
/// Pick one or more image/media files using the native file dialog.
pub fn pick_media_files() -> Task<Message> {
pub fn pick_media_files(title: String, filter_label: String) -> Task<Message> {
Task::perform(
async {
async move {
rfd::AsyncFileDialog::new()
.set_title("Import Media")
.set_title(&title)
.add_filter(
"Images",
&filter_label,
&["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff", "bmp"],
)
.pick_files()

View File

@@ -1,6 +1,12 @@
use std::path::PathBuf;
use std::sync::mpsc;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{NSApplication, NSApplicationDelegate};
use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
use crate::app::Message;
/// Events that arrive from macOS lifecycle hooks (openFile, openURLs).
@@ -10,11 +16,75 @@ pub enum LifecycleEvent {
UrlOpen(String),
}
/// Create the macOS lifecycle channel (sender goes to objc hooks, receiver polled by subscription).
/// Ivars for the delegate — holds the sender side of the lifecycle channel.
#[derive(Debug)]
pub struct DelegateIvars {
tx: mpsc::Sender<LifecycleEvent>,
}
define_class!(
// SAFETY: NSObject has no subclassing requirements. BdsAppDelegate does not impl Drop.
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "BdsAppDelegate"]
#[ivars = DelegateIvars]
pub struct BdsAppDelegate;
unsafe impl NSObjectProtocol for BdsAppDelegate {}
unsafe impl NSApplicationDelegate for BdsAppDelegate {
#[unsafe(method(applicationDidFinishLaunching:))]
fn did_finish_launching(&self, _notification: &NSNotification) {
// App is already running via Iced; nothing to do here.
}
#[unsafe(method(application:openFile:))]
fn application_open_file(&self, _sender: &NSApplication, filename: &NSString) -> bool {
let path = PathBuf::from(filename.to_string());
let _ = self.ivars().tx.send(LifecycleEvent::FileOpen(path));
true
}
#[unsafe(method(application:openURLs:))]
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
for i in 0..urls.len() {
if let Some(url) = urls.objectAtIndex(i).absoluteString() {
let _ = self.ivars().tx.send(LifecycleEvent::UrlOpen(url.to_string()));
}
}
}
}
);
impl BdsAppDelegate {
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
let this = Self::alloc(mtm);
let this = this.set_ivars(DelegateIvars { tx });
unsafe { msg_send![super(this), init] }
}
}
/// Create the macOS lifecycle channel and wire the native delegate.
///
/// Returns `(Retained<BdsAppDelegate>, Receiver)`. The delegate must be kept alive
/// for the lifetime of the application (drop it and the callbacks stop).
pub fn lifecycle_channel() -> (mpsc::Sender<LifecycleEvent>, mpsc::Receiver<LifecycleEvent>) {
mpsc::channel()
}
/// Install the native Objective-C delegate on NSApplication, wiring it to the given sender.
///
/// Must be called from the main thread after the Iced application is running.
/// Returns the retained delegate (caller must keep it alive).
pub fn install_delegate(tx: mpsc::Sender<LifecycleEvent>) -> Option<Retained<BdsAppDelegate>> {
let mtm = MainThreadMarker::new()?;
let delegate = BdsAppDelegate::new(mtm, tx);
let app = NSApplication::sharedApplication(mtm);
let object = ProtocolObject::from_ref(&*delegate);
app.setDelegate(Some(object));
Some(delegate)
}
/// Poll the macOS lifecycle receiver for the next event and map to a Message.
pub fn poll_lifecycle(
receiver: &mpsc::Receiver<LifecycleEvent>,

View File

@@ -1,5 +1,7 @@
pub mod navigation;
pub mod tabs;
pub mod toast;
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
pub use tabs::{Tab, TabType};
pub use toast::{Toast, ToastLevel};

View File

@@ -0,0 +1,80 @@
/// Toast notification state.
///
/// Toasts are ephemeral, auto-dismissing messages shown at the top of
/// the workspace. Each toast has a severity level, a message, and a
/// monotonically increasing id used for targeted dismissal.
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
/// Severity determines the visual style.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastLevel {
Info,
Success,
Warning,
Error,
}
/// A single toast notification.
#[derive(Debug, Clone)]
pub struct Toast {
pub id: u64,
pub level: ToastLevel,
pub message: String,
/// Unix-millis when this toast was created.
pub created_at: u64,
}
impl Toast {
/// Default display duration in milliseconds.
pub const DEFAULT_DURATION_MS: u64 = 4000;
pub fn new(level: ToastLevel, message: String) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
Self {
id: NEXT_TOAST_ID.fetch_add(1, Ordering::Relaxed),
level,
message,
created_at: now,
}
}
/// Whether this toast has exceeded its display duration.
pub fn is_expired(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
now.saturating_sub(self.created_at) >= Self::DEFAULT_DURATION_MS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toast_ids_are_unique() {
let a = Toast::new(ToastLevel::Info, "a".into());
let b = Toast::new(ToastLevel::Info, "b".into());
assert_ne!(a.id, b.id);
}
#[test]
fn toast_levels() {
let t = Toast::new(ToastLevel::Error, "oops".into());
assert_eq!(t.level, ToastLevel::Error);
assert!(!t.message.is_empty());
}
#[test]
fn fresh_toast_not_expired() {
let t = Toast::new(ToastLevel::Info, "test".into());
assert!(!t.is_expired());
}
}

View File

@@ -5,4 +5,5 @@ pub mod tab_bar;
pub mod status_bar;
pub mod panel;
pub mod project_selector;
pub mod toast;
pub mod welcome;

View File

@@ -0,0 +1,104 @@
use iced::widget::{button, container, row, text, Space};
use iced::widget::text::Shaping;
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
use crate::app::Message;
use crate::state::toast::{Toast, ToastLevel};
/// Background color per toast severity.
fn toast_bg(level: ToastLevel) -> Color {
match level {
ToastLevel::Info => Color::from_rgb(0.16, 0.22, 0.34),
ToastLevel::Success => Color::from_rgb(0.12, 0.30, 0.16),
ToastLevel::Warning => Color::from_rgb(0.38, 0.30, 0.10),
ToastLevel::Error => Color::from_rgb(0.38, 0.14, 0.14),
}
}
/// Border color per toast severity.
fn toast_border(level: ToastLevel) -> Color {
match level {
ToastLevel::Info => Color::from_rgb(0.25, 0.40, 0.65),
ToastLevel::Success => Color::from_rgb(0.20, 0.55, 0.25),
ToastLevel::Warning => Color::from_rgb(0.65, 0.50, 0.15),
ToastLevel::Error => Color::from_rgb(0.65, 0.20, 0.20),
}
}
fn toast_style(level: ToastLevel) -> impl Fn(&Theme) -> container::Style {
move |_theme: &Theme| container::Style {
background: Some(Background::Color(toast_bg(level))),
border: Border {
color: toast_border(level),
width: 1.0,
radius: 4.0.into(),
},
..container::Style::default()
}
}
fn dismiss_btn(_theme: &Theme, status: button::Status) -> button::Style {
let color = match status {
button::Status::Hovered => Color::WHITE,
_ => Color::from_rgb(0.65, 0.65, 0.70),
};
button::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
text_color: color,
border: Border::default(),
..button::Style::default()
}
}
/// Render the toast stack as an overlay element.
///
/// Returns `None` when no toasts are visible.
pub fn view(toasts: &[Toast]) -> Option<Element<'static, Message>> {
if toasts.is_empty() {
return None;
}
let items: Vec<Element<'static, Message>> = toasts
.iter()
.map(|toast| {
let level = toast.level;
let dismiss = button(text("\u{2715}").size(11).shaping(Shaping::Advanced))
.on_press(Message::DismissToast(toast.id))
.padding([2, 4])
.style(dismiss_btn);
container(
row![
text(toast.message.clone())
.size(12)
.shaping(Shaping::Advanced)
.color(Color::WHITE),
Space::with_width(Length::Fill),
dismiss,
]
.spacing(8)
.align_y(Alignment::Center)
.padding([6, 12]),
)
.width(Length::Fixed(420.0))
.style(toast_style(level))
.into()
})
.collect();
Some(
container(
container(
iced::widget::Column::with_children(items)
.spacing(4)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.align_x(Alignment::Center)
.padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0 }),
)
.width(Length::Fill)
.height(Length::Shrink)
.into(),
)
}

View File

@@ -8,7 +8,8 @@ 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::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, welcome};
use crate::state::toast::Toast;
use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
/// Main content area background.
fn content_bg(_theme: &Theme) -> container::Style {
@@ -67,6 +68,8 @@ pub fn view(
project_dropdown_open: bool,
// i18n
locale: UiLocale,
// Toasts
toasts: &[Toast],
) -> Element<'static, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, locale);
@@ -181,12 +184,25 @@ pub fn view(
None
};
// Collect overlays: dropdowns and toasts
let mut overlays: Vec<Element<'static, Message>> = Vec::new();
if let Some(toast_overlay) = toast::view(toasts) {
overlays.push(toast_overlay);
}
if let Some(overlay) = overlay {
stack![base_layout, overlay]
overlays.push(overlay);
}
if overlays.is_empty() {
base_layout
} else {
let mut layers = vec![base_layout];
layers.extend(overlays);
stack(layers)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
base_layout
}
}

View File

@@ -13,6 +13,7 @@ use bds_core::i18n::UiLocale;
use bds_ui::app::Message;
use bds_ui::state::navigation::{PanelTab, SidebarView};
use bds_ui::state::tabs::{Tab, TabType};
use bds_ui::state::toast::ToastLevel;
// ── Smoke: Message enum is well-formed ──
@@ -87,6 +88,11 @@ fn new_message_variants_constructable() {
label: "test".into(),
result: Ok("ok".into()),
};
// Toast
let _show = Message::ShowToast(ToastLevel::Info, "hello".into());
let _dismiss = Message::DismissToast(1);
let _expire = Message::ExpireToasts;
}
// ── Smoke: BdsApp type is accessible from integration tests ──

View File

@@ -0,0 +1,163 @@
//! M2: Native Workspace validation tests.
//!
//! Validates toast system, menu sync logic, dialog i18n,
//! and keyboard shortcut coverage.
use bds_core::i18n::{translate, UiLocale};
use bds_ui::state::toast::{Toast, ToastLevel};
use bds_ui::platform::menu::MenuAction;
// ── Toast system ──
#[test]
fn toast_ids_are_monotonically_increasing() {
let a = Toast::new(ToastLevel::Info, "first".into());
let b = Toast::new(ToastLevel::Warning, "second".into());
let c = Toast::new(ToastLevel::Error, "third".into());
assert!(b.id > a.id);
assert!(c.id > b.id);
}
#[test]
fn toast_level_variants() {
let info = Toast::new(ToastLevel::Info, "info".into());
let success = Toast::new(ToastLevel::Success, "ok".into());
let warning = Toast::new(ToastLevel::Warning, "warn".into());
let error = Toast::new(ToastLevel::Error, "err".into());
assert_eq!(info.level, ToastLevel::Info);
assert_eq!(success.level, ToastLevel::Success);
assert_eq!(warning.level, ToastLevel::Warning);
assert_eq!(error.level, ToastLevel::Error);
}
#[test]
fn fresh_toast_is_not_expired() {
let t = Toast::new(ToastLevel::Info, "test".into());
assert!(!t.is_expired());
}
#[test]
fn toast_preserves_message() {
let t = Toast::new(ToastLevel::Error, "something failed".into());
assert_eq!(t.message, "something failed");
}
// ── Menu enable/disable rules ──
// (These test the expected invariants, not the BdsApp method directly,
// since BdsApp::new() requires main thread for muda.)
#[test]
fn menu_actions_that_need_project() {
let project_actions = [
MenuAction::NewPost,
MenuAction::ImportMedia,
MenuAction::OpenDataFolder,
MenuAction::EditMenu,
MenuAction::RebuildDatabase,
MenuAction::ReindexText,
MenuAction::MetadataDiff,
MenuAction::RegenerateCalendar,
MenuAction::ValidateTranslations,
MenuAction::GenerateSitemap,
MenuAction::ValidateSite,
];
// All should have i18n keys
for action in &project_actions {
let key = action.i18n_key();
let label = translate(UiLocale::En, key);
assert_ne!(label, key, "missing translation for {key}");
}
assert_eq!(project_actions.len(), 11);
}
#[test]
fn menu_actions_that_need_tab() {
let tab_actions = [
MenuAction::Save,
MenuAction::OpenInBrowser,
MenuAction::Find,
MenuAction::Replace,
];
for action in &tab_actions {
let key = action.i18n_key();
let label = translate(UiLocale::En, key);
assert_ne!(label, key, "missing translation for {key}");
}
assert_eq!(tab_actions.len(), 4);
}
#[test]
fn menu_actions_gated_by_offline() {
let online_only = [
MenuAction::FillMissingTranslations,
MenuAction::UploadSite,
];
for action in &online_only {
let key = action.i18n_key();
let label = translate(UiLocale::En, key);
assert_ne!(label, key, "missing translation for {key}");
}
assert_eq!(online_only.len(), 2);
}
// ── Dialog i18n ──
#[test]
fn dialog_keys_exist_in_all_locales() {
let keys = ["dialog.selectFolder", "dialog.importMedia", "dialog.imageFilter"];
for locale in UiLocale::all() {
for key in &keys {
let label = translate(*locale, key);
assert_ne!(label, *key, "missing {key} for locale {locale}");
}
}
}
// ── Keyboard shortcut coverage ──
#[test]
fn accelerator_actions_match_spec() {
// M2 spec: these actions must have keyboard shortcuts
let accelerated = [
MenuAction::NewPost, // Cmd+N
MenuAction::ImportMedia, // Cmd+I
MenuAction::Save, // Cmd+S
MenuAction::Find, // Cmd+F
MenuAction::Replace, // Cmd+H
MenuAction::EditPreferences, // Cmd+,
MenuAction::ViewPosts, // Cmd+1
MenuAction::ViewMedia, // Cmd+2
MenuAction::ToggleSidebar, // Cmd+B
MenuAction::TogglePanel, // Cmd+J
MenuAction::PublishSelected, // Cmd+Shift+P
MenuAction::PreviewPost, // Cmd+Shift+V
MenuAction::GenerateSitemap, // Cmd+R
MenuAction::ValidateSite, // Cmd+Shift+L
MenuAction::UploadSite, // Cmd+Shift+U
];
// All must be valid MenuAction variants with i18n keys
for action in &accelerated {
assert!(!action.i18n_key().is_empty());
}
assert_eq!(accelerated.len(), 15, "M2 spec has 15 accelerator-bound actions");
}
// ── Toast i18n keys ──
#[test]
fn toast_keys_exist_in_all_locales() {
let keys = [
"projectSelector.toast.switched",
"projectSelector.toast.switchFailed",
"projectSelector.toast.created",
"projectSelector.toast.createFailed",
"projectSelector.toast.deleteFailed",
];
for locale in UiLocale::all() {
for key in &keys {
let label = translate(*locale, key);
assert_ne!(label, *key, "missing {key} for locale {locale}");
}
}
}

View File

@@ -8,7 +8,7 @@ use bds_core::engine::project;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
(db, dir)