Implement the headless SSH server
This commit is contained in:
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
bds-core = { workspace = true }
|
||||
bds-editor = { workspace = true }
|
||||
bds-server = { workspace = true }
|
||||
iced = { workspace = true }
|
||||
muda = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
@@ -38,11 +39,12 @@ winresource = "0.1"
|
||||
product-name = "Blogging Desktop Server"
|
||||
identifier = "de.rfc1437.ruds"
|
||||
description = "A desktop application for writing and publishing static blogs."
|
||||
before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp"
|
||||
before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp -p bds-server"
|
||||
binaries = [
|
||||
{ path = "bds-ui", main = true },
|
||||
{ path = "bds-cli" },
|
||||
{ path = "bds-mcp" },
|
||||
{ path = "bds-server" },
|
||||
]
|
||||
deep-link-protocols = [{ schemes = ["ruds"] }]
|
||||
icons = [
|
||||
|
||||
@@ -200,6 +200,13 @@ pub enum Message {
|
||||
ShowModal(modal::ModalState),
|
||||
DismissModal,
|
||||
ConfirmModal(modal::ConfirmAction),
|
||||
RemoteTargetChanged(String),
|
||||
RemoteConnectRequested,
|
||||
RemoteConnected(Result<(Arc<bds_server::DesktopClient>, Vec<Project>), String>),
|
||||
RemoteProjectSelected(String),
|
||||
RemoteOpenProjectRequested,
|
||||
RemoteProjectOpened(Result<Project, String>),
|
||||
RemoteDisconnectRequested,
|
||||
FindQueryChanged(String),
|
||||
ReplaceQueryChanged(String),
|
||||
FindNext,
|
||||
@@ -933,6 +940,11 @@ pub struct BdsApp {
|
||||
|
||||
// Modal
|
||||
active_modal: Option<modal::ModalState>,
|
||||
remote_client: Option<Arc<bds_server::DesktopClient>>,
|
||||
remote_projects: Vec<Project>,
|
||||
remote_project: Option<Project>,
|
||||
remote_display_name: Option<String>,
|
||||
remote_previous_locale: Option<UiLocale>,
|
||||
|
||||
// Local preview
|
||||
preview_session: Option<PreviewSession>,
|
||||
@@ -1048,6 +1060,7 @@ impl BdsApp {
|
||||
registry.set_enabled(MenuAction::Find, false);
|
||||
registry.set_enabled(MenuAction::Replace, false);
|
||||
registry.set_enabled(MenuAction::OpenInBrowser, false);
|
||||
registry.set_enabled(MenuAction::DisconnectServer, false);
|
||||
let chat_conversations = db
|
||||
.as_ref()
|
||||
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
|
||||
@@ -1105,6 +1118,11 @@ impl BdsApp {
|
||||
toasts: Vec::new(),
|
||||
active_modal: search_index_rebuild_required
|
||||
.then_some(modal::ModalState::SearchIndexRepair),
|
||||
remote_client: None,
|
||||
remote_projects: Vec::new(),
|
||||
remote_project: None,
|
||||
remote_display_name: None,
|
||||
remote_previous_locale: None,
|
||||
preview_session: None,
|
||||
mcp_server,
|
||||
embedded_preview: None,
|
||||
@@ -1187,6 +1205,11 @@ impl BdsApp {
|
||||
theme_badge: String::from("pico"),
|
||||
toasts: Vec::new(),
|
||||
active_modal: None,
|
||||
remote_client: None,
|
||||
remote_projects: Vec::new(),
|
||||
remote_project: None,
|
||||
remote_display_name: None,
|
||||
remote_previous_locale: None,
|
||||
preview_session: None,
|
||||
mcp_server: None,
|
||||
embedded_preview: None,
|
||||
@@ -2694,6 +2717,212 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::RemoteTargetChanged(value) => {
|
||||
if let Some(modal::ModalState::RemoteConnection { target, error, .. }) =
|
||||
self.active_modal.as_mut()
|
||||
{
|
||||
*target = value;
|
||||
*error = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RemoteConnectRequested => {
|
||||
let target = match self.active_modal.as_mut() {
|
||||
Some(modal::ModalState::RemoteConnection {
|
||||
target,
|
||||
connecting,
|
||||
error,
|
||||
..
|
||||
}) => {
|
||||
*connecting = true;
|
||||
*error = None;
|
||||
target.clone()
|
||||
}
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let parsed = match bds_server::RemoteTarget::parse(&target) {
|
||||
Ok(target) => target,
|
||||
Err(error) => {
|
||||
if let Some(modal::ModalState::RemoteConnection {
|
||||
connecting,
|
||||
error: shown,
|
||||
..
|
||||
}) = self.active_modal.as_mut()
|
||||
{
|
||||
*connecting = false;
|
||||
let _ = error;
|
||||
*shown = Some(t(self.ui_locale, "remoteConnection.invalidTarget"));
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let data_root = bds_core::util::application_data_dir();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let client = Arc::new(
|
||||
bds_server::DesktopClient::connect(parsed, &data_root)
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
let projects =
|
||||
client.list_projects().map_err(|error| error.to_string())?;
|
||||
Ok((client, projects))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
Err(format!("remote connection task failed: {error}"))
|
||||
})
|
||||
},
|
||||
Message::RemoteConnected,
|
||||
)
|
||||
}
|
||||
Message::RemoteConnected(result) => {
|
||||
match result {
|
||||
Ok((client, projects)) => {
|
||||
let server_locale =
|
||||
bds_core::i18n::normalize_language(client.server_locale());
|
||||
self.remote_projects.clone_from(&projects);
|
||||
self.remote_client = Some(client);
|
||||
if let Some(modal::ModalState::RemoteConnection {
|
||||
connecting,
|
||||
connected,
|
||||
error,
|
||||
projects: shown_projects,
|
||||
selected_project_id,
|
||||
..
|
||||
}) = self.active_modal.as_mut()
|
||||
{
|
||||
*connecting = false;
|
||||
*connected = true;
|
||||
*error = None;
|
||||
shown_projects.clone_from(&projects);
|
||||
*selected_project_id =
|
||||
projects.first().map(|project| project.id.clone());
|
||||
}
|
||||
self.menu_registry
|
||||
.set_enabled(MenuAction::DisconnectServer, true);
|
||||
if self.remote_previous_locale.is_none() {
|
||||
self.remote_previous_locale = Some(self.ui_locale);
|
||||
}
|
||||
self.apply_ui_locale(server_locale);
|
||||
}
|
||||
Err(connection_error) => {
|
||||
let remains_connected = self.remote_client.is_some();
|
||||
if let Some(modal::ModalState::RemoteConnection {
|
||||
connecting,
|
||||
connected,
|
||||
error,
|
||||
..
|
||||
}) = self.active_modal.as_mut()
|
||||
{
|
||||
*connecting = false;
|
||||
*connected = remains_connected;
|
||||
*error = Some(tw(
|
||||
self.ui_locale,
|
||||
"remoteConnection.failed",
|
||||
&[("reason", &connection_error)],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RemoteProjectSelected(project_id) => {
|
||||
if let Some(modal::ModalState::RemoteConnection {
|
||||
selected_project_id,
|
||||
error,
|
||||
..
|
||||
}) = self.active_modal.as_mut()
|
||||
{
|
||||
*selected_project_id = Some(project_id);
|
||||
*error = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RemoteOpenProjectRequested => {
|
||||
let project_id = match self.active_modal.as_mut() {
|
||||
Some(modal::ModalState::RemoteConnection {
|
||||
selected_project_id: Some(project_id),
|
||||
connecting,
|
||||
error,
|
||||
..
|
||||
}) => {
|
||||
*connecting = true;
|
||||
*error = None;
|
||||
project_id.clone()
|
||||
}
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let Some(client) = self.remote_client.clone() else {
|
||||
return Task::done(Message::RemoteProjectOpened(Err(t(
|
||||
self.ui_locale,
|
||||
"remoteConnection.connectionLost",
|
||||
))));
|
||||
};
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
client
|
||||
.open_project(&project_id)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("remote project task failed: {error}")))
|
||||
},
|
||||
Message::RemoteProjectOpened,
|
||||
)
|
||||
}
|
||||
Message::RemoteProjectOpened(result) => {
|
||||
match result {
|
||||
Ok(project) => {
|
||||
let target = self
|
||||
.remote_client
|
||||
.as_ref()
|
||||
.map(|client| client.target().label())
|
||||
.unwrap_or_default();
|
||||
self.remote_display_name = Some(format!("{} — {target}", project.name));
|
||||
self.remote_project = Some(project);
|
||||
self.active_modal = None;
|
||||
self.notify(
|
||||
ToastLevel::Success,
|
||||
&t(self.ui_locale, "remoteConnection.opened"),
|
||||
);
|
||||
}
|
||||
Err(open_error) => {
|
||||
if let Some(modal::ModalState::RemoteConnection {
|
||||
connecting, error, ..
|
||||
}) = self.active_modal.as_mut()
|
||||
{
|
||||
*connecting = false;
|
||||
*error = Some(tw(
|
||||
self.ui_locale,
|
||||
"remoteConnection.openFailed",
|
||||
&[("reason", &open_error)],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RemoteDisconnectRequested => {
|
||||
if let Some(client) = self.remote_client.take() {
|
||||
client.close();
|
||||
}
|
||||
self.remote_projects.clear();
|
||||
self.remote_project = None;
|
||||
self.remote_display_name = None;
|
||||
self.active_modal = None;
|
||||
self.menu_registry
|
||||
.set_enabled(MenuAction::DisconnectServer, false);
|
||||
if let Some(locale) = self.remote_previous_locale.take() {
|
||||
self.apply_ui_locale(locale);
|
||||
}
|
||||
self.notify(
|
||||
ToastLevel::Info,
|
||||
&t(self.ui_locale, "remoteConnection.disconnected"),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Message::FindQueryChanged(value) => {
|
||||
if let Some(modal::ModalState::FindReplace { query, .. }) =
|
||||
self.active_modal.as_mut()
|
||||
@@ -3095,7 +3324,11 @@ impl BdsApp {
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
|
||||
let active_name = self.remote_display_name.as_deref().or_else(|| {
|
||||
self.active_project
|
||||
.as_ref()
|
||||
.map(|project| project.name.as_str())
|
||||
});
|
||||
let active_post_filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &self.page_filter,
|
||||
_ => &self.post_filter,
|
||||
@@ -3615,6 +3848,47 @@ impl BdsApp {
|
||||
}
|
||||
|
||||
fn process_domain_events(&mut self) -> Task<Message> {
|
||||
let remote_messages = self
|
||||
.remote_client
|
||||
.as_ref()
|
||||
.map(|client| client.drain_events())
|
||||
.unwrap_or_default();
|
||||
for message in remote_messages {
|
||||
match message {
|
||||
bds_server::protocol::ServerMessage::Event { sequence, event } => {
|
||||
self.add_output(&tw(
|
||||
self.ui_locale,
|
||||
"remoteConnection.eventReceived",
|
||||
&[
|
||||
("sequence", &sequence.to_string()),
|
||||
("event", &format!("{event:?}")),
|
||||
],
|
||||
));
|
||||
}
|
||||
bds_server::protocol::ServerMessage::Tasks { tasks, .. } => {
|
||||
self.add_output(&tw(
|
||||
self.ui_locale,
|
||||
"remoteConnection.taskUpdate",
|
||||
&[("count", &tasks.len().to_string())],
|
||||
));
|
||||
}
|
||||
bds_server::protocol::ServerMessage::Error { code, message, .. } => {
|
||||
self.notify(ToastLevel::Error, &message);
|
||||
if remote_error_closes_connection(&code) {
|
||||
self.remote_client = None;
|
||||
self.remote_project = None;
|
||||
self.remote_projects.clear();
|
||||
self.remote_display_name = None;
|
||||
self.menu_registry
|
||||
.set_enabled(MenuAction::DisconnectServer, false);
|
||||
if let Some(locale) = self.remote_previous_locale.take() {
|
||||
self.apply_ui_locale(locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
bds_server::protocol::ServerMessage::Response { .. } => {}
|
||||
}
|
||||
}
|
||||
if let Some(db) = &self.db {
|
||||
let _ = engine::cli_sync::poll_notifications(db.conn());
|
||||
}
|
||||
@@ -3849,6 +4123,26 @@ impl BdsApp {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::ConnectServer => {
|
||||
let target = self
|
||||
.remote_client
|
||||
.as_ref()
|
||||
.map(|client| client.target().label())
|
||||
.unwrap_or_default();
|
||||
self.active_modal = Some(modal::ModalState::RemoteConnection {
|
||||
target,
|
||||
connecting: false,
|
||||
connected: self.remote_client.is_some(),
|
||||
error: None,
|
||||
projects: self.remote_projects.clone(),
|
||||
selected_project_id: self
|
||||
.remote_project
|
||||
.as_ref()
|
||||
.map(|project| project.id.clone()),
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::DisconnectServer => Task::done(Message::RemoteDisconnectRequested),
|
||||
// Edit
|
||||
MenuAction::Find => {
|
||||
self.active_modal = Some(modal::ModalState::FindReplace {
|
||||
@@ -5279,6 +5573,8 @@ impl BdsApp {
|
||||
),
|
||||
);
|
||||
}
|
||||
self.menu_registry
|
||||
.set_enabled(MenuAction::DisconnectServer, self.remote_client.is_some());
|
||||
}
|
||||
|
||||
// ── Editor save/publish helpers ──
|
||||
@@ -8652,16 +8948,21 @@ fn language_label(locale: UiLocale, code: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_error_closes_connection(code: &str) -> bool {
|
||||
code == "connection_lost"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
|
||||
PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl,
|
||||
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
|
||||
save_editor_settings_state_impl, save_script_editor_state_impl,
|
||||
save_template_editor_state_impl,
|
||||
remote_error_closes_connection, save_editor_settings_state_impl,
|
||||
save_script_editor_state_impl, save_template_editor_state_impl,
|
||||
};
|
||||
use crate::i18n::t;
|
||||
use crate::platform::menu::MenuAction;
|
||||
use crate::state::ToastLevel;
|
||||
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
@@ -8735,6 +9036,49 @@ mod tests {
|
||||
(db, project, tempdir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_connection_menu_opens_localized_selection_and_failure_states() {
|
||||
let (db, project, temp) = setup();
|
||||
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
||||
let _ = app.dispatch_menu_action(MenuAction::ConnectServer);
|
||||
assert!(matches!(
|
||||
app.active_modal,
|
||||
Some(modal::ModalState::RemoteConnection {
|
||||
connected: false,
|
||||
ref projects,
|
||||
..
|
||||
}) if projects.is_empty()
|
||||
));
|
||||
|
||||
let _ = app.update(Message::RemoteTargetChanged("not-a-target".into()));
|
||||
let _ = app.update(Message::RemoteConnectRequested);
|
||||
assert!(matches!(
|
||||
app.active_modal,
|
||||
Some(modal::ModalState::RemoteConnection {
|
||||
connecting: false,
|
||||
error: Some(ref error),
|
||||
..
|
||||
}) if error == &t(UiLocale::En, "remoteConnection.invalidTarget")
|
||||
));
|
||||
|
||||
let _ = app.update(Message::RemoteConnected(Err("refused".into())));
|
||||
assert!(matches!(
|
||||
app.active_modal,
|
||||
Some(modal::ModalState::RemoteConnection {
|
||||
connected: false,
|
||||
error: Some(ref error),
|
||||
..
|
||||
}) if error.contains("refused") && error != "refused"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_transport_loss_closes_an_open_remote_session() {
|
||||
assert!(remote_error_closes_connection("connection_lost"));
|
||||
assert!(!remote_error_closes_connection("sync_watcher_error"));
|
||||
assert!(!remote_error_closes_connection("engine_error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
|
||||
let (db, project, temp) = setup();
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
|
||||
use bds_server::boot::{BootMode, Platform};
|
||||
use bds_ui::BdsApp;
|
||||
use bds_ui::components::inputs;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let resolved = BootMode::resolve(std::env::var("BDS_MODE").ok().as_deref());
|
||||
let env = std::env::vars().collect();
|
||||
let mode = resolved.effective(current_platform(), &env);
|
||||
if mode != BootMode::Desktop {
|
||||
if mode != resolved {
|
||||
let locale = bds_core::i18n::detect_os_locale();
|
||||
eprintln!(
|
||||
"{}",
|
||||
bds_core::i18n::translate(locale, "remoteTerminal.headlessFallback")
|
||||
);
|
||||
}
|
||||
let config = bds_server::ServerConfig::from_environment(
|
||||
bds_core::util::application_database_path(),
|
||||
bds_core::util::application_data_dir(),
|
||||
)?;
|
||||
return bds_server::run_headless(mode, config);
|
||||
}
|
||||
|
||||
let icon =
|
||||
iced::window::icon::from_file_data(include_bytes!("../assets/app-icons/bds.png"), None)
|
||||
.expect("bundled application icon must be valid");
|
||||
@@ -16,5 +35,17 @@ fn main() -> iced::Result {
|
||||
icon: Some(icon),
|
||||
..Default::default()
|
||||
})
|
||||
.run_with(BdsApp::new)
|
||||
.run_with(BdsApp::new)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_platform() -> Platform {
|
||||
#[cfg(target_os = "macos")]
|
||||
return Platform::MacOs;
|
||||
#[cfg(windows)]
|
||||
return Platform::Windows;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
return Platform::Unix;
|
||||
#[allow(unreachable_code)]
|
||||
Platform::Windows
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ pub enum MenuAction {
|
||||
Save,
|
||||
OpenInBrowser,
|
||||
OpenDataFolder,
|
||||
ConnectServer,
|
||||
DisconnectServer,
|
||||
// Edit (custom items only)
|
||||
Find,
|
||||
Replace,
|
||||
@@ -60,6 +62,8 @@ impl MenuAction {
|
||||
MenuAction::Save,
|
||||
MenuAction::OpenInBrowser,
|
||||
MenuAction::OpenDataFolder,
|
||||
MenuAction::ConnectServer,
|
||||
MenuAction::DisconnectServer,
|
||||
MenuAction::Find,
|
||||
MenuAction::Replace,
|
||||
MenuAction::EditPreferences,
|
||||
@@ -95,6 +99,8 @@ impl MenuAction {
|
||||
"save" => Self::Save,
|
||||
"open_in_browser" => Self::OpenInBrowser,
|
||||
"open_data_folder" => Self::OpenDataFolder,
|
||||
"connect_server" => Self::ConnectServer,
|
||||
"disconnect_server" => Self::DisconnectServer,
|
||||
"find" => Self::Find,
|
||||
"replace" => Self::Replace,
|
||||
"edit_preferences" => Self::EditPreferences,
|
||||
@@ -133,6 +139,8 @@ impl MenuAction {
|
||||
Self::Save => "menu.item.save",
|
||||
Self::OpenInBrowser => "menu.item.openInBrowser",
|
||||
Self::OpenDataFolder => "menu.item.openDataFolder",
|
||||
Self::ConnectServer => "menu.item.connectServer",
|
||||
Self::DisconnectServer => "menu.item.disconnectServer",
|
||||
Self::Find => "menu.item.find",
|
||||
Self::Replace => "menu.item.replace",
|
||||
Self::EditPreferences => "menu.item.editPreferences",
|
||||
@@ -342,6 +350,9 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::ConnectServer, locale, None));
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::DisconnectServer, locale, None));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
|
||||
|
||||
// -- Edit --
|
||||
@@ -523,16 +534,18 @@ pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iced subscription that polls muda `MenuEvent`s each frame.
|
||||
/// Iced subscription that polls muda `MenuEvent`s independently of window input.
|
||||
///
|
||||
/// Produces `Message::MenuEvent(MenuId)` so the app can look up the
|
||||
/// `MenuAction` via its `MenuRegistry`.
|
||||
/// `MenuAction` via its `MenuRegistry`. Native menu selection does not itself
|
||||
/// produce an Iced window event, so an `iced::event` listener would leave the
|
||||
/// action queued until the user next moved or clicked inside the window.
|
||||
pub fn menu_subscription() -> Subscription<Message> {
|
||||
iced::event::listen_with(|_event, _status, _id| {
|
||||
iced::time::every(std::time::Duration::from_millis(50)).map(|_| {
|
||||
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||
Some(Message::MenuEvent(event.id))
|
||||
Message::MenuEvent(event.id)
|
||||
} else {
|
||||
None
|
||||
Message::Noop
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +104,14 @@ pub enum ModalState {
|
||||
source_language: String,
|
||||
available_targets: Vec<LanguageTarget>,
|
||||
},
|
||||
RemoteConnection {
|
||||
target: String,
|
||||
connecting: bool,
|
||||
connected: bool,
|
||||
error: Option<String>,
|
||||
projects: Vec<bds_core::model::Project>,
|
||||
selected_project_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -219,6 +227,119 @@ pub fn view(
|
||||
data_dir: Option<&Path>,
|
||||
) -> Element<'static, Message> {
|
||||
let modal_content: Element<'static, Message> = match state {
|
||||
ModalState::RemoteConnection {
|
||||
target,
|
||||
connecting,
|
||||
connected,
|
||||
error,
|
||||
projects,
|
||||
selected_project_id,
|
||||
} => {
|
||||
let target_input =
|
||||
text_input(&t(locale, "remoteConnection.targetPlaceholder"), &target)
|
||||
.on_input(Message::RemoteTargetChanged)
|
||||
.on_submit(Message::RemoteConnectRequested)
|
||||
.style(inputs::field_style);
|
||||
let mut content = column![
|
||||
text(t(locale, "remoteConnection.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE),
|
||||
text(t(locale, "remoteConnection.description"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75)),
|
||||
Space::with_height(8.0),
|
||||
text(t(locale, "remoteConnection.target"))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.80, 0.80, 0.85)),
|
||||
target_input,
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(error) = error {
|
||||
content = content.push(
|
||||
inputs::card(text(error).size(12).color(Color::from_rgb(1.0, 0.55, 0.55)))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
if !projects.is_empty() {
|
||||
content = content.push(Space::with_height(6.0));
|
||||
content = content.push(
|
||||
text(t(locale, "remoteConnection.projects"))
|
||||
.size(13)
|
||||
.color(Color::WHITE),
|
||||
);
|
||||
let mut project_rows = column![].spacing(6);
|
||||
for project in projects {
|
||||
let selected = selected_project_id.as_deref() == Some(project.id.as_str());
|
||||
let marker = if selected { "●" } else { "○" };
|
||||
project_rows = project_rows.push(
|
||||
button(
|
||||
row![
|
||||
text(marker).size(12),
|
||||
text(project.name.clone()).size(13),
|
||||
Space::with_width(Length::Fill),
|
||||
text(project.description.unwrap_or_default())
|
||||
.size(11)
|
||||
.color(Color::from_rgb(0.65, 0.65, 0.70)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.on_press(Message::RemoteProjectSelected(project.id))
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill)
|
||||
.style(if selected {
|
||||
inputs::primary_button
|
||||
} else {
|
||||
inputs::secondary_button
|
||||
}),
|
||||
);
|
||||
}
|
||||
content = content.push(project_rows);
|
||||
}
|
||||
|
||||
let cancel = button(text(t(locale, "remoteConnection.cancel")).size(13))
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style);
|
||||
let action = if !connected {
|
||||
let button = button(
|
||||
text(if connecting {
|
||||
t(locale, "remoteConnection.connecting")
|
||||
} else {
|
||||
t(locale, "remoteConnection.connect")
|
||||
})
|
||||
.size(13),
|
||||
)
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style);
|
||||
if connecting || target.trim().is_empty() {
|
||||
button
|
||||
} else {
|
||||
button.on_press(Message::RemoteConnectRequested)
|
||||
}
|
||||
} else {
|
||||
let button = button(text(t(locale, "remoteConnection.open")).size(13))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style);
|
||||
if selected_project_id.is_some() {
|
||||
button.on_press(Message::RemoteOpenProjectRequested)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
};
|
||||
content = content.push(Space::with_height(8.0)).push(
|
||||
row![cancel, Space::with_width(Length::Fill), action].align_y(Alignment::Center),
|
||||
);
|
||||
container(content.padding(20))
|
||||
.width(Length::Fixed(500.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
ModalState::ConfirmDelete {
|
||||
entity_name,
|
||||
references,
|
||||
|
||||
Reference in New Issue
Block a user