use std::collections::{HashMap, HashSet}; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::Duration; use anyhow::{Result, anyhow, bail}; use bds_core::db::Database; use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind}; use bds_core::engine::{self, domain_events}; use bds_core::i18n::{UiLocale, normalize_language}; use bds_core::model::metadata::ProjectMetadata; use bds_core::model::{ DomainEntity, Media, NotificationAction, Post, PostStatus, Project, PublishingPreferences, ScriptKind, ScriptStatus, SshMode, Tag, TemplateKind, TemplateStatus, }; use bds_editor::EditorBuffer; use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::Size; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; use ratatui_image::picker::Picker; use ratatui_image::protocol::Protocol; use ratatui_image::{Image as TerminalImage, Resize}; use crate::host::ApplicationHost; const SIDEBAR_WIDTH: u16 = 30; const MAX_DIFF_BYTES: usize = 512 * 1024; const COLORS: &[&str] = &["", "#7aa2f7", "#9ece6a", "#e0af68", "#bb9af7", "#f7768e"]; #[derive(Clone, Copy)] struct CommandSpec { id: &'static str, key: &'static str, } const COMMANDS: &[CommandSpec] = &[ CommandSpec { id: "metadata-diff", key: "tui.commandMetadataDiff", }, CommandSpec { id: "validate-site", key: "tui.commandValidateSite", }, CommandSpec { id: "force-render", key: "tui.commandForceRender", }, CommandSpec { id: "rebuild-database", key: "tui.commandRebuildDatabase", }, CommandSpec { id: "reindex-search", key: "tui.commandReindexSearch", }, CommandSpec { id: "validate-translations-gui", key: "tui.commandValidateTranslations", }, CommandSpec { id: "find-duplicates-gui", key: "tui.commandFindDuplicates", }, CommandSpec { id: "upload-site", key: "tui.commandUploadSite", }, CommandSpec { id: "browser-preview-url", key: "tui.commandBrowserPreviewUrl", }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum TuiView { Posts, Media, Templates, Scripts, Tags, Settings, Git, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Sidebar, Editor, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditorMode { Source, Preview, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TuiKey { Char(char), Enter, Esc, Backspace, Delete, Up, Down, Left, Right, Home, End, PageUp, PageDown, Tab, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct TuiModifiers { pub ctrl: bool, pub shift: bool, pub alt: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TuiInput { pub key: TuiKey, pub modifiers: TuiModifiers, } impl TuiInput { pub const fn plain(key: TuiKey) -> Self { Self { key, modifiers: TuiModifiers { ctrl: false, shift: false, alt: false, }, } } pub const fn ctrl(key: TuiKey) -> Self { Self { key, modifiers: TuiModifiers { ctrl: true, shift: false, alt: false, }, } } } #[derive(Debug, Clone, PartialEq, Eq)] enum SidebarItem { Header(String), Post(String, String), Media(String, String), Template(String, String), Script(String, String), TagSection(TagSection), SettingSection(SettingSection), GitFile(String, String), GitCommit(String, String), Empty(String), } impl SidebarItem { fn selectable(&self) -> bool { !matches!(self, Self::Header(_) | Self::Empty(_)) } fn label(&self, locale: UiLocale) -> String { match self { Self::Header(value) | Self::Empty(value) => value.clone(), Self::Post(_, value) | Self::Media(_, value) | Self::Template(_, value) | Self::Script(_, value) | Self::GitFile(_, value) | Self::GitCommit(_, value) => value.clone(), Self::TagSection(value) => bds_core::i18n::translate(locale, value.key()), Self::SettingSection(value) => bds_core::i18n::translate(locale, value.key()), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TagSection { Cloud, Manage, Merge, } impl TagSection { const ALL: [Self; 3] = [Self::Cloud, Self::Manage, Self::Merge]; const fn key(self) -> &'static str { match self { Self::Cloud => "tags.nav.cloud", Self::Manage => "tags.nav.manage", Self::Merge => "tags.nav.merge", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SettingSection { Project, Editor, Content, Ai, Technology, Publishing, Data, Mcp, } impl SettingSection { const ALL: [Self; 8] = [ Self::Project, Self::Editor, Self::Content, Self::Ai, Self::Technology, Self::Publishing, Self::Data, Self::Mcp, ]; const fn key(self) -> &'static str { match self { Self::Project => "settings.nav.project", Self::Editor => "settings.nav.editor", Self::Content => "settings.nav.content", Self::Ai => "settings.nav.ai", Self::Technology => "settings.nav.technology", Self::Publishing => "settings.nav.publishing", Self::Data => "settings.nav.data", Self::Mcp => "settings.nav.mcp", } } } #[derive(Debug, Clone, PartialEq, Eq)] enum FieldKind { Text, Bool, Enum(&'static [&'static str]), ReadOnly, } #[derive(Debug, Clone, PartialEq, Eq)] struct SettingField { label: String, key: &'static str, kind: FieldKind, value: String, } #[derive(Debug, Clone, PartialEq, Eq)] enum EditorEntity { Post(String), Template(String), Script(String), } struct Editor { entity: EditorEntity, title: String, syntax: &'static str, post_language: Option, buffer: EditorBuffer, mode: EditorMode, } #[derive(Debug, Clone, PartialEq, Eq)] enum PromptKind { Search, Command, ProjectPath, Commit, Setting(usize), TagCreate, TagRename(String), ConfirmDeleteTag(String), EditorTitle, } #[derive(Debug, Clone, PartialEq, Eq)] struct Prompt { kind: PromptKind, value: String, candidates: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] enum Panel { Welcome, MediaPreview { title: String, path: PathBuf, }, Settings(SettingSection), Tags(TagSection), Git, Report { title: String, body: String, action: ReportAction, }, Help, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ReportAction { MetadataDiff, SiteValidation, } #[derive(Debug, Clone, PartialEq, Eq)] enum Overlay { Projects { selected: usize }, ConfirmDiscard, } struct BackgroundResult { status: String, panel: Option, reload: bool, } pub struct TuiApp { host: ApplicationHost, remote: bool, locale: UiLocale, project: Option, data_dir: Option, pub view: TuiView, pub focus: Focus, pub selected_index: usize, items: Vec, editor: Option, panel: Panel, overlay: Option, prompt: Option, filters: HashMap, status: String, output: Vec, settings_fields: Vec, panel_index: usize, marked_tags: HashSet, scroll: u16, quit: bool, events: domain_events::EventSubscription, started_task_ids: HashSet, completed_task_ids: HashSet, airplane: bool, background_tx: mpsc::Sender, background_rx: mpsc::Receiver, image_source: Option, image_protocol: Option, terminal_size: (u16, u16), } impl TuiApp { pub fn new(host: ApplicationHost, remote: bool) -> Result { let db = host.database()?; let locale = engine::settings::ui_language(db.conn())? .map(|value| normalize_language(&value)) .unwrap_or_else(bds_core::i18n::detect_os_locale); let project = engine::project::get_active_project(db.conn())?; let data_dir = project .as_ref() .map(|project| host.project_data_dir(project)); let started_task_ids = host .tasks() .snapshots() .into_iter() .map(|task| task.id) .collect(); let (background_tx, background_rx) = mpsc::channel(); let mut app = Self { host, remote, locale, project, data_dir, view: TuiView::Posts, focus: Focus::Sidebar, selected_index: 0, items: Vec::new(), editor: None, panel: Panel::Welcome, overlay: None, prompt: None, filters: HashMap::new(), status: String::new(), output: Vec::new(), settings_fields: Vec::new(), panel_index: 0, marked_tags: HashSet::new(), scroll: 0, quit: false, events: domain_events::subscribe(), started_task_ids, completed_task_ids: HashSet::new(), airplane: std::env::var_os("BDS_AIRPLANE").is_some(), background_tx, background_rx, image_source: None, image_protocol: None, terminal_size: (80, 24), }; app.reload()?; Ok(app) } pub fn should_quit(&self) -> bool { self.quit } pub fn locale(&self) -> UiLocale { self.locale } pub fn editor_text(&self) -> Option { self.editor.as_ref().map(|editor| editor.buffer.text()) } pub fn has_unsaved_changes(&self) -> bool { self.editor .as_ref() .is_some_and(|editor| editor.buffer.is_dirty()) } pub fn resize(&mut self, width: u16, height: u16) { let size = (width.max(20), height.max(6)); if size != self.terminal_size { self.terminal_size = size; self.rebuild_image_protocol(); } } fn rebuild_image_protocol(&mut self) { let Some(source) = self.image_source.clone() else { self.image_protocol = None; return; }; let size = Size::new( self.terminal_size .0 .saturating_sub(SIDEBAR_WIDTH + 4) .max(4), self.terminal_size.1.saturating_sub(6).max(2), ); self.image_protocol = Picker::halfblocks() .new_protocol(source, size, Resize::Fit(None)) .ok(); } fn database(&self) -> Result { self.host.database() } fn project_id(&self) -> Result<&str> { self.project .as_ref() .map(|value| value.id.as_str()) .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject"))) } fn data_dir(&self) -> Result<&Path> { self.data_dir .as_deref() .ok_or_else(|| anyhow!(self.tr("tui.noActiveProjectDataFolder"))) } pub fn poll(&mut self) -> Result<()> { self.poll_tasks(); while let Ok(result) = self.background_rx.try_recv() { self.status = result.status; if let Some(panel) = result.panel { self.panel = panel; self.focus = Focus::Editor; } if result.reload { self.reload()?; } } for event in self.events.drain() { if let bds_core::model::DomainEvent::SettingsChanged { key, .. } = &event && key == engine::settings::UI_LANGUAGE_KEY { let db = self.database()?; self.locale = engine::settings::ui_language(db.conn())? .map(|v| normalize_language(&v)) .unwrap_or(UiLocale::En); self.reload()?; if let Panel::Settings(section) = self.panel { self.settings_fields = self.load_setting_fields(section)?; self.panel_index = self .panel_index .min(self.settings_fields.len().saturating_sub(1)); } if self .prompt .as_ref() .is_some_and(|prompt| prompt.kind == PromptKind::Command) { let query = self .prompt .as_ref() .map(|prompt| prompt.value.clone()) .unwrap_or_default(); let candidates = self .command_names() .into_iter() .filter(|name| contains_ci(name, &query)) .collect(); if let Some(prompt) = &mut self.prompt { prompt.candidates = candidates; } } self.refresh_open_report()?; } if let bds_core::model::DomainEvent::EntityChanged { project_id, entity, entity_id, action, } = &event && self .project .as_ref() .is_some_and(|project| &project.id == project_id) { let editing_deleted = *action == NotificationAction::Deleted && match (&self.editor, entity) { ( Some(Editor { entity: EditorEntity::Post(id), .. }), DomainEntity::Post, ) | ( Some(Editor { entity: EditorEntity::Template(id), .. }), DomainEntity::Template, ) | ( Some(Editor { entity: EditorEntity::Script(id), .. }), DomainEntity::Script, ) => id == entity_id, _ => false, }; if editing_deleted { self.editor = None; self.focus = Focus::Sidebar; self.status = self.tr("tui.openItemDeleted"); } self.reload()?; self.refresh_open_report()?; } } Ok(()) } fn refresh_open_report(&mut self) -> Result<()> { let Panel::Report { action, .. } = self.panel else { return Ok(()); }; let db = self.database()?; let data_dir = self.data_dir()?; let project_id = self.project_id()?; self.panel = match action { ReportAction::MetadataDiff => Panel::Report { title: self.tr("menu.item.metadataDiff"), body: metadata_report_body(db.conn(), data_dir, project_id, self.locale)?, action, }, ReportAction::SiteValidation => Panel::Report { title: self.tr("menu.item.validateSite"), body: site_validation_body(db.conn(), data_dir, project_id, self.locale)?, action, }, }; Ok(()) } fn queue_task( &mut self, label: &str, work: impl FnOnce() -> Result + Send + 'static, ) { let tasks = self.host.tasks(); let task_id = tasks.submit(label); let sender = self.background_tx.clone(); self.started_task_ids.insert(task_id); self.status = self.tr_with("tui.taskRunning", &[("label", label)]); std::thread::spawn(move || { if !tasks.wait_until_runnable(task_id) { return; } match work() { Ok(result) => { tasks.complete(task_id); let _ = sender.send(result); } Err(error) => { let message = error.to_string(); tasks.fail(task_id, message.clone()); let _ = sender.send(BackgroundResult { status: message, panel: None, reload: false, }); } } }); } fn poll_tasks(&mut self) { for task in self.host.tasks().snapshots() { if self.started_task_ids.insert(task.id) { self.status = task.label.clone(); } match task.status { engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => { self.status = match task.progress { Some(progress) => self.tr_with( "tui.taskProgress", &[ ("label", &task.label), ("percent", &format!("{:.0}", progress * 100.0)), ], ), None => task.label, }; } engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => { self.status = self.tr_with("tui.taskComplete", &[("label", &task.label)]); } engine::task::TaskStatus::Failed(error) if self.completed_task_ids.insert(task.id) => { self.status = self.tr_with( "tui.taskFailed", &[("label", &task.label), ("error", &error)], ); } engine::task::TaskStatus::Cancelled if self.completed_task_ids.insert(task.id) => { self.status = self.tr_with("tui.taskCancelled", &[("label", &task.label)]); } _ => {} } } } fn reload(&mut self) -> Result<()> { self.items = self.sidebar_items()?; self.clamp_selection(); Ok(()) } fn sidebar_items(&self) -> Result> { let Some(project) = &self.project else { return Ok(vec![SidebarItem::Empty(self.tr("tui.noProject"))]); }; let db = self.database()?; let query = self .filters .get(&self.view) .map(String::as_str) .unwrap_or(""); let mut items = Vec::new(); match self.view { TuiView::Posts => { let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)?; for (heading_key, status) in [ ("sidebar.drafts", PostStatus::Draft), ("sidebar.published", PostStatus::Published), ("sidebar.archived", PostStatus::Archived), ] { items.push(SidebarItem::Header(self.tr(heading_key))); items.extend( posts .iter() .filter(|post| { post.status == status && matches_filter_post(post, query) }) .map(|post| SidebarItem::Post(post.id.clone(), post.title.clone())), ); } } TuiView::Media => { items.push(SidebarItem::Header(self.tr("tui.viewMedia"))); items.extend( bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id)? .into_iter() .filter(|media| matches_filter_media(media, query)) .map(|media| { SidebarItem::Media( media.id, media.title.unwrap_or_else(|| media.filename.clone()), ) }), ); } TuiView::Templates => { items.push(SidebarItem::Header(self.tr("tui.viewTemplates"))); items.extend( bds_core::db::queries::template::list_templates_by_project( db.conn(), &project.id, )? .into_iter() .filter(|item| contains_ci(&format!("{} {}", item.title, item.slug), query)) .map(|item| SidebarItem::Template(item.id, item.title)), ); } TuiView::Scripts => { items.push(SidebarItem::Header(self.tr("tui.viewScripts"))); items.extend( bds_core::db::queries::script::list_scripts_by_project(db.conn(), &project.id)? .into_iter() .filter(|item| contains_ci(&format!("{} {}", item.title, item.slug), query)) .map(|item| SidebarItem::Script(item.id, item.title)), ); } TuiView::Tags => { items.push(SidebarItem::Header(self.tr("tui.viewTags"))); items.extend(TagSection::ALL.into_iter().map(SidebarItem::TagSection)); } TuiView::Settings => { items.push(SidebarItem::Header(self.tr("tui.viewSettings"))); items.extend( SettingSection::ALL .into_iter() .map(SidebarItem::SettingSection), ); } TuiView::Git => return self.git_items(), } if !items.iter().any(SidebarItem::selectable) { items.push(SidebarItem::Empty(self.tr("tui.noMatchingItems"))); } Ok(items) } fn clamp_selection(&mut self) { if self.items.is_empty() { self.selected_index = 0; return; } self.selected_index = self.selected_index.min(self.items.len() - 1); if !self.items[self.selected_index].selectable() { self.selected_index = self .items .iter() .position(SidebarItem::selectable) .unwrap_or(0); } } } fn contains_ci(haystack: &str, needle: &str) -> bool { haystack.to_lowercase().contains(&needle.to_lowercase()) } fn matches_filter_post(post: &Post, query: &str) -> bool { query.split_whitespace().all(|token| { if let Some(tag) = token.strip_prefix("tag:") { post.tags .iter() .any(|value| value.eq_ignore_ascii_case(tag)) } else if let Some(category) = token.strip_prefix("category:") { post.categories .iter() .any(|value| value.eq_ignore_ascii_case(category)) } else if token.len() == 10 && token.as_bytes().get(4) == Some(&b'-') && token.as_bytes().get(7) == Some(&b'-') { chrono::DateTime::from_timestamp_millis(post.created_at) .is_some_and(|date| date.format("%Y-%m-%d").to_string() == token) } else { contains_ci( &format!( "{} {} {}", post.title, post.slug, post.excerpt.as_deref().unwrap_or("") ), token, ) } }) } fn matches_filter_media(media: &Media, query: &str) -> bool { query.split_whitespace().all(|token| { contains_ci( &format!( "{} {} {}", media.title.as_deref().unwrap_or(""), media.filename, media.alt.as_deref().unwrap_or("") ), token, ) }) } impl TuiApp { pub fn handle_input(&mut self, input: TuiInput) -> Result<()> { if let Err(error) = self.handle_input_inner(input) { self.status = error.to_string(); } Ok(()) } fn handle_input_inner(&mut self, input: TuiInput) -> Result<()> { if self.prompt.is_some() { return self.handle_prompt(input); } if self.overlay.is_some() { return self.handle_overlay(input); } if input.modifiers.ctrl { return match input.key { TuiKey::Char('s') => self.save(false), TuiKey::Char('p') => self.save(true), TuiKey::Char('e') => { self.toggle_preview(); Ok(()) } TuiKey::Char('g') => self.ai_quick_action(), TuiKey::Char('u') => self.unpublish(), TuiKey::Char('t') => self.edit_title(), TuiKey::Char('l') => self.cycle_editor_language(), TuiKey::Char('q') => { self.request_quit(); Ok(()) } _ => self.handle_editor_input(input), }; } if self.focus == Focus::Editor && self.editor.is_none() { return self.handle_panel_input(input); } if self.focus == Focus::Editor { return self.handle_editor_input(input); } match input.key { TuiKey::Char('q') => self.request_quit(), TuiKey::Char('j') | TuiKey::Down => self.move_selection(1), TuiKey::Char('k') | TuiKey::Up => self.move_selection(-1), TuiKey::Enter => self.open_selected()?, TuiKey::Char('n') => self.new_item()?, TuiKey::Char('/') => { self.prompt = Some(Prompt { kind: PromptKind::Search, value: self.filters.get(&self.view).cloned().unwrap_or_default(), candidates: Vec::new(), }) } TuiKey::Char(':') => { self.prompt = Some(Prompt { kind: PromptKind::Command, value: String::new(), candidates: self.command_names(), }) } TuiKey::Char('p') => self.overlay = Some(Overlay::Projects { selected: 0 }), TuiKey::Char('o') => { self.prompt = Some(Prompt { kind: PromptKind::ProjectPath, value: String::new(), candidates: Vec::new(), }) } TuiKey::Char('1') => self.set_view(TuiView::Posts)?, TuiKey::Char('2') => self.set_view(TuiView::Media)?, TuiKey::Char('3') => self.set_view(TuiView::Templates)?, TuiKey::Char('4') => self.set_view(TuiView::Scripts)?, TuiKey::Char('5') => self.set_view(TuiView::Tags)?, TuiKey::Char('6') => self.set_view(TuiView::Settings)?, TuiKey::Char('7') => self.set_view(TuiView::Git)?, TuiKey::PageUp => self.scroll = self.scroll.saturating_sub(10), TuiKey::PageDown => self.scroll = self.scroll.saturating_add(10), TuiKey::Char('c') if self.view == TuiView::Git => { self.prompt = Some(Prompt { kind: PromptKind::Commit, value: String::new(), candidates: Vec::new(), }) } TuiKey::Char('u') if self.view == TuiView::Git => self.git_pull()?, TuiKey::Char('s') if self.view == TuiView::Git => self.git_push()?, TuiKey::Char(' ') if self.view == TuiView::Tags => self.toggle_tag_mark(), TuiKey::Char('m') if self.view == TuiView::Tags => self.merge_marked_tags()?, TuiKey::Char('d') if self.view == TuiView::Tags => self.begin_tag_delete()?, TuiKey::Char('c') if self.view == TuiView::Tags => self.cycle_tag_color()?, TuiKey::Char('t') if self.view == TuiView::Tags => self.cycle_tag_template()?, TuiKey::Char('s') if self.view == TuiView::Tags => self.sync_tags()?, TuiKey::Esc => { self.panel = Panel::Welcome; self.scroll = 0; } _ => {} } Ok(()) } fn request_quit(&mut self) { if self.has_unsaved_changes() { self.overlay = Some(Overlay::ConfirmDiscard); } else { self.quit = true; } } fn set_view(&mut self, view: TuiView) -> Result<()> { self.view = view; self.focus = Focus::Sidebar; self.selected_index = 0; self.editor = None; self.image_source = None; self.image_protocol = None; self.panel = match view { TuiView::Tags => Panel::Tags(TagSection::Cloud), TuiView::Settings => Panel::Settings(SettingSection::Project), TuiView::Git => Panel::Git, _ => Panel::Welcome, }; self.reload() } fn move_selection(&mut self, delta: isize) { if self.items.is_empty() { return; } let mut index = self.selected_index; for _ in 0..self.items.len() { index = if delta > 0 { (index + 1) % self.items.len() } else { index.checked_sub(1).unwrap_or(self.items.len() - 1) }; if self.items[index].selectable() { self.selected_index = index; break; } } } fn open_selected(&mut self) -> Result<()> { let Some(item) = self.items.get(self.selected_index).cloned() else { return Ok(()); }; match item { SidebarItem::Post(id, _) => self.open_post(&id), SidebarItem::Template(id, _) => self.open_template(&id), SidebarItem::Script(id, _) => self.open_script(&id), SidebarItem::Media(id, _) => self.open_media(&id), SidebarItem::TagSection(section) => { self.panel = Panel::Tags(section); self.panel_index = 0; self.focus = Focus::Editor; Ok(()) } SidebarItem::SettingSection(section) => self.open_settings(section), SidebarItem::GitFile(path, _) => self.open_git_file(&path), SidebarItem::GitCommit(hash, _) => self.open_git_commit(&hash), _ => Ok(()), } } fn new_item(&mut self) -> Result<()> { let db = self.database()?; let project_id = self.project_id()?.to_owned(); let untitled = self.tr("tui.untitled"); let entity = match self.view { TuiView::Posts => EditorEntity::Post( engine::post::create_post( db.conn(), self.data_dir()?, &project_id, &untitled, Some(""), Vec::new(), Vec::new(), None, None, None, )? .id, ), TuiView::Templates => EditorEntity::Template( engine::template::create_template( db.conn(), &project_id, &untitled, TemplateKind::Post, "", )? .id, ), TuiView::Scripts => EditorEntity::Script( engine::script::create_script( db.conn(), &project_id, &untitled, ScriptKind::Utility, "function main()\nend\n", Some("main"), )? .id, ), TuiView::Tags => { self.prompt = Some(Prompt { kind: PromptKind::TagCreate, value: String::new(), candidates: Vec::new(), }); return Ok(()); } _ => { self.status = self.tr("tui.newItemUnavailable"); return Ok(()); } }; match entity { EditorEntity::Post(id) => self.open_post(&id), EditorEntity::Template(id) => self.open_template(&id), EditorEntity::Script(id) => self.open_script(&id), } } fn open_post(&mut self, id: &str) -> Result<()> { let db = self.database()?; let post = bds_core::db::queries::post::get_post_by_id(db.conn(), id)?; let content = if let Some(content) = post.content.clone() { content } else { read_published_body(self.data_dir()?, &post.file_path, PublishedKind::Post)? }; self.editor = Some(editor( EditorEntity::Post(post.id), post.title, "markdown", post.language, &content, )); self.focus = Focus::Editor; Ok(()) } fn open_template(&mut self, id: &str) -> Result<()> { let db = self.database()?; let item = bds_core::db::queries::template::get_template_by_id(db.conn(), id)?; let content = if let Some(content) = item.content.clone() { content } else { read_published_body(self.data_dir()?, &item.file_path, PublishedKind::Template)? }; self.editor = Some(editor( EditorEntity::Template(item.id), item.title, "html", None, &content, )); self.focus = Focus::Editor; Ok(()) } fn open_script(&mut self, id: &str) -> Result<()> { let db = self.database()?; let item = bds_core::db::queries::script::get_script_by_id(db.conn(), id)?; let content = if let Some(content) = item.content.clone() { content } else { read_published_body(self.data_dir()?, &item.file_path, PublishedKind::Script)? }; self.editor = Some(editor( EditorEntity::Script(item.id), item.title, "lua", None, &content, )); self.focus = Focus::Editor; Ok(()) } fn open_media(&mut self, id: &str) -> Result<()> { let db = self.database()?; let item = bds_core::db::queries::media::get_media_by_id(db.conn(), id)?; let path = self.data_dir()?.join(item.file_path); if item.mime_type.starts_with("image/") { self.image_source = image::ImageReader::open(&path) .ok() .and_then(|reader| reader.decode().ok()); self.rebuild_image_protocol(); } else { self.image_source = None; self.image_protocol = None; } self.panel = Panel::MediaPreview { title: item.title.unwrap_or(item.original_name), path, }; Ok(()) } fn handle_editor_input(&mut self, input: TuiInput) -> Result<()> { let Some(editor) = self.editor.as_mut() else { if input.key == TuiKey::Esc { self.focus = Focus::Sidebar; } return Ok(()); }; if input.key == TuiKey::Esc { self.focus = Focus::Sidebar; return Ok(()); } if editor.mode == EditorMode::Preview { return Ok(()); } let shift = input.modifiers.shift; match input.key { TuiKey::Char(value) if !input.modifiers.ctrl && !input.modifiers.alt => { editor.buffer.insert(&value.to_string()) } TuiKey::Enter => editor.buffer.insert("\n"), TuiKey::Tab => editor.buffer.insert(" "), TuiKey::Backspace => editor.buffer.backspace(), TuiKey::Delete => editor.buffer.delete_forward(), TuiKey::Up if shift => editor.buffer.select_up(), TuiKey::Up => editor.buffer.move_up(), TuiKey::Down if shift => editor.buffer.select_down(), TuiKey::Down => editor.buffer.move_down(), TuiKey::Left if shift => editor.buffer.select_left(), TuiKey::Left => editor.buffer.move_left(), TuiKey::Right if shift => editor.buffer.select_right(), TuiKey::Right => editor.buffer.move_right(), TuiKey::Home if shift => editor.buffer.select_home(), TuiKey::Home => editor.buffer.move_home(), TuiKey::End if shift => editor.buffer.select_end(), TuiKey::End => editor.buffer.move_end(), TuiKey::PageUp => editor.buffer.move_page_up(20), TuiKey::PageDown => editor.buffer.move_page_down(20), TuiKey::Char('z') if input.modifiers.ctrl => editor.buffer.undo(), TuiKey::Char('y') if input.modifiers.ctrl => editor.buffer.redo(), _ => {} } let visible_width = self.terminal_size.0.saturating_sub(SIDEBAR_WIDTH).max(1) as usize; let visible_height = self.terminal_size.1.saturating_sub(3).max(1) as usize; let (cursor_visual_line, total_visual_lines) = visual_line_metrics(&editor.buffer, visible_width); editor.buffer.ensure_visual_line_visible( cursor_visual_line, visible_height, total_visual_lines.saturating_sub(visible_height), ); Ok(()) } fn toggle_preview(&mut self) { if let Some(editor) = &mut self.editor { editor.mode = if editor.mode == EditorMode::Source { EditorMode::Preview } else { EditorMode::Source }; self.status = if editor.mode == EditorMode::Preview { bds_core::i18n::translate(self.locale, "editor.modePreview") } else { bds_core::i18n::translate(self.locale, "tui.editorSource") }; } } fn save(&mut self, publish: bool) -> Result<()> { let Some(editor) = &self.editor else { return self.save_settings(); }; let entity = editor.entity.clone(); let title = editor.title.clone(); let post_language = editor.post_language.clone(); let content = editor.buffer.text(); let db = self.database()?; match entity { EditorEntity::Post(id) => { engine::post::update_post( db.conn(), self.data_dir()?, &id, Some(&title), None, None, Some(&content), None, None, None, Some(post_language.as_deref()), None, None, )?; if publish { engine::post::publish_post(db.conn(), self.data_dir()?, &id)?; } } EditorEntity::Template(id) => { engine::template::validate_template(&content).map_err(|error| anyhow!(error))?; let project_id = self.project_id()?.to_owned(); engine::template::update_template( db.conn(), &id, &project_id, Some(&title), None, None, None, Some(&content), )?; if publish { let item = bds_core::db::queries::template::get_template_by_id(db.conn(), &id)?; if item.status == TemplateStatus::Published { engine::template::unpublish_template(db.conn(), self.data_dir()?, &id)?; } engine::template::publish_template(db.conn(), self.data_dir()?, &id)?; } } EditorEntity::Script(id) => { engine::script::validate_script_syntax(&content).map_err(|error| anyhow!(error))?; let project_id = self.project_id()?.to_owned(); engine::script::update_script( db.conn(), &id, &project_id, Some(&title), None, None, None, None, Some(&content), )?; if publish { let item = bds_core::db::queries::script::get_script_by_id(db.conn(), &id)?; if item.status == ScriptStatus::Published { engine::script::unpublish_script(db.conn(), self.data_dir()?, &id)?; } engine::script::publish_script(db.conn(), self.data_dir()?, &id)?; } } } if let Some(editor) = &mut self.editor { editor.buffer.set_dirty(false); } self.status = if publish { self.tr("tui.savedAndPublished") } else { self.tr("tui.saved") }; self.reload() } fn ai_quick_action(&mut self) -> Result<()> { let Some(Editor { entity: EditorEntity::Post(_), title, buffer, .. }) = &self.editor else { return Ok(()); }; let db = self.database()?; if let Err(error) = engine::ai::active_endpoint(db.conn(), self.airplane) { self.status = if self.airplane { self.tr("tui.airplaneAiEndpointRequired") } else { error.to_string() }; return Ok(()); } let request = engine::ai::OneShotRequest { operation: engine::ai::OneShotOperation::AnalyzePost, content: serde_json::json!({ "title": title, "content": buffer.text() }), }; let (response, _) = engine::ai::run_one_shot(db.conn(), self.airplane, &request)?; let engine::ai::OneShotResponse::PostAnalysis(analysis) = response else { bail!(self.tr("tui.aiWrongResponseType")); }; self.output.push(self.tr_with( "tui.aiSuggestions", &[("title", &analysis.title), ("excerpt", &analysis.excerpt)], )); self.status = self.tr("tui.aiSuggestionsAdded"); Ok(()) } fn unpublish(&mut self) -> Result<()> { let Some(editor) = &self.editor else { return Ok(()); }; let db = self.database()?; match &editor.entity { EditorEntity::Post(id) => engine::post::archive_post(db.conn(), self.data_dir()?, id)?, EditorEntity::Template(id) => { engine::template::unpublish_template(db.conn(), self.data_dir()?, id)?; } EditorEntity::Script(id) => { engine::script::unpublish_script(db.conn(), self.data_dir()?, id)?; } } self.status = self.tr("tui.unpublished"); self.reload() } fn edit_title(&mut self) -> Result<()> { if let Some(editor) = &self.editor { self.prompt = Some(Prompt { kind: PromptKind::EditorTitle, value: editor.title.clone(), candidates: Vec::new(), }); } Ok(()) } fn cycle_editor_language(&mut self) -> Result<()> { let Some(editor) = &mut self.editor else { return Ok(()); }; if !matches!(editor.entity, EditorEntity::Post(_)) { return Ok(()); } let mut languages = vec!["en".to_string()]; if let Some(data_dir) = &self.data_dir && let Ok(metadata) = engine::meta::read_project_json(data_dir) { if let Some(main) = metadata.main_language { languages.insert(0, main); } languages.extend(metadata.blog_languages); } languages.sort(); languages.dedup(); let current = editor .post_language .as_deref() .unwrap_or(languages.first().map(String::as_str).unwrap_or("en")); let index = languages .iter() .position(|value| value == current) .unwrap_or(0); editor.post_language = Some(languages[(index + 1) % languages.len()].clone()); editor.buffer.set_dirty(true); self.status = bds_core::i18n::translate_with( self.locale, "tui.editorLanguage", &[("language", editor.post_language.as_deref().unwrap_or("en"))], ); Ok(()) } fn handle_panel_input(&mut self, input: TuiInput) -> Result<()> { match input.key { TuiKey::Esc => { self.focus = Focus::Sidebar; self.prompt = None; self.panel = Panel::Welcome; self.settings_fields.clear(); self.scroll = 0; } TuiKey::Up | TuiKey::Char('k') => { if matches!(self.panel, Panel::Settings(_)) { self.move_setting_selection(-1); } else { self.panel_index = self.panel_index.saturating_sub(1); } } TuiKey::Down | TuiKey::Char('j') => { if matches!(self.panel, Panel::Settings(_)) { self.move_setting_selection(1); } else { self.panel_index = self.panel_index.saturating_add(1); } } TuiKey::PageUp => self.scroll = self.scroll.saturating_sub(10), TuiKey::PageDown => self.scroll = self.scroll.saturating_add(10), TuiKey::Enter => match self.panel { Panel::Settings(_) => self.edit_setting_field()?, Panel::Tags(TagSection::Manage) => self.begin_tag_rename()?, Panel::Report { action, .. } => self.apply_report(action)?, _ => {} }, TuiKey::Char('n') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { self.prompt = Some(Prompt { kind: PromptKind::TagCreate, value: String::new(), candidates: Vec::new(), }) } TuiKey::Char(' ') if matches!(self.panel, Panel::Tags(TagSection::Merge)) => { self.toggle_tag_mark() } TuiKey::Char('m') if matches!(self.panel, Panel::Tags(TagSection::Merge)) => { self.merge_marked_tags()? } TuiKey::Char('d') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { self.begin_tag_delete()? } TuiKey::Char('c') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { self.cycle_tag_color()? } TuiKey::Char('t') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { self.cycle_tag_template()? } TuiKey::Char('s') if matches!(self.panel, Panel::Tags(_)) => self.sync_tags()?, _ => {} } let max = match self.panel { Panel::Settings(_) => self.settings_fields.len(), Panel::Tags(_) => self.tags().map(|tags| tags.len()).unwrap_or(0), _ => usize::MAX, }; if max > 0 { self.panel_index = self.panel_index.min(max - 1); } else { self.panel_index = 0; } Ok(()) } fn move_setting_selection(&mut self, delta: isize) { if self.settings_fields.is_empty() { return; } let mut index = self.panel_index; for _ in 0..self.settings_fields.len() { index = if delta > 0 { (index + 1) % self.settings_fields.len() } else { index .checked_sub(1) .unwrap_or(self.settings_fields.len() - 1) }; if self.settings_fields[index].kind != FieldKind::ReadOnly { self.panel_index = index; return; } } } fn handle_prompt(&mut self, input: TuiInput) -> Result<()> { let Some(mut prompt) = self.prompt.take() else { return Ok(()); }; let refilter = matches!(input.key, TuiKey::Char(_) | TuiKey::Backspace); match input.key { TuiKey::Esc => { if prompt.kind == PromptKind::Search { self.filters.remove(&self.view); self.reload()?; } else if prompt.kind == PromptKind::ProjectPath { self.overlay = Some(Overlay::Projects { selected: 0 }); } self.status.clear(); return Ok(()); } TuiKey::Backspace => { prompt.value.pop(); } TuiKey::Char(value) if !input.modifiers.ctrl && !input.modifiers.alt => { prompt.value.push(value); if prompt.kind == PromptKind::ProjectPath { prompt.candidates.clear(); } } TuiKey::Tab if prompt.kind == PromptKind::ProjectPath => complete_path(&mut prompt), TuiKey::Up if prompt.kind == PromptKind::Command && !prompt.candidates.is_empty() => { prompt.candidates.rotate_right(1) } TuiKey::Down if prompt.kind == PromptKind::Command && !prompt.candidates.is_empty() => { prompt.candidates.rotate_left(1) } TuiKey::Enter => return self.submit_prompt(prompt), _ => {} } if prompt.kind == PromptKind::Search { self.filters.insert(self.view, prompt.value.clone()); self.reload()?; } else if prompt.kind == PromptKind::Command && refilter { prompt.candidates = self .command_names() .iter() .filter(|name| contains_ci(name, &prompt.value)) .cloned() .collect(); } self.prompt = Some(prompt); Ok(()) } fn submit_prompt(&mut self, prompt: Prompt) -> Result<()> { match prompt.kind { PromptKind::Search => { self.filters.insert(self.view, prompt.value); self.reload()?; } PromptKind::Command => { let typed = prompt.value.trim_start_matches(':').trim(); let selected = if typed == "?" || self.command_id(typed).is_some() { typed.to_owned() } else { prompt .candidates .first() .cloned() .unwrap_or_else(|| typed.to_owned()) }; self.run_command(&selected)?; } PromptKind::ProjectPath => self.open_project_path(&prompt.value)?, PromptKind::Commit => { if !self.require_git()? { return Ok(()); } else if prompt.value.trim().is_empty() { self.status = self.tr("tui.commitMessageRequired"); self.prompt = Some(prompt); } else { let data_dir = self.data_dir()?.to_owned(); let message = prompt.value; let locale = self.locale; let label = self.tr("tui.taskGitCommit"); self.queue_task(&label, move || { let output = engine::git::GitEngine::new(&data_dir) .commit_all(&message)? .output; Ok(BackgroundResult { status: if output.trim().is_empty() { bds_core::i18n::translate(locale, "tui.committed") } else { output }, panel: Some(Panel::Git), reload: true, }) }); } } PromptKind::Setting(index) => { if let Some(field) = self.settings_fields.get_mut(index) { field.value = prompt.value; } } PromptKind::TagCreate => { if prompt.value.trim().is_empty() { self.status = self.tr("tui.tagNameRequired"); self.prompt = Some(prompt); } else { let db = self.database()?; engine::tag::create_tag( db.conn(), self.data_dir()?, self.project_id()?, prompt.value.trim(), None, )?; self.status = self.tr("tui.tagCreated"); } } PromptKind::TagRename(id) => { if prompt.value.trim().is_empty() { self.status = self.tr("tui.tagNameRequired"); } else { let db = self.database()?; engine::tag::rename_tag( db.conn(), self.data_dir()?, self.project_id()?, &id, prompt.value.trim(), )?; self.status = self.tr("tui.tagRenamed"); } } PromptKind::ConfirmDeleteTag(id) => { if prompt.value.eq_ignore_ascii_case("y") || prompt .value .eq_ignore_ascii_case(&self.tr("tui.confirmYesInput")) { let db = self.database()?; engine::tag::delete_tag(db.conn(), self.data_dir()?, self.project_id()?, &id)?; self.status = self.tr("tui.tagDeleted"); } else { self.status = self.tr("tui.deleteCancelled"); } } PromptKind::EditorTitle => { if prompt.value.trim().is_empty() { self.status = self.tr("tui.titleRequired"); self.prompt = Some(prompt); } else if let Some(editor) = &mut self.editor { editor.title = prompt.value.trim().to_owned(); editor.buffer.set_dirty(true); } } } Ok(()) } fn handle_overlay(&mut self, input: TuiInput) -> Result<()> { let Some(mut overlay) = self.overlay.take() else { return Ok(()); }; match &mut overlay { Overlay::ConfirmDiscard => match input.key { TuiKey::Char(value) if value == 'y' || self .tr("tui.confirmYesInput") .starts_with(value.to_ascii_lowercase()) => { self.quit = true } TuiKey::Char(value) if value == 'n' || self .tr("tui.confirmNoInput") .starts_with(value.to_ascii_lowercase()) => {} TuiKey::Esc => {} _ => self.overlay = Some(overlay), }, Overlay::Projects { selected } => { let projects = engine::project::list_projects(self.database()?.conn())?; match input.key { TuiKey::Esc => {} TuiKey::Up | TuiKey::Char('k') => *selected = selected.saturating_sub(1), TuiKey::Down | TuiKey::Char('j') => { *selected = (*selected + 1).min(projects.len().saturating_sub(1)) } TuiKey::Enter => { if let Some(project) = projects.get(*selected) { self.activate_project(project.clone())?; return Ok(()); } } TuiKey::Char('o') => { self.prompt = Some(Prompt { kind: PromptKind::ProjectPath, value: String::new(), candidates: Vec::new(), }); return Ok(()); } _ => {} } if !matches!(input.key, TuiKey::Esc) { self.overlay = Some(overlay); } } } Ok(()) } fn activate_project(&mut self, project: Project) -> Result<()> { let db = self.database()?; engine::project::set_active_project(db.conn(), &project.id)?; self.data_dir = Some(self.host.project_data_dir(&project)); self.project = Some(project); self.editor = None; self.panel = Panel::Welcome; self.status = self.tr("tui.projectSwitched"); self.reload() } fn open_project_path(&mut self, value: &str) -> Result<()> { let path = expand_home(value); if !path.is_dir() { self.status = self.tr_with("tui.notDirectory", &[("path", &path.display().to_string())]); self.prompt = Some(Prompt { kind: PromptKind::ProjectPath, value: value.into(), candidates: Vec::new(), }); return Ok(()); } let canonical = path.canonicalize()?; let db = self.database()?; if let Some(project) = engine::project::list_projects(db.conn())? .into_iter() .find(|project| { project .data_path .as_deref() .map(Path::new) .is_some_and(|existing| existing == canonical) }) { return self.activate_project(project); } let name = canonical .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| self.tr("tui.defaultProjectName")); let project = engine::project::create_project( db.conn(), &name, Some( canonical .to_str() .ok_or_else(|| anyhow!(self.tr("tui.projectPathInvalidUtf8")))?, ), )?; engine::project::set_active_project(db.conn(), &project.id)?; let report = engine::rebuild::rebuild_from_filesystem(db.conn(), &canonical, &project.id)?; self.output.push(self.tr_with( "tui.rebuildSummary", &[ ( "posts", &(report.posts_created + report.posts_updated).to_string(), ), ( "media", &(report.media_created + report.media_updated).to_string(), ), ( "templates", &(report.templates_created + report.templates_updated).to_string(), ), ( "scripts", &(report.scripts_created + report.scripts_updated).to_string(), ), ], )); self.activate_project(project) } } fn expand_home(value: &str) -> PathBuf { if value == "~" { return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value)); } if let Some(rest) = value.strip_prefix("~/") { return dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(rest); } PathBuf::from(value) } fn complete_path(prompt: &mut Prompt) { let expanded = expand_home(&prompt.value); let (directory, prefix) = if expanded.is_dir() { (expanded.as_path(), "") } else { ( expanded.parent().unwrap_or(Path::new(".")), expanded.file_name().and_then(|v| v.to_str()).unwrap_or(""), ) }; let show_dot = prefix.starts_with('.'); let Ok(entries) = fs::read_dir(directory) else { return; }; let mut candidates = entries .flatten() .filter(|entry| entry.path().is_dir()) .filter_map(|entry| entry.file_name().into_string().ok()) .filter(|name| (show_dot || !name.starts_with('.')) && name.starts_with(prefix)) .collect::>(); candidates.sort(); if candidates.is_empty() { return; } let completion = longest_common_prefix(&candidates); let parent = Path::new(&prompt.value).parent().unwrap_or(Path::new("")); prompt.value = parent.join(completion).to_string_lossy().into_owned(); if candidates.len() == 1 { prompt.value.push(std::path::MAIN_SEPARATOR); } prompt.candidates = candidates; } fn longest_common_prefix(values: &[String]) -> String { let Some(first) = values.first() else { return String::new(); }; first .chars() .take_while(|_| true) .enumerate() .take_while(|(index, value)| { values .iter() .all(|candidate| candidate.chars().nth(*index) == Some(*value)) }) .map(|(_, value)| value) .collect() } impl TuiApp { fn open_settings(&mut self, section: SettingSection) -> Result<()> { self.settings_fields = self.load_setting_fields(section)?; self.panel = Panel::Settings(section); self.panel_index = self .settings_fields .iter() .position(|field| field.kind != FieldKind::ReadOnly) .unwrap_or(0); self.focus = Focus::Editor; Ok(()) } fn load_setting_fields(&self, section: SettingSection) -> Result> { let db = self.database()?; let project = self.project.as_ref(); let metadata = self .data_dir() .ok() .and_then(|path| engine::meta::read_project_json(path).ok()); let publishing = self .data_dir() .ok() .and_then(|path| engine::meta::read_publishing_json(path).ok()) .unwrap_or_default(); let ai = engine::ai::load_ai_settings(db.conn(), self.airplane)?; let specs: Vec<(&'static str, FieldKind, String)> = match section { SettingSection::Project => vec![ ( "project.name", FieldKind::Text, project.map(|p| p.name.clone()).unwrap_or_default(), ), ( "project.description", FieldKind::Text, project .and_then(|p| p.description.clone()) .unwrap_or_default(), ), ( "project.data_path", FieldKind::ReadOnly, self.data_dir .as_ref() .map(|p| p.display().to_string()) .unwrap_or_default(), ), ( engine::settings::UI_LANGUAGE_KEY, FieldKind::Enum(&["en", "de", "fr", "it", "es"]), self.locale.code().into(), ), ( "meta.default_author", FieldKind::Text, metadata .as_ref() .and_then(|value| value.default_author.clone()) .unwrap_or_default(), ), ( "meta.max_posts_per_page", FieldKind::Text, metadata .as_ref() .map(|value| value.max_posts_per_page.to_string()) .unwrap_or_else(|| "50".into()), ), ( "meta.main_language", FieldKind::Text, metadata .as_ref() .and_then(|value| value.main_language.clone()) .unwrap_or_default(), ), ( "meta.blog_languages", FieldKind::Text, metadata .as_ref() .map(|value| value.blog_languages.join(", ")) .unwrap_or_default(), ), ( "meta.public_url", FieldKind::Text, metadata .as_ref() .and_then(|value| value.public_url.clone()) .unwrap_or_default(), ), ( "meta.image_import_concurrency", FieldKind::Text, metadata .as_ref() .map(|value| value.image_import_concurrency.to_string()) .unwrap_or_else(|| "4".into()), ), ( "meta.blogmark_category", FieldKind::Text, metadata .as_ref() .and_then(|value| value.blogmark_category.clone()) .unwrap_or_default(), ), ], SettingSection::Editor => field_specs(&[ ( "editor.default_mode", FieldKind::Enum(&["markdown", "preview"]), "markdown", ), ( "editor.diff_view_style", FieldKind::Enum(&["inline", "side-by-side"]), "inline", ), ("editor.wrap_long_lines", FieldKind::Bool, "true"), ("editor.hide_unchanged_regions", FieldKind::Bool, "false"), ]), SettingSection::Content => vec![ ( "meta.categories", FieldKind::ReadOnly, self.data_dir() .ok() .and_then(|path| engine::meta::read_categories_json(path).ok()) .map(|values| values.join(", ")) .unwrap_or_default(), ), ( "meta.category_editing", FieldKind::ReadOnly, self.tr("tui.categoryEditingGuiOnly"), ), ], SettingSection::Ai => vec![ ( "ai.endpoint.online.url", FieldKind::Text, ai.online.endpoint.url, ), ( "ai.endpoint.online.model", FieldKind::Text, ai.online.endpoint.model, ), ( "ai.endpoint.online.api_key", FieldKind::ReadOnly, if ai.online.endpoint.api_key_configured { self.tr("tui.apiKeyConfigured") } else { self.tr("tui.apiKeyConfigureDesktop") }, ), ( "ai.endpoint.online.title_model", FieldKind::Text, ai.online.title_model.unwrap_or_default(), ), ( "ai.endpoint.online.image_model", FieldKind::Text, ai.online.image_model.unwrap_or_default(), ), ( "ai.endpoint.online.chat_supports_tools", FieldKind::Bool, ai.online.chat_supports_tools.unwrap_or(false).to_string(), ), ( "ai.endpoint.online.image_supports_vision", FieldKind::Bool, ai.online.image_supports_vision.unwrap_or(false).to_string(), ), ( "ai.endpoint.airplane.url", FieldKind::Text, ai.airplane.endpoint.url, ), ( "ai.endpoint.airplane.model", FieldKind::Text, ai.airplane.endpoint.model, ), ( "ai.endpoint.airplane.api_key", FieldKind::ReadOnly, if ai.airplane.endpoint.api_key_configured { self.tr("tui.apiKeyConfigured") } else { self.tr("tui.apiKeyConfigureDesktop") }, ), ( "ai.endpoint.airplane.title_model", FieldKind::Text, ai.airplane.title_model.unwrap_or_default(), ), ( "ai.endpoint.airplane.image_model", FieldKind::Text, ai.airplane.image_model.unwrap_or_default(), ), ( "ai.endpoint.airplane.chat_supports_tools", FieldKind::Bool, ai.airplane.chat_supports_tools.unwrap_or(false).to_string(), ), ( "ai.endpoint.airplane.image_supports_vision", FieldKind::Bool, ai.airplane .image_supports_vision .unwrap_or(false) .to_string(), ), ("ai.system_prompt", FieldKind::Text, ai.system_prompt), ], SettingSection::Technology => vec![ ("technology.runtime", FieldKind::ReadOnly, "Lua".into()), ( "meta.semantic_similarity_enabled", FieldKind::Bool, metadata .as_ref() .map(|value| value.semantic_similarity_enabled.to_string()) .unwrap_or_else(|| "false".into()), ), ], SettingSection::Publishing => vec![ ( "publishing.ssh_host", FieldKind::Text, publishing.ssh_host.unwrap_or_default(), ), ( "publishing.ssh_username", FieldKind::Text, publishing.ssh_user.unwrap_or_default(), ), ( "publishing.ssh_remote_path", FieldKind::Text, publishing.ssh_remote_path.unwrap_or_default(), ), ( "publishing.ssh_mode", FieldKind::Enum(&["scp", "rsync"]), match publishing.ssh_mode { SshMode::Scp => "scp".into(), SshMode::Rsync => "rsync".into(), }, ), ], SettingSection::Data => vec![ ( "data.database", FieldKind::ReadOnly, self.host.database_path().display().to_string(), ), ("data.automatic_rebuild", FieldKind::Bool, "true".into()), ], SettingSection::Mcp => { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); vec![ ("mcp.http.enabled", FieldKind::Bool, "false".into()), ( "mcp.agent.claude_code", FieldKind::Bool, engine::mcp::is_agent_configured(engine::mcp::McpAgent::ClaudeCode, &home) .to_string(), ), ( "mcp.agent.github_copilot", FieldKind::Bool, engine::mcp::is_agent_configured( engine::mcp::McpAgent::GithubCopilot, &home, ) .to_string(), ), ( "mcp.proposals", FieldKind::ReadOnly, engine::mcp::list_pending_proposals(db.conn(), self.project_id()?)? .len() .to_string(), ), ] } }; specs .into_iter() .map(|(key, kind, fallback)| { let value = if kind == FieldKind::ReadOnly || key.starts_with("project.") || key.starts_with("meta.") || key.starts_with("publishing.") || key.starts_with("ai.endpoint.") || key.starts_with("mcp.agent.") || key == "ai.system_prompt" { fallback } else { engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback) }; Ok(SettingField { label: setting_field_label(self.locale, key), key, kind, value, }) }) .collect() } fn edit_setting_field(&mut self) -> Result<()> { let Some(field) = self.settings_fields.get_mut(self.panel_index) else { return Ok(()); }; match &field.kind { FieldKind::ReadOnly => { self.status = bds_core::i18n::translate(self.locale, "tui.valueReadOnly") } FieldKind::Bool => { field.value = (!field.value.eq_ignore_ascii_case("true")).to_string() } FieldKind::Enum(values) => { let index = values .iter() .position(|value| *value == field.value) .unwrap_or(0); field.value = values[(index + 1) % values.len()].to_string(); } FieldKind::Text => { self.prompt = Some(Prompt { kind: PromptKind::Setting(self.panel_index), value: field.value.clone(), candidates: Vec::new(), }) } } Ok(()) } fn save_settings(&mut self) -> Result<()> { let Panel::Settings(section) = self.panel else { return Ok(()); }; let db = self.database()?; match section { SettingSection::Project => { let name = self.setting_value("project.name").trim(); if name.is_empty() { bail!(self.tr("tui.projectNameRequired")); } let mut project = self .project .clone() .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))?; project.name = name.into(); project.description = optional_string(self.setting_value("project.description")); project.updated_at = bds_core::util::now_unix_ms(); let mut metadata = self.project_metadata()?; metadata.name = project.name.clone(); metadata.description = project.description.clone(); metadata.default_author = optional_string(self.setting_value("meta.default_author")); metadata.main_language = optional_string(self.setting_value("meta.main_language")); metadata.public_url = optional_string(self.setting_value("meta.public_url")); metadata.blogmark_category = optional_string(self.setting_value("meta.blogmark_category")); metadata.blog_languages = self .setting_value("meta.blog_languages") .split(',') .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect(); metadata.blog_languages.sort(); metadata.blog_languages.dedup(); metadata.max_posts_per_page = self .setting_value("meta.max_posts_per_page") .trim() .parse() .map_err(|_| anyhow!(self.tr("tui.postsPerPageInvalid")))?; metadata.image_import_concurrency = self .setting_value("meta.image_import_concurrency") .trim() .parse() .map_err(|_| anyhow!(self.tr("tui.imageImportConcurrencyInvalid")))?; metadata.validate().map_err(|error| anyhow!(error))?; bds_core::db::queries::project::update_project(db.conn(), &project)?; engine::meta::write_project_json(self.data_dir()?, &metadata)?; self.project = Some(project); if let Some(value) = self.setting_field(engine::settings::UI_LANGUAGE_KEY) { engine::settings::set(db.conn(), value.key, &value.value)?; self.locale = normalize_language(&value.value); } } SettingSection::Content => {} SettingSection::Ai => { self.save_ai_settings(&db)?; } SettingSection::Technology => { let mut metadata = self.project_metadata()?; metadata.semantic_similarity_enabled = self .setting_value("meta.semantic_similarity_enabled") .eq_ignore_ascii_case("true"); engine::meta::write_project_json(self.data_dir()?, &metadata)?; } SettingSection::Publishing => { let preferences = PublishingPreferences { ssh_host: optional_string(self.setting_value("publishing.ssh_host")), ssh_user: optional_string(self.setting_value("publishing.ssh_username")), ssh_remote_path: optional_string( self.setting_value("publishing.ssh_remote_path"), ), ssh_mode: if self.setting_value("publishing.ssh_mode") == "rsync" { SshMode::Rsync } else { SshMode::Scp }, }; engine::meta::write_publishing_json(self.data_dir()?, &preferences)?; } SettingSection::Mcp => { engine::settings::set( db.conn(), "mcp.http.enabled", self.setting_value("mcp.http.enabled"), )?; let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); for (agent, key) in [ (engine::mcp::McpAgent::ClaudeCode, "mcp.agent.claude_code"), ( engine::mcp::McpAgent::GithubCopilot, "mcp.agent.github_copilot", ), ] { let desired = self.setting_value(key).eq_ignore_ascii_case("true"); let configured = engine::mcp::is_agent_configured(agent, &home); if desired && !configured { let executable = engine::mcp::packaged_mcp_executable()?; engine::mcp::install_agent_config(agent, &home, &executable)?; } else if !desired && configured { engine::mcp::remove_agent_config(agent, &home)?; } } } _ => { for field in &self.settings_fields { if field.kind != FieldKind::ReadOnly { engine::settings::set(db.conn(), field.key, &field.value)?; } } } } if let Some(value) = self .settings_fields .iter() .find(|field| field.key == "ai.airplane_mode") { self.airplane = value.value == "true"; } if let Some(value) = self .settings_fields .iter() .find(|field| field.key == engine::settings::UI_LANGUAGE_KEY) { self.locale = normalize_language(&value.value); } let selected_key = self .settings_fields .get(self.panel_index) .map(|field| field.key); self.settings_fields = self.load_setting_fields(section)?; self.panel_index = selected_key .and_then(|key| { self.settings_fields .iter() .position(|field| field.key == key) }) .filter(|index| self.settings_fields[*index].kind != FieldKind::ReadOnly) .or_else(|| { self.settings_fields .iter() .position(|field| field.kind != FieldKind::ReadOnly) }) .unwrap_or(0); self.status = self.tr("tui.settingsSaved"); Ok(()) } fn setting_field(&self, key: &str) -> Option<&SettingField> { self.settings_fields.iter().find(|field| field.key == key) } fn setting_value(&self, key: &str) -> &str { self.setting_field(key) .map(|field| field.value.as_str()) .unwrap_or("") } fn project_metadata(&self) -> Result { engine::meta::read_project_json(self.data_dir()?).or_else(|_| { let project = self .project .as_ref() .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))?; Ok(ProjectMetadata { name: project.name.clone(), description: project.description.clone(), public_url: None, main_language: None, default_author: None, max_posts_per_page: 50, image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, blog_languages: Vec::new(), }) }) } fn save_ai_settings(&mut self, db: &Database) -> Result<()> { for (kind, url_key, model_key) in [ ( AiEndpointKind::Online, "ai.endpoint.online.url", "ai.endpoint.online.model", ), ( AiEndpointKind::Airplane, "ai.endpoint.airplane.url", "ai.endpoint.airplane.model", ), ] { let url = self.setting_value(url_key).trim(); let model = self.setting_value(model_key).trim(); if !url.is_empty() || !model.is_empty() { engine::ai::save_endpoint( db.conn(), &AiEndpointConfig { kind, url: url.into(), model: model.into(), api_key: engine::ai::load_endpoint_api_key(kind)?, }, )?; } let prefix = format!("ai.endpoint.{}", kind.as_str()); engine::ai::save_model_preferences( db.conn(), kind, optional_str(self.setting_value(&format!("{prefix}.title_model"))), optional_str(self.setting_value(&format!("{prefix}.image_model"))), Some( self.setting_value(&format!("{prefix}.chat_supports_tools")) .eq_ignore_ascii_case("true"), ), Some( self.setting_value(&format!("{prefix}.image_supports_vision")) .eq_ignore_ascii_case("true"), ), )?; } engine::ai::save_system_prompt(db.conn(), self.setting_value("ai.system_prompt"))?; Ok(()) } fn tags(&self) -> Result> { let db = self.database()?; let mut tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), self.project_id()?)?; tags.sort_by_key(|tag| tag.name.to_lowercase()); Ok(tags) } fn begin_tag_rename(&mut self) -> Result<()> { if let Some(tag) = self.tags()?.get(self.panel_index) { self.prompt = Some(Prompt { kind: PromptKind::TagRename(tag.id.clone()), value: tag.name.clone(), candidates: Vec::new(), }); } Ok(()) } fn begin_tag_delete(&mut self) -> Result<()> { if let Some(tag) = self.tags()?.get(self.panel_index) { let db = self.database()?; let count = bds_core::db::queries::post::list_posts_by_project(db.conn(), self.project_id()?)? .into_iter() .filter(|post| { post.tags .iter() .any(|name| name.eq_ignore_ascii_case(&tag.name)) }) .count(); self.status = self.tr_with( "tui.deleteTagConfirm", &[ ("name", &tag.name), ("count", &count.to_string()), ("yes", &self.tr("tui.confirmYesInput")), ], ); self.prompt = Some(Prompt { kind: PromptKind::ConfirmDeleteTag(tag.id.clone()), value: String::new(), candidates: Vec::new(), }); } Ok(()) } fn toggle_tag_mark(&mut self) { if let Ok(tags) = self.tags() && let Some(tag) = tags.get(self.panel_index) && !self.marked_tags.remove(&tag.id) { self.marked_tags.insert(tag.id.clone()); } } fn merge_marked_tags(&mut self) -> Result<()> { let tags = self.tags()?; let Some(target) = tags.get(self.panel_index) else { return Ok(()); }; if !self.marked_tags.contains(&target.id) || self.marked_tags.len() < 2 { self.status = self.tr("tui.markTagsRequired"); return Ok(()); } let sources = self .marked_tags .iter() .filter(|id| *id != &target.id) .map(String::as_str) .collect::>(); let db = self.database()?; engine::tag::merge_tags( db.conn(), self.data_dir()?, self.project_id()?, &sources, &target.id, )?; self.marked_tags.clear(); self.status = self.tr("tui.tagsMerged"); Ok(()) } fn cycle_tag_color(&mut self) -> Result<()> { let Some(tag) = self.tags()?.get(self.panel_index).cloned() else { return Ok(()); }; let index = COLORS .iter() .position(|color| Some(*color) == tag.color.as_deref()) .unwrap_or(0); let db = self.database()?; engine::tag::update_tag( db.conn(), self.data_dir()?, &tag.id, None, Some(COLORS[(index + 1) % COLORS.len()]), None, )?; self.status = self.tr("tui.tagColourUpdated"); Ok(()) } fn cycle_tag_template(&mut self) -> Result<()> { let Some(tag) = self.tags()?.get(self.panel_index).cloned() else { return Ok(()); }; let db = self.database()?; let mut templates = bds_core::db::queries::template::list_templates_by_project( db.conn(), self.project_id()?, )? .into_iter() .filter(|template| template.kind == TemplateKind::Post) .map(|template| template.slug) .collect::>(); templates.insert(0, String::new()); let index = templates .iter() .position(|slug| Some(slug.as_str()) == tag.post_template_slug.as_deref()) .unwrap_or(0); engine::tag::update_tag( db.conn(), self.data_dir()?, &tag.id, None, None, Some(&templates[(index + 1) % templates.len()]), )?; self.status = self.tr("tui.tagTemplateUpdated"); Ok(()) } fn sync_tags(&mut self) -> Result<()> { let db = self.database()?; let tags = engine::tag::sync_tags_from_posts(db.conn(), self.project_id()?)?; engine::tag::rewrite_tags_json(db.conn(), self.data_dir()?, self.project_id()?)?; self.status = self.tr_with( "tui.tagsSynchronized", &[("count", &tags.len().to_string())], ); Ok(()) } } fn field_specs( values: &[(&'static str, FieldKind, &'static str)], ) -> Vec<(&'static str, FieldKind, String)> { values .iter() .map(|(key, kind, value)| (*key, kind.clone(), (*value).into())) .collect() } fn setting_field_label(locale: UiLocale, key: &str) -> String { let translation_key = match key { "project.name" => "settings.projectName", "project.description" => "settings.projectDescription", "project.data_path" => "settings.dataPath", engine::settings::UI_LANGUAGE_KEY => "tui.settingUiLanguage", "editor.default_mode" => "settings.defaultMode", "editor.diff_view_style" => "settings.diffViewStyle", "editor.wrap_long_lines" => "settings.wrapLongLines", "editor.hide_unchanged_regions" => "settings.hideUnchangedRegions", "meta.default_author" => "settings.defaultAuthor", "meta.max_posts_per_page" => "settings.maxPostsPerPage", "meta.main_language" => "settings.mainLanguage", "meta.blog_languages" => "settings.blogLanguages", "meta.public_url" => "settings.publicUrl", "meta.image_import_concurrency" => "settings.imageImportConcurrency", "meta.blogmark_category" => "settings.blogmarkCategory", "meta.categories" => "editor.categories", "meta.category_editing" => "tui.categoryEditing", "ai.endpoint.online.url" => "settings.onlineEndpointUrl", "ai.endpoint.online.model" => "settings.onlineChatModel", "ai.endpoint.online.api_key" => "settings.onlineApiKey", "ai.endpoint.online.title_model" => "settings.onlineTitleModel", "ai.endpoint.online.image_model" => "settings.onlineImageModel", "ai.endpoint.online.chat_supports_tools" => "settings.onlineModelSupportsTools", "ai.endpoint.online.image_supports_vision" => "settings.onlineModelSupportsVision", "ai.endpoint.airplane.url" => "settings.airplaneEndpointUrl", "ai.endpoint.airplane.model" => "settings.airplaneChatModel", "ai.endpoint.airplane.api_key" => "settings.airplaneApiKey", "ai.endpoint.airplane.title_model" => "settings.airplaneTitleModel", "ai.endpoint.airplane.image_model" => "settings.airplaneImageModel", "ai.endpoint.airplane.chat_supports_tools" => "settings.airplaneModelSupportsTools", "ai.endpoint.airplane.image_supports_vision" => "settings.airplaneModelSupportsVision", "ai.system_prompt" => "settings.systemPrompt", "technology.runtime" => "tui.settingRuntime", "meta.semantic_similarity_enabled" => "settings.semanticSimilarityEnabled", "publishing.ssh_host" => "settings.sshHost", "publishing.ssh_username" => "settings.sshUsername", "publishing.ssh_remote_path" => "settings.sshRemotePath", "publishing.ssh_mode" => "tui.settingTransferMode", "data.database" => "tui.settingDatabase", "data.automatic_rebuild" => "tui.settingAutomaticRebuild", "mcp.http.enabled" => "settings.mcpEnable", "mcp.agent.claude_code" => "tui.settingClaudeCode", "mcp.agent.github_copilot" => "tui.settingGithubCopilot", "mcp.proposals" => "settings.mcpProposals", _ => return key.into(), }; bds_core::i18n::translate(locale, translation_key) } fn optional_string(value: &str) -> Option { optional_str(value).map(ToOwned::to_owned) } fn optional_str(value: &str) -> Option<&str> { let value = value.trim(); (!value.is_empty()).then_some(value) } fn visual_line_metrics(buffer: &EditorBuffer, width: usize) -> (usize, usize) { let width = width.max(1); let (cursor_line, cursor_col) = buffer.cursor(); let mut visual_cursor = 0; let mut visual_total = 0; for index in 0..buffer.line_count() { let line_len = buffer .line(index) .map(|line| line.chars().filter(|character| *character != '\n').count()) .unwrap_or(0); if index < cursor_line { visual_cursor += line_len.max(1).div_ceil(width); } else if index == cursor_line { visual_cursor += cursor_col / width; } visual_total += line_len.max(1).div_ceil(width); } (visual_cursor, visual_total.max(1)) } impl TuiApp { fn git_items(&self) -> Result> { let Some(data_dir) = &self.data_dir else { return Ok(vec![SidebarItem::Empty(self.tr("tui.noActiveProject"))]); }; let git = engine::git::GitEngine::new(data_dir); let repository = git.repository()?; if !repository.is_initialized { return Ok(vec![SidebarItem::Empty(self.tr("git.notRepository"))]); } let remote = git.remote_state()?; let branch = remote .local_branch .clone() .unwrap_or_else(|| self.tr("tui.detachedHead")); let mut items = vec![SidebarItem::Header(format!( "{branch} ↑{} ↓{}", remote.ahead, remote.behind ))]; items.extend(git.status()?.into_iter().map(|file| { SidebarItem::GitFile( file.path.clone(), format!("{} {}", file.kind.code(), file.path), ) })); items.push(SidebarItem::Header(self.tr("git.history"))); items.extend(git.history(&branch)?.into_iter().map(|commit| { let marker = match commit.sync_status { engine::git::SyncStatus::LocalOnly => "↑", engine::git::SyncStatus::RemoteOnly => "↓", engine::git::SyncStatus::Both => " ", }; let short = commit.hash.chars().take(8).collect::(); SidebarItem::GitCommit( commit.hash, format!("{marker} {short} {}", commit.subject.unwrap_or_default()), ) })); if !items.iter().any(SidebarItem::selectable) { items.push(SidebarItem::Empty(self.tr("tui.workingTreeClean"))); } Ok(items) } fn open_git_file(&mut self, path: &str) -> Result<()> { let patch = engine::git::GitEngine::new(self.data_dir()?) .file_diff(path)? .patch; self.output = vec![truncate_bytes(&patch, MAX_DIFF_BYTES, self.locale)]; self.panel = Panel::Git; self.scroll = 0; Ok(()) } fn open_git_commit(&mut self, hash: &str) -> Result<()> { let diff = engine::git::GitEngine::new(self.data_dir()?).commit_diff(hash)?; self.output = vec![truncate_bytes(&diff, MAX_DIFF_BYTES, self.locale)]; self.panel = Panel::Git; self.scroll = 0; Ok(()) } fn git_pull(&mut self) -> Result<()> { if !self.require_git()? { return Ok(()); } if self.airplane { self.status = self.tr("tui.airplaneGitPullBlocked"); return Ok(()); } let data_dir = self.data_dir()?.to_owned(); let locale = self.locale; let label = self.tr("tui.taskGitPull"); self.queue_task(&label, move || { let mut output = Vec::new(); let result = engine::git::GitEngine::new(&data_dir) .pull(|| false, |line| output.push(line.text))?; output.push(result.output); Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, "tui.gitPullComplete", &[("output", &output.join("\n"))], ), panel: Some(Panel::Git), reload: true, }) }); Ok(()) } fn git_push(&mut self) -> Result<()> { if !self.require_git()? { return Ok(()); } if self.airplane { self.status = self.tr("tui.airplaneGitPushBlocked"); return Ok(()); } let data_dir = self.data_dir()?.to_owned(); let locale = self.locale; let label = self.tr("tui.taskGitPush"); self.queue_task(&label, move || { let mut output = Vec::new(); let result = engine::git::GitEngine::new(&data_dir) .push(|| false, |line| output.push(line.text))?; output.push(result.output); Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, "tui.gitPushComplete", &[("output", &output.join("\n"))], ), panel: Some(Panel::Git), reload: true, }) }); Ok(()) } fn require_git(&mut self) -> Result { let ready = engine::git::GitEngine::new(self.data_dir()?) .repository()? .is_initialized; if !ready { self.status = self.tr("git.notRepository"); } Ok(ready) } fn run_command(&mut self, command: &str) -> Result<()> { let command = if command.is_empty() { COMMANDS.first().map(|command| command.id).unwrap_or("") } else if command == "?" { command } else { self.command_id(command).unwrap_or(command) }; if command == "?" { self.panel = Panel::Help; self.focus = Focus::Editor; return Ok(()); } let project_id = self.project_id()?.to_owned(); let data_dir = self.data_dir()?.to_owned(); let database_path = self.host.database_path().to_owned(); match command { "metadata-diff" => { let locale = self.locale; let label = self.tr("tui.commandMetadataDiff"); self.queue_task(&label, move || { let db = Database::open(&database_path)?; Ok(BackgroundResult { status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"), panel: Some(Panel::Report { title: bds_core::i18n::translate(locale, "menu.item.metadataDiff"), body: metadata_report_body(db.conn(), &data_dir, &project_id, locale)?, action: ReportAction::MetadataDiff, }), reload: false, }) }); } "validate-site" => { let locale = self.locale; let label = self.tr("tui.commandValidateSite"); self.queue_task(&label, move || { let db = Database::open(&database_path)?; Ok(BackgroundResult { status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"), panel: Some(Panel::Report { title: bds_core::i18n::translate(locale, "menu.item.validateSite"), body: site_validation_body(db.conn(), &data_dir, &project_id, locale)?, action: ReportAction::SiteValidation, }), reload: false, }) }); } "force-render" => { let locale = self.locale; let label = self.tr("tui.commandForceRender"); self.queue_task(&label, move || { let db = Database::open(&database_path)?; let metadata = engine::meta::read_project_json(&data_dir)?; let posts = published_sources(db.conn(), &data_dir, &project_id)?; let report = engine::generation::generate_starter_site( db.conn(), &data_dir.join("html"), &project_id, &metadata, &posts, metadata.main_language.as_deref().unwrap_or("en"), )?; Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, "tui.filesRendered", &[("count", &report.written_paths.len().to_string())], ), panel: None, reload: false, }) }); } "rebuild-database" => { let locale = self.locale; let label = self.tr("tui.commandRebuildDatabase"); self.queue_task(&label, move || { let db = Database::open(&database_path)?; let report = engine::rebuild::rebuild_from_filesystem( db.conn(), &data_dir, &project_id, )?; let count = report.posts_created + report.posts_updated + report.media_created + report.media_updated + report.templates_created + report.templates_updated + report.scripts_created + report.scripts_updated; Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, "tui.itemsRebuilt", &[("count", &count.to_string())], ), panel: None, reload: true, }) }); } "reindex-search" => { let locale = self.locale; let label = self.tr("tui.commandReindexSearch"); self.queue_task(&label, move || { let db = Database::open(&database_path)?; let report = engine::search::reindex_project(db.conn(), &project_id, None)?; Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, "tui.postsReindexed", &[("count", &report.posts_indexed.to_string())], ), panel: None, reload: false, }) }); } "validate-translations-gui" | "find-duplicates-gui" => { self.status = self.tr("tui.commandDesktopOnly") } "upload-site" if self.airplane => self.status = self.tr("tui.airplaneUploadBlocked"), "upload-site" => self.status = self.tr("tui.uploadRequiresPublishingSettings"), "browser-preview-url" => { self.status = format!("file://{}", data_dir.join("html/index.html").display()) } _ => self.status = self.tr_with("tui.unknownCommand", &[("command", command)]), } Ok(()) } fn apply_report(&mut self, action: ReportAction) -> Result<()> { let db = self.database()?; let project_id = self.project_id()?.to_owned(); let data_dir = self.data_dir()?.to_owned(); match action { ReportAction::MetadataDiff => { let report = engine::metadata_diff::compute_metadata_diff( db.conn(), &data_dir, &project_id, )?; for item in &report.diffs { engine::metadata_diff::repair_metadata_diff_item( db.conn(), &data_dir, &project_id, engine::metadata_diff::RepairDirection::FileToDatabase, item, )?; } for orphan in &report.orphans { if orphan.reason == "file_without_db_entry" { engine::metadata_diff::import_orphan_file( db.conn(), &data_dir, &project_id, orphan, )?; } } self.status = self.tr_with( "tui.metadataChangesApplied", &[( "count", &(report.diffs.len() + report.orphans.len()).to_string(), )], ); self.reload()?; } ReportAction::SiteValidation => { let validation = engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)?; let metadata = engine::meta::read_project_json(&data_dir)?; let posts = published_sources(db.conn(), &data_dir, &project_id)?; let sections = engine::generation::sections_from_validation_report(&validation); let report = engine::generation::apply_validation_sections( db.conn(), &data_dir.join("html"), &project_id, &metadata, &posts, §ions, )?; self.status = self.tr_with( "tui.validationApplied", &[ ("written", &report.written_paths.len().to_string()), ("removed", &report.deleted_paths.len().to_string()), ], ); } } self.panel = Panel::Welcome; self.focus = Focus::Sidebar; Ok(()) } } fn truncate_bytes(value: &str, limit: usize, locale: UiLocale) -> String { if value.len() <= limit { return value.into(); } let mut end = limit; while !value.is_char_boundary(end) { end -= 1; } format!( "{}\n\n{}", &value[..end], bds_core::i18n::translate(locale, "tui.diffTruncated") ) } fn published_sources( conn: &bds_core::db::DbConnection, data_dir: &Path, project_id: &str, ) -> Result> { let mut sources = Vec::new(); for post in bds_core::db::queries::post::list_posts_by_project(conn, project_id)? { if let Some(source) = engine::generation::load_published_post_source(data_dir, post)? { sources.push(source); } } Ok(sources) } fn metadata_report_body( conn: &bds_core::db::DbConnection, data_dir: &Path, project_id: &str, locale: UiLocale, ) -> Result { let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?; let mut lines = report .diffs .iter() .map(|item| { bds_core::i18n::translate_with( locale, "tui.metadataDiffFields", &[ ("entity", &item.entity_type), ("path", &item.file_path), ("count", &item.fields.len().to_string()), ], ) }) .collect::>(); lines.extend(report.orphans.iter().map(|item| { bds_core::i18n::translate_with( locale, "tui.metadataOrphan", &[("path", &item.file_path), ("reason", &item.reason)], ) })); lines.extend(report.errors.iter().map(|error| { bds_core::i18n::translate_with(locale, "tui.metadataError", &[("error", error)]) })); if lines.is_empty() { lines.push(bds_core::i18n::translate( locale, "tui.noMetadataDifferences", )); } Ok(lines.join("\n")) } fn site_validation_body( conn: &bds_core::db::DbConnection, data_dir: &Path, project_id: &str, locale: UiLocale, ) -> Result { let report = engine::validate_site::validate_site(conn, data_dir, project_id)?; Ok(bds_core::i18n::translate_with( locale, "tui.siteValidationReport", &[ ("missing", &report.missing_pages.join("\n")), ("extra", &report.extra_pages.join("\n")), ("stale", &report.stale_pages.join("\n")), ], )) } impl TuiApp { pub fn render(&mut self, frame: &mut Frame<'_>) { let area = frame.area(); self.resize(area.width, area.height); self.render_buffer(area, frame.buffer_mut()); } pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) { let background = Style::default() .bg(Color::Rgb(24, 26, 32)) .fg(Color::Rgb(210, 214, 224)); buffer.set_style(area, background); let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), ]) .split(area); let body = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(SIDEBAR_WIDTH.min(area.width.saturating_sub(8))), Constraint::Min(8), ]) .split(vertical[0]); self.render_sidebar(body[0], buffer); self.render_main(body[1], buffer); let output = self .output .last() .map(|value| value.lines().last().unwrap_or("")) .unwrap_or(""); Paragraph::new(output) .style( Style::default() .bg(Color::Rgb(35, 38, 46)) .fg(Color::Rgb(153, 162, 184)), ) .render(vertical[1], buffer); self.render_status(vertical[2], buffer); if let Some(prompt) = &self.prompt { self.render_prompt_overlay(area, prompt, buffer); } if let Some(overlay) = &self.overlay { self.render_overlay(area, overlay, buffer); } } fn render_sidebar(&self, area: Rect, buffer: &mut Buffer) { let filter = self .filters .get(&self.view) .filter(|value| !value.is_empty()) .map(|value| format!(" /{value}")) .unwrap_or_default(); let title = format!(" {}{} ", self.view_title(), filter); let inner = Block::default() .title(title) .borders(Borders::RIGHT) .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) .inner(area); Block::default() .title(format!(" {}{} ", self.view_title(), filter)) .borders(Borders::RIGHT) .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) .render(area, buffer); let rows = self .items .iter() .enumerate() .map(|(index, item)| { let style = if index == self.selected_index && self.focus == Focus::Sidebar { Style::default() .bg(Color::Rgb(54, 61, 78)) .fg(Color::White) .add_modifier(Modifier::BOLD) } else if matches!(item, SidebarItem::Header(_)) { Style::default() .fg(Color::Rgb(126, 137, 160)) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Rgb(196, 201, 214)) }; let prefix = if matches!(item, SidebarItem::Header(_)) { "" } else { " " }; Line::styled(format!("{prefix}{}", item.label(self.locale)), style) }) .collect::>(); Paragraph::new(rows).render(inner, buffer); } fn render_main(&self, area: Rect, buffer: &mut Buffer) { if let Some(editor) = &self.editor { self.render_editor(area, editor, buffer); return; } if let Panel::MediaPreview { title, path } = &self.panel && let Some(protocol) = &self.image_protocol { let block = Block::default() .title(format!(" {title} — {} ", path.display())) .padding(ratatui::widgets::Padding::new(1, 1, 1, 1)); let inner = block.inner(area); block.render(area, buffer); TerminalImage::new(protocol).render(inner, buffer); return; } let (title, text) = match &self.panel { Panel::Welcome => ( self.project .as_ref() .map(|project| project.name.clone()) .unwrap_or_else(|| { self.tr(if self.remote { "remoteTerminal.serverTitle" } else { "remoteTerminal.localTitle" }) }), self.tr("tui.help"), ), Panel::MediaPreview { title, path } => ( title.clone(), self.tr_with( "tui.imagePreview", &[ ("path", &path.display().to_string()), ( "dimensions", &image_dimensions(path, self.locale) .unwrap_or_else(|| self.tr("modal.postGallery.unavailable")), ), ], ), ), Panel::Settings(section) => (self.tr(section.key()), self.settings_text()), Panel::Tags(section) => (self.tr(section.key()), self.tags_text(*section)), Panel::Git => ( self.tr("git.diff"), self.output .last() .cloned() .unwrap_or_else(|| self.git_diff_text()), ), Panel::Report { title, body, .. } => ( title.clone(), format!("{body}\n\n{}", self.tr("tui.applyCancel")), ), Panel::Help => ( self.tr("tui.commandsTitle"), self.command_names().join("\n"), ), }; Paragraph::new(text) .block( Block::default() .title(format!(" {title} ")) .borders(Borders::NONE) .padding(ratatui::widgets::Padding::new(2, 2, 1, 1)), ) .wrap(Wrap { trim: false }) .scroll((self.scroll, 0)) .render(area, buffer); } fn render_editor(&self, area: Rect, editor: &Editor, buffer: &mut Buffer) { let dirty = if editor.buffer.is_dirty() { " ●" } else { "" }; let language = editor.post_language.as_deref().unwrap_or(editor.syntax); let mode = if editor.mode == EditorMode::Preview { self.tr("editor.modePreview") } else { language.to_owned() }; let title = format!(" {} — {}{} ", editor.title, mode, dirty); let inner = Block::default() .title(title.clone()) .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) .inner(area); Block::default() .title(title) .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) .render(area, buffer); if editor.mode == EditorMode::Preview { let source = editor.buffer.text(); Paragraph::new(tui_markdown::from_str(&source)) .wrap(Wrap { trim: false }) .scroll((editor.buffer.scroll_offset() as u16, 0)) .render(inner, buffer); return; } let source = editor.buffer.text(); let highlighter = bds_editor::highlighter(); let extension = match editor.syntax { "markdown" => "md", other => other, }; let syntax = highlighter.syntax_for_extension(extension); let cursor_line = editor.buffer.cursor().0; let lines = highlighter .highlight_lines(&source, syntax) .into_iter() .enumerate() .map(|(index, spans)| { let row_style = if index == cursor_line { Style::default().bg(Color::Rgb(39, 43, 53)) } else { Style::default() }; Line::from( spans .into_iter() .map(|(style, value)| { Span::styled( value.trim_end_matches('\n').to_owned(), Style::default() .fg(Color::Rgb( style.foreground.r, style.foreground.g, style.foreground.b, )) .bg(row_style.bg.unwrap_or(Color::Reset)), ) }) .collect::>(), ) .style(row_style) }) .collect::>(); Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) .scroll((editor.buffer.scroll_offset() as u16, 0)) .render(inner, buffer); } fn render_status(&self, area: Rect, buffer: &mut Buffer) { let transport = bds_core::i18n::translate_with( self.locale, "tui.status", &[ ( "transport", &self.tr(if self.remote { "tui.remote" } else { "tui.local" }), ), ("locale", self.locale.code()), ], ); let text = if let Some(prompt) = &self.prompt { let marker = match prompt.kind { PromptKind::Search => "/".into(), PromptKind::Command => ":".into(), PromptKind::ProjectPath => self.tr("tui.promptOpen"), PromptKind::Commit => self.tr("tui.promptCommit"), PromptKind::ConfirmDeleteTag(_) => self.tr("tui.promptConfirm"), _ => self.tr("tui.promptValue"), }; if matches!(prompt.kind, PromptKind::Search | PromptKind::Command) { format!("{marker}{}", prompt.value) } else { format!("{marker} {}", prompt.value) } } else if !self.status.is_empty() { format!("{transport} · {}", self.status) } else { transport }; Paragraph::new(text) .style( Style::default() .bg(Color::Rgb(47, 52, 64)) .fg(Color::Rgb(230, 233, 240)), ) .render(area, buffer); } fn render_overlay(&self, area: Rect, overlay: &Overlay, buffer: &mut Buffer) { let popup = centered( area, 64.min(area.width.saturating_sub(2)), 16.min(area.height.saturating_sub(2)), ); Clear.render(popup, buffer); let (title, lines) = match overlay { Overlay::ConfirmDiscard => ( self.tr("tui.unsavedTitle"), vec![Line::from(self.tr("tui.unsavedPrompt"))], ), Overlay::Projects { selected } => { let projects = self .database() .and_then(|db| engine::project::list_projects(db.conn()).map_err(Into::into)) .unwrap_or_default(); ( self.tr("remoteTerminal.availableProjects"), projects .into_iter() .enumerate() .map(|(index, project)| { Line::styled( format!( "{} {}", if project.is_active { "●" } else { " " }, project.name ), if index == *selected { Style::default() .bg(Color::Rgb(54, 61, 78)) .add_modifier(Modifier::BOLD) } else { Style::default() }, ) }) .collect(), ) } }; Paragraph::new(lines) .block( Block::default() .title(title) .borders(Borders::ALL) .style(Style::default().bg(Color::Rgb(31, 34, 42))), ) .render(popup, buffer); } fn render_prompt_overlay(&self, area: Rect, prompt: &Prompt, buffer: &mut Buffer) { if !matches!(prompt.kind, PromptKind::Command | PromptKind::ProjectPath) { return; } let candidates = if prompt.kind == PromptKind::Command && prompt.value == "?" { self.command_names() } else { prompt.candidates.clone() }; if candidates.is_empty() && prompt.kind == PromptKind::ProjectPath { return; } let height = (candidates.len() as u16 + 2).clamp(3, area.height.saturating_sub(3).max(3)); let popup = Rect::new( area.x + 2, area.y + area.height.saturating_sub(height + 2), area.width.saturating_sub(4), height, ); Clear.render(popup, buffer); let rows = candidates .into_iter() .enumerate() .map(|(index, value)| { Line::styled( value, if index == 0 { Style::default() .bg(Color::Rgb(54, 61, 78)) .add_modifier(Modifier::BOLD) } else { Style::default() }, ) }) .collect::>(); Paragraph::new(rows) .block( Block::default() .title(if prompt.kind == PromptKind::Command { self.tr("tui.commandsTitle") } else { self.tr("tui.foldersTitle") }) .borders(Borders::ALL) .style(Style::default().bg(Color::Rgb(31, 34, 42))), ) .render(popup, buffer); } fn view_title(&self) -> String { self.tr(match self.view { TuiView::Posts => "tui.viewPosts", TuiView::Media => "tui.viewMedia", TuiView::Templates => "tui.viewTemplates", TuiView::Scripts => "tui.viewScripts", TuiView::Tags => "tui.viewTags", TuiView::Settings => "tui.viewSettings", TuiView::Git => "tui.viewGit", }) } fn settings_text(&self) -> String { let fields = self .settings_fields .iter() .enumerate() .map(|(index, field)| { format!( "{} {:<22} {}{}", if index == self.panel_index { "›" } else { " " }, field.label, field.value, if field.kind == FieldKind::ReadOnly { self.tr("tui.readOnlyMarker") } else { String::new() } ) }) .collect::>() .join("\n"); format!("{fields}\n\n{}", self.tr("tui.settingsHint")) } fn tags_text(&self, section: TagSection) -> String { let Ok(mut tags) = self.tags() else { return self.tr("tui.tagsLoadFailed"); }; let usage = self.tag_usage(); if section == TagSection::Cloud { tags.sort_by_key(|tag| { std::cmp::Reverse(*usage.get(&tag.name.to_lowercase()).unwrap_or(&0)) }); } let mut rows = tags .iter() .enumerate() .map(|(index, tag)| { format!( "{}{} {:<24} {:>4} {} {}", if index == self.panel_index { "›" } else { " " }, if self.marked_tags.contains(&tag.id) { "●" } else { " " }, tag.name, usage.get(&tag.name.to_lowercase()).unwrap_or(&0), tag.color.as_deref().unwrap_or("—"), tag.post_template_slug.as_deref().unwrap_or("—") ) }) .collect::>(); if rows.is_empty() { rows.push(self.tr("tui.noTags")); } rows.push(format!( "\n{}", self.tr(match section { TagSection::Cloud => "tui.tagCloudHint", TagSection::Manage => "tui.tagManageHint", TagSection::Merge => "tui.tagMergeHint", }) )); rows.join("\n") } fn tag_usage(&self) -> HashMap { let Ok(db) = self.database() else { return HashMap::new(); }; let Ok(project_id) = self.project_id() else { return HashMap::new(); }; let Ok(posts) = bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id) else { return HashMap::new(); }; let mut usage = HashMap::new(); for tag in posts.into_iter().flat_map(|post| post.tags) { *usage.entry(tag.to_lowercase()).or_default() += 1; } usage } fn git_diff_text(&self) -> String { let Ok(data_dir) = self.data_dir() else { return self.tr("tui.noActiveProject"); }; match engine::git::GitEngine::new(data_dir).diff() { Ok(diff) => truncate_bytes( &self.tr_with( "tui.gitDiffBody", &[("staged", &diff.staged), ("unstaged", &diff.unstaged)], ), MAX_DIFF_BYTES, self.locale, ), Err(error) => error.to_string(), } } fn tr(&self, key: &str) -> String { bds_core::i18n::translate(self.locale, key) } fn tr_with(&self, key: &str, params: &[(&str, &str)]) -> String { bds_core::i18n::translate_with(self.locale, key, params) } fn command_names(&self) -> Vec { COMMANDS .iter() .map(|command| self.tr(command.key)) .collect() } fn command_id(&self, name: &str) -> Option<&'static str> { COMMANDS .iter() .find(|command| command.id == name || self.tr(command.key) == name) .map(|command| command.id) } } fn centered(area: Rect, width: u16, height: u16) -> Rect { Rect::new( area.x + area.width.saturating_sub(width) / 2, area.y + area.height.saturating_sub(height) / 2, width, height, ) } fn image_dimensions(path: &Path, locale: UiLocale) -> Option { let data = fs::read(path).ok()?; if data.starts_with(b"\x89PNG") && data.len() >= 24 { return Some(bds_core::i18n::translate_with( locale, "tui.imageDimensions", &[ ("format", "PNG"), ( "width", &u32::from_be_bytes(data[16..20].try_into().ok()?).to_string(), ), ( "height", &u32::from_be_bytes(data[20..24].try_into().ok()?).to_string(), ), ], )); } if data.starts_with(b"GIF8") && data.len() >= 10 { return Some(bds_core::i18n::translate_with( locale, "tui.imageDimensions", &[ ("format", "GIF"), ( "width", &u16::from_le_bytes(data[6..8].try_into().ok()?).to_string(), ), ( "height", &u16::from_le_bytes(data[8..10].try_into().ok()?).to_string(), ), ], )); } Some(bds_core::i18n::translate_with( locale, "tui.fileBytes", &[("count", &data.len().to_string())], )) } pub fn run_local(host: ApplicationHost) -> Result<()> { ratatui::run(|terminal| -> io::Result<()> { let mut app = TuiApp::new(host, false).map_err(io::Error::other)?; loop { app.poll().map_err(io::Error::other)?; terminal.draw(|frame| app.render(frame))?; if event::poll(Duration::from_millis(100))? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { if let Some(input) = crossterm_input(key) { app.handle_input(input).map_err(io::Error::other)?; } } Event::Resize(_, _) => {} _ => {} } } if app.should_quit() { break Ok(()); } } }) .map_err(Into::into) } fn crossterm_input(key: KeyEvent) -> Option { let modifiers = key.modifiers; let key = match key.code { KeyCode::Char(value) => TuiKey::Char(value), KeyCode::Enter => TuiKey::Enter, KeyCode::Esc => TuiKey::Esc, KeyCode::Backspace => TuiKey::Backspace, KeyCode::Delete => TuiKey::Delete, KeyCode::Up => TuiKey::Up, KeyCode::Down => TuiKey::Down, KeyCode::Left => TuiKey::Left, KeyCode::Right => TuiKey::Right, KeyCode::Home => TuiKey::Home, KeyCode::End => TuiKey::End, KeyCode::PageUp => TuiKey::PageUp, KeyCode::PageDown => TuiKey::PageDown, KeyCode::Tab | KeyCode::BackTab => TuiKey::Tab, _ => return None, }; Some(TuiInput { key, modifiers: TuiModifiers { ctrl: modifiers.contains(KeyModifiers::CONTROL), shift: modifiers.contains(KeyModifiers::SHIFT), alt: modifiers.contains(KeyModifiers::ALT), }, }) } #[derive(Default)] pub(crate) struct InputDecoder { pending: Vec, } impl InputDecoder { pub(crate) fn push(&mut self, bytes: &[u8]) -> Vec { self.pending.extend_from_slice(bytes); let mut inputs = Vec::new(); while let Some(first) = self.pending.first().copied() { if first == 0x1b { if self.pending.len() == 1 { break; } let sequences: &[(&[u8], TuiKey)] = &[ (b"\x1b[A", TuiKey::Up), (b"\x1b[B", TuiKey::Down), (b"\x1b[C", TuiKey::Right), (b"\x1b[D", TuiKey::Left), (b"\x1b[H", TuiKey::Home), (b"\x1b[F", TuiKey::End), (b"\x1b[5~", TuiKey::PageUp), (b"\x1b[6~", TuiKey::PageDown), (b"\x1b[3~", TuiKey::Delete), (b"\x1b[Z", TuiKey::Tab), ]; if let Some((sequence, key)) = sequences .iter() .find(|(sequence, _)| self.pending.starts_with(sequence)) { self.pending.drain(..sequence.len()); inputs.push(TuiInput::plain(*key)); continue; } if sequences .iter() .any(|(sequence, _)| sequence.starts_with(&self.pending)) { break; } self.pending.remove(0); inputs.push(TuiInput::plain(TuiKey::Esc)); continue; } let (input, consumed) = match first { b'\r' | b'\n' => (Some(TuiInput::plain(TuiKey::Enter)), 1), b'\t' => (Some(TuiInput::plain(TuiKey::Tab)), 1), 0x7f | 0x08 => (Some(TuiInput::plain(TuiKey::Backspace)), 1), 1..=26 => ( Some(TuiInput::ctrl(TuiKey::Char((b'a' + first - 1) as char))), 1, ), _ => match std::str::from_utf8(&self.pending) { Ok(value) => { let value = value.chars().next().expect("non-empty input"); (Some(TuiInput::plain(TuiKey::Char(value))), value.len_utf8()) } Err(error) if error.error_len().is_none() => break, Err(_) => (None, 1), }, }; self.pending.drain(..consumed); if let Some(input) = input { inputs.push(input); } } inputs } pub(crate) fn flush(&mut self) -> Vec { if self.pending.as_slice() == [0x1b] { self.pending.clear(); vec![TuiInput::plain(TuiKey::Esc)] } else { Vec::new() } } } pub(crate) fn render_ansi(app: &mut TuiApp, width: u16, height: u16) -> Vec { let width = width.max(20); let height = height.max(6); let area = Rect::new(0, 0, width, height); app.resize(width, height); let mut buffer = Buffer::empty(area); app.render_buffer(area, &mut buffer); let mut output = String::from("\x1b[?25l\x1b[2J\x1b[H"); let mut last_style: Option