use std::path::PathBuf; use std::sync::Arc; use iced::{Element, Subscription, Task}; use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine; use bds_core::i18n::{detect_os_locale, UiLocale}; use bds_core::model::{Post, Project}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; use crate::state::navigation::{ handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot, }; use crate::state::tabs::{self, Tab, TabType}; use crate::views::workspace; // ─────────────────────────────────────────────────────────── // Message // ─────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum Message { // Menu MenuEvent(muda::MenuId), // Navigation SetActiveView(SidebarView), ToggleSidebar, TogglePanel, // Tabs OpenTab(Tab), CloseTab(String), SelectTab(String), PinTab(String), // Project ProjectsLoaded(Vec), SwitchProject(String), ProjectSwitched(Result), RequestCreateProject, CreateProject { name: String, data_path: Option }, ProjectCreated(Result), DeleteProject(String), ProjectDeleted(Result), // Dialogs FolderPicked(Option), MediaFilesPicked(Option>), // Tasks TaskTick, // macOS lifecycle FileOpenRequested(PathBuf), UrlOpenRequested(String), // Panel SetPanelTab(PanelTab), // Settings SetOfflineMode(bool), SetUiLocale(UiLocale), ToggleLocaleDropdown, ToggleProjectDropdown, // Blog actions (dispatched to engine) RebuildDatabase, ReindexText, RegenerateCalendar, ValidateTranslations, GenerateSite, RunMetadataDiff, EngineTaskDone { task_id: TaskId, label: String, result: Result }, Noop, InitMenuBar, } // ─────────────────────────────────────────────────────────── // App State // ─────────────────────────────────────────────────────────── pub struct BdsApp { // Database db: Option, db_path: PathBuf, // Project active_project: Option, projects: Vec, data_dir: Option, // Counts post_count: usize, media_count: usize, // Sidebar data sidebar_posts: Vec, // Navigation sidebar_view: SidebarView, sidebar_visible: bool, // Tabs tabs: Vec, active_tab: Option, // Panel panel_visible: bool, panel_tab: PanelTab, // Tasks task_manager: Arc, task_snapshots: Vec, output_entries: Vec, // Platform _menu_bar: muda::Menu, menu_registry: MenuRegistry, // i18n ui_locale: UiLocale, // Flags offline_mode: bool, locale_dropdown_open: bool, project_dropdown_open: bool, // macOS lifecycle receiver #[cfg(target_os = "macos")] _lifecycle_rx: std::sync::mpsc::Receiver, } // ─────────────────────────────────────────────────────────── // App Implementation // ─────────────────────────────────────────────────────────── impl BdsApp { pub fn new() -> (Self, Task) { let locale = detect_os_locale(); let (menu_bar, registry) = menu::build_menu_bar(locale); // Open or create the database let db_path = dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("bds") .join("bds.db"); // Ensure parent directory exists if let Some(parent) = db_path.parent() { let _ = std::fs::create_dir_all(parent); } let db = Database::open(&db_path).ok(); if let Some(ref db) = db { let _ = db.migrate(); } // Load projects let projects = db .as_ref() .and_then(|d| engine::project::list_projects(d.conn()).ok()) .unwrap_or_default(); let active_project = db .as_ref() .and_then(|d| engine::project::get_active_project(d.conn()).ok()) .flatten(); let data_dir = active_project .as_ref() .and_then(|p| p.data_path.as_ref()) .map(PathBuf::from); // If no projects exist, create a default one let init_task = if projects.is_empty() { if let Some(ref db) = db { let default_data = dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("bds") .join("projects") .join("my-blog"); match engine::project::create_project( db.conn(), "My Blog", Some(default_data.to_str().unwrap_or("my-blog")), ) { Ok(project) => { let _ = engine::project::set_active_project(db.conn(), &project.id); Task::done(Message::ProjectsLoaded(vec![project])) } Err(_) => Task::none(), } } else { Task::none() } } else { Task::done(Message::ProjectsLoaded(projects.clone())) }; // Chain menu initialization after project loading // (must happen after the event loop has started for macOS) let init_task = Task::batch([init_task, Task::done(Message::InitMenuBar)]); registry.set_enabled(MenuAction::Save, false); registry.set_enabled(MenuAction::PublishSelected, false); registry.set_enabled(MenuAction::PreviewPost, false); registry.set_enabled(MenuAction::Find, false); registry.set_enabled(MenuAction::Replace, false); registry.set_enabled(MenuAction::OpenInBrowser, false); #[cfg(target_os = "macos")] let (_lifecycle_tx, _lifecycle_rx) = crate::platform::macos::lifecycle_channel(); ( Self { db, db_path, active_project: active_project.clone(), projects, data_dir, post_count: 0, media_count: 0, sidebar_posts: Vec::new(), sidebar_view: SidebarView::Posts, sidebar_visible: true, tabs: Vec::new(), active_tab: None, panel_visible: false, panel_tab: PanelTab::Tasks, task_manager: Arc::new(TaskManager::default()), task_snapshots: Vec::new(), output_entries: Vec::new(), _menu_bar: menu_bar, menu_registry: registry, ui_locale: locale, offline_mode: false, locale_dropdown_open: false, project_dropdown_open: false, #[cfg(target_os = "macos")] _lifecycle_rx, }, init_task, ) } pub fn update(&mut self, message: Message) -> Task { match message { // ── Menu event dispatch ── Message::MenuEvent(id) => { if let Some(action) = self.menu_registry.lookup(&id) { return self.dispatch_menu_action(action); } Task::none() } // ── Navigation ── Message::SetActiveView(view) => { let (new_view, new_visible) = handle_activity_click(self.sidebar_view, self.sidebar_visible, view); self.sidebar_view = new_view; self.sidebar_visible = new_visible; Task::none() } Message::ToggleSidebar => { self.sidebar_visible = !self.sidebar_visible; Task::none() } Message::TogglePanel => { self.panel_visible = !self.panel_visible; Task::none() } // ── Tabs ── Message::OpenTab(tab) => { let idx = tabs::open_tab(&mut self.tabs, tab); if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); } Task::none() } Message::CloseTab(id) => { if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) { self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone()); } else { self.active_tab = None; } Task::none() } Message::SelectTab(id) => { if self.tabs.iter().any(|t| t.id == id) { self.active_tab = Some(id); } Task::none() } Message::PinTab(id) => { tabs::pin_tab(&mut self.tabs, &id); Task::none() } // ── 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() .flatten(); self.data_dir = self .active_project .as_ref() .and_then(|p| p.data_path.as_ref()) .map(PathBuf::from); } self.refresh_counts(); Task::none() } Message::SwitchProject(project_id) => { self.project_dropdown_open = false; if let Some(ref db) = self.db { match engine::project::set_active_project(db.conn(), &project_id) { Ok(()) => { self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned(); self.data_dir = self .active_project .as_ref() .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)])); } Err(_) => { self.add_output(&t(self.ui_locale, "projectSelector.toast.switchFailed")); } } } 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), } Task::none() } Message::RequestCreateProject => { crate::platform::dialog::pick_folder() } Message::CreateProject { name, data_path } => { if let Some(ref db) = self.db { let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string()); match engine::project::create_project( db.conn(), &name, path_str.as_deref(), ) { Ok(project) => { let _ = engine::project::set_active_project(db.conn(), &project.id); self.projects = engine::project::list_projects(db.conn()).unwrap_or_default(); 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); } Err(_) => { self.add_output(&t(self.ui_locale, "projectSelector.toast.createFailed")); } } } Task::none() } 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), } Task::none() } Message::DeleteProject(project_id) => { if let Some(ref db) = self.db { let data_path = self.projects.iter() .find(|p| p.id == project_id) .and_then(|p| p.data_path.as_ref()) .map(PathBuf::from); match engine::project::delete_project(db.conn(), &project_id, data_path.as_deref()) { Ok(()) => { self.projects.retain(|p| p.id != project_id); } Err(_) => { self.add_output(&t(self.ui_locale, "projectSelector.toast.deleteFailed")); } } } Task::none() } Message::ProjectDeleted(result) => { if let Err(msg) = result { self.add_output(&msg); } Task::none() } // ── Dialogs ── Message::FolderPicked(path) => { if let Some(path) = path { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "New Project".to_string()); return Task::done(Message::CreateProject { name, data_path: Some(path), }); } Task::none() } Message::MediaFilesPicked(_paths) => { // Media import will be expanded in later milestones Task::none() } // ── Tasks ── Message::TaskTick => { self.refresh_task_snapshots(); Task::none() } // ── macOS lifecycle ── Message::FileOpenRequested(_path) => { // File open handling deferred to later milestones Task::none() } Message::UrlOpenRequested(_url) => { // URL open handling deferred to later milestones Task::none() } // ── Panel ── Message::SetPanelTab(tab) => { self.panel_tab = tab; Task::none() } // ── Settings ── Message::SetOfflineMode(mode) => { self.offline_mode = mode; Task::none() } Message::SetUiLocale(locale) => { self.ui_locale = locale; self.locale_dropdown_open = false; menu::update_menu_labels(&self.menu_registry, locale); Task::none() } Message::ToggleLocaleDropdown => { self.locale_dropdown_open = !self.locale_dropdown_open; self.project_dropdown_open = false; Task::none() } Message::ToggleProjectDropdown => { self.project_dropdown_open = !self.project_dropdown_open; self.locale_dropdown_open = false; Task::none() } // ── Blog engine actions (async via TaskManager) ── Message::RebuildDatabase => { self.spawn_engine_task( "engine.rebuildStarted", |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; let on_progress: engine::rebuild::ProgressFn = Arc::new(move |pct, msg| { tm.report_progress(tid, Some(pct), Some(msg.to_string())); }); let report = engine::rebuild::rebuild_from_filesystem_with_progress( db.conn(), &data_dir, &project_id, Some(on_progress), ).map_err(|e| e.to_string())?; let posts = report.posts_created + report.posts_updated; let media = report.media_created + report.media_updated; let templates = report.templates_created + report.templates_updated; let scripts = report.scripts_created + report.scripts_updated; Ok(format!("posts={posts}, media={media}, templates={templates}, scripts={scripts}")) }, ) } Message::ReindexText => { self.spawn_engine_task( "engine.reindexStarted", |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; tm.report_progress(tid, Some(0.0), Some("Reading project config...".into())); let main_lang = engine::meta::read_project_json(&data_dir) .ok() .and_then(|m| m.main_language) .unwrap_or_else(|| "en".to_string()); tm.report_progress(tid, Some(0.10), Some("Reindexing...".into())); let report = engine::search::reindex_all(db.conn(), &project_id, &main_lang) .map_err(|e| e.to_string())?; Ok(format!("posts={}, media={}", report.posts_indexed, report.media_indexed)) }, ) } Message::RegenerateCalendar => { self.spawn_engine_task( "engine.calendarStarted", |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; tm.report_progress(tid, Some(0.10), Some("Generating calendar JSON...".into())); engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) .map_err(|e| e.to_string())?; Ok("done".to_string()) }, ) } Message::ValidateTranslations => { self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation"); self.spawn_engine_task( "engine.validateTranslationsStarted", |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; tm.report_progress(tid, Some(0.10), Some("Checking translations...".into())); let report = engine::validate_translations::validate_translations( db.conn(), &data_dir, &project_id, ).map_err(|e| e.to_string())?; Ok(format!("db_issues={}, fs_issues={}", report.db_issues.len(), report.fs_issues.len())) }, ) } Message::GenerateSite => { self.spawn_engine_task( "engine.generateSiteStarted", |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; tm.report_progress(tid, Some(0.10), Some("Generating calendar...".into())); engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) .map_err(|e| e.to_string())?; Ok("done".to_string()) }, ) } Message::RunMetadataDiff => { self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff"); Task::none() } Message::EngineTaskDone { task_id, label, result } => { match &result { Ok(detail) => { self.task_manager.complete(task_id); self.add_output(&format!("{label}: {detail}")); } Err(err) => { self.task_manager.fail(task_id, err.clone()); self.add_output(&format!("{label} failed: {err}")); } } self.refresh_counts(); self.refresh_task_snapshots(); Task::none() } Message::Noop => Task::none(), Message::InitMenuBar => { #[cfg(target_os = "macos")] menu::init_menu_for_nsapp(&self._menu_bar); Task::none() } } } pub fn view(&self) -> Element<'_, Message> { let active_name = self.active_project.as_ref().map(|p| p.name.as_str()); workspace::view( self.sidebar_view, self.sidebar_visible, &self.tabs, self.active_tab.as_deref(), self.panel_visible, self.panel_tab, &self.task_snapshots, &self.output_entries, &self.sidebar_posts, active_name, &self.projects, self.active_project.as_ref().map(|p| p.id.as_str()), self.post_count, self.media_count, self.offline_mode, self.locale_dropdown_open, self.project_dropdown_open, self.ui_locale, ) } pub fn subscription(&self) -> Subscription { let menu_sub = menu::menu_subscription(); let task_tick = iced::time::every(std::time::Duration::from_millis(500)) .map(|_| Message::TaskTick); Subscription::batch([menu_sub, task_tick]) } // ── Private helpers ── fn dispatch_menu_action(&mut self, action: MenuAction) -> Task { match action { // File MenuAction::NewPost => { if let (Some(db), Some(project), Some(data_dir)) = (&self.db, &self.active_project, &self.data_dir) { let title = t(self.ui_locale, "post.untitled"); match engine::post::create_post( db.conn(), data_dir, &project.id, &title, Some(""), Vec::new(), Vec::new(), None, None, None, ) { Ok(post) => { let tab = Tab { id: post.id.clone(), tab_type: TabType::Post, title: post.title.clone(), is_transient: true, }; let idx = tabs::open_tab(&mut self.tabs, tab); if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); } } Err(e) => { self.add_output(&format!("Failed to create post: {e}")); } } } Task::none() } MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(), MenuAction::Save => Task::none(), // Disabled in M2 MenuAction::OpenInBrowser => Task::none(), MenuAction::OpenDataFolder => { if let Some(ref dir) = self.data_dir { let _ = open::that(dir); } Task::none() } // Edit MenuAction::Find => Task::none(), MenuAction::Replace => Task::none(), MenuAction::EditPreferences => { self.open_singleton_tab(TabType::Settings, "Settings"); Task::none() } // View MenuAction::ViewPosts => { Task::done(Message::SetActiveView(SidebarView::Posts)) } MenuAction::ViewMedia => { Task::done(Message::SetActiveView(SidebarView::Media)) } MenuAction::ToggleSidebar => { Task::done(Message::ToggleSidebar) } MenuAction::TogglePanel => { Task::done(Message::TogglePanel) } // Blog MenuAction::PublishSelected => Task::none(), // Disabled in M2 MenuAction::PreviewPost => Task::none(), // Disabled in M2 MenuAction::EditMenu => { self.open_singleton_tab(TabType::MenuEditor, "Menu Editor"); Task::none() } MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase), MenuAction::ReindexText => Task::done(Message::ReindexText), MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff), MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar), MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations), MenuAction::FillMissingTranslations => { if self.offline_mode { self.add_output(&t(self.ui_locale, "engine.fillMissingTranslationsOffline")); } else { // AI translation not yet available — inform user self.add_output(&t(self.ui_locale, "engine.fillMissingTranslationsNoAi")); } Task::none() } MenuAction::GenerateSitemap => Task::done(Message::GenerateSite), MenuAction::ValidateSite => { self.open_singleton_tab(TabType::SiteValidation, "Site Validation"); Task::none() } MenuAction::UploadSite => { if self.offline_mode { self.add_output(&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() .map(|p| { p.ssh_host.as_ref().map_or(false, |h| !h.is_empty()) && p.ssh_user.as_ref().map_or(false, |u| !u.is_empty()) }) .unwrap_or(false); if !has_creds { self.add_output(&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) } } Task::none() } // Help MenuAction::About => { self.add_output(&t(self.ui_locale, "menu.item.about")); Task::none() } MenuAction::OpenDocumentation => { self.open_singleton_tab(TabType::Documentation, "Documentation"); Task::none() } MenuAction::ViewOnGitHub => { let _ = open::that("https://github.com/nickarumern/bds"); Task::none() } MenuAction::ReportIssue => { let _ = open::that("https://github.com/nickarumern/bds/issues"); Task::none() } } } fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) { let tab = Tab { id: format!("singleton-{title}"), tab_type, title: title.to_string(), is_transient: false, }; let idx = tabs::open_tab(&mut self.tabs, tab); if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); } } fn refresh_task_snapshots(&mut self) { self.task_snapshots = self .task_manager .snapshots() .into_iter() .map(|(id, label, status, progress, message)| { let status_str = match &status { TaskStatus::Queued => "queued".to_string(), TaskStatus::Running => "running".to_string(), TaskStatus::Completed => "completed".to_string(), TaskStatus::Failed(e) => format!("failed: {e}"), TaskStatus::Cancelled => "cancelled".to_string(), }; TaskSnapshot { id, label, status: status_str, progress, message, } }) .collect(); } fn add_output(&mut self, text: &str) { self.output_entries.push(OutputEntry { timestamp: chrono::Utc::now().timestamp(), text: text.to_string(), }); } /// Spawn a blocking engine operation on a background thread via TaskManager. /// /// Returns `Task::none()` if no active project/db/data_dir. /// Otherwise registers the task, logs the start message, and returns an /// async `Task` that opens a fresh DB connection on a worker thread. /// /// The closure receives `(db_path, project_id, data_dir, task_manager, task_id)`. /// Use `task_manager.report_progress(task_id, percent, message)` for live updates. fn spawn_engine_task( &mut self, label_key: &str, work: F, ) -> Task where F: FnOnce(PathBuf, String, PathBuf, Arc, TaskId) -> Result + Send + 'static, { let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { return Task::none(); }; let db_path = self.db_path.clone(); let project_id = project.id.clone(); let data_dir = data_dir.clone(); let label = t(self.ui_locale, label_key); self.add_output(&label); let task_id = self.task_manager.submit(&label); self.refresh_task_snapshots(); let label_for_msg = label.clone(); let tm = Arc::clone(&self.task_manager); Task::perform( async move { tokio::task::spawn_blocking(move || work(db_path, project_id, data_dir, tm, task_id)) .await .unwrap_or_else(|e| Err(format!("task panicked: {e}"))) }, move |result| Message::EngineTaskDone { task_id, label: label_for_msg.clone(), result, }, ) } fn refresh_counts(&mut self) { if let (Some(db), Some(project)) = (&self.db, &self.active_project) { self.post_count = bds_core::db::queries::post::count_posts_by_project( db.conn(), &project.id, ) .unwrap_or(0) as usize; self.media_count = bds_core::db::queries::media::count_media_by_project( db.conn(), &project.id, ) .unwrap_or(0) as usize; self.sidebar_posts = bds_core::db::queries::post::list_posts_by_project( db.conn(), &project.id, ) .unwrap_or_default(); } } }