From 591aa9ae69454da790e28aa5588edc89f0f9002c Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 3 Aug 2026 03:01:35 -0400 Subject: [PATCH] Simplify Appa folder management UI --- appa-cosmic/src/app.rs | 289 ++++++++++++++++++----------------------- src/cli.rs | 4 +- src/core.rs | 23 +--- src/daemon.rs | 11 +- src/service.rs | 44 ------- 5 files changed, 132 insertions(+), 239 deletions(-) diff --git a/appa-cosmic/src/app.rs b/appa-cosmic/src/app.rs index 4ba6b09..7c43e93 100644 --- a/appa-cosmic/src/app.rs +++ b/appa-cosmic/src/app.rs @@ -1,17 +1,18 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::LazyLock}; use appa::core::{Dashboard, FolderDetails, FolderSummary}; use cosmic::{ iced::{Alignment, Length}, prelude::*, - widget::{self, nav_bar}, + widget::{self, menu}, }; const APP_ID: &str = "io.github.appa.Appa"; +static MENU_ID: LazyLock = + LazyLock::new(|| cosmic::iced::id::Id::new("appa-menu")); pub struct AppModel { core: cosmic::Core, - nav: nav_bar::Model, dashboard: Option, details: Option, folder_path: String, @@ -19,15 +20,20 @@ pub struct AppModel { selected_folder: Option, pending_revocation: Option<(PathBuf, String)>, notice: Option, + active_form: Option, } #[derive(Clone, Debug)] pub enum Message { + Surface(cosmic::surface::Action), DashboardLoaded(Result), FolderDetailsLoaded(Result), FolderPathChanged(String), InvitationChanged(String), Refresh, + ShowAddFolder, + ShowJoinFolder, + CloseForm, RegisterFolder, JoinFolder, CreateInvite(PathBuf), @@ -41,16 +47,31 @@ pub enum Message { }, ConfirmRevocation, CancelRevocation, - ServiceInstalled(Result<(), String>), - InstallService, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Page { - Dashboard, +enum FolderForm { + Add, + Join, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MenuAction { AddFolder, JoinFolder, - Diagnostics, + Refresh, +} + +impl menu::Action for MenuAction { + type Message = Message; + + fn message(&self) -> Self::Message { + match self { + Self::AddFolder => Message::ShowAddFolder, + Self::JoinFolder => Message::ShowJoinFolder, + Self::Refresh => Message::Refresh, + } + } } impl cosmic::Application for AppModel { @@ -69,28 +90,8 @@ impl cosmic::Application for AppModel { } fn init(core: cosmic::Core, _flags: ()) -> (Self, Task>) { - let mut nav = nav_bar::Model::default(); - nav.insert() - .text("Folders") - .data(Page::Dashboard) - .icon(widget::icon::from_name("folder-symbolic")) - .activate(); - nav.insert() - .text("Add folder") - .data(Page::AddFolder) - .icon(widget::icon::from_name("list-add-symbolic")); - nav.insert() - .text("Join folder") - .data(Page::JoinFolder) - .icon(widget::icon::from_name("network-workgroup-symbolic")); - nav.insert() - .text("Diagnostics") - .data(Page::Diagnostics) - .icon(widget::icon::from_name("utilities-terminal-symbolic")); - let app = Self { core, - nav, dashboard: None, details: None, folder_path: String::new(), @@ -98,28 +99,13 @@ impl cosmic::Application for AppModel { selected_folder: None, pending_revocation: None, notice: None, + active_form: None, }; (app, load_dashboard()) } - fn nav_model(&self) -> Option<&nav_bar::Model> { - Some(&self.nav) - } - fn view(&self) -> Element<'_, Message> { - let page = self - .nav - .active_data::() - .copied() - .unwrap_or(Page::Dashboard); - let content = match page { - Page::Dashboard => self.dashboard_view(), - Page::AddFolder => self.add_folder_view(), - Page::JoinFolder => self.join_folder_view(), - Page::Diagnostics => self.diagnostics_view(), - }; - - widget::container(content) + widget::container(self.folders_view()) .padding(cosmic::theme::spacing().space_l) .width(Length::Fill) .height(Length::Fill) @@ -128,6 +114,11 @@ impl cosmic::Application for AppModel { fn update(&mut self, message: Message) -> Task> { match message { + Message::Surface(action) => { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(action), + )); + } Message::DashboardLoaded(result) => match result { Ok(dashboard) => { self.dashboard = Some(dashboard); @@ -142,6 +133,9 @@ impl cosmic::Application for AppModel { Message::FolderPathChanged(path) => self.folder_path = path, Message::InvitationChanged(invitation) => self.invitation = invitation, Message::Refresh => return load_dashboard(), + Message::ShowAddFolder => self.active_form = Some(FolderForm::Add), + Message::ShowJoinFolder => self.active_form = Some(FolderForm::Join), + Message::CloseForm => self.active_form = None, Message::RegisterFolder => return register_folder(self.folder_path.clone()), Message::JoinFolder => { return join_folder(self.folder_path.clone(), self.invitation.clone()); @@ -159,7 +153,10 @@ impl cosmic::Application for AppModel { self.selected_folder = Some(path.clone()); return load_folder_details(path); } - Message::CloseFolderDetails => self.details = None, + Message::CloseFolderDetails => { + self.details = None; + self.selected_folder = None; + } Message::OpenFolder(path) => { if let Err(error) = open::that_detached(path) { self.notice = Some(format!("Could not open the folder: {error}")); @@ -177,52 +174,44 @@ impl cosmic::Application for AppModel { } } Message::CancelRevocation => self.pending_revocation = None, - Message::InstallService => return install_service(), - Message::ServiceInstalled(result) => match result { - Ok(()) => { - self.notice = - Some("The Appa background service is installed and started.".to_owned()); - return load_dashboard(); - } - Err(error) => self.notice = Some(error), - }, } Task::none() } - fn on_nav_select(&mut self, id: nav_bar::Id) -> Task> { - self.nav.activate(id); - Task::none() + fn header_start(&self) -> Vec> { + let key_bindings = std::collections::HashMap::new(); + vec![widget::responsive_menu_bar().into_element( + self.core(), + &key_bindings, + MENU_ID.clone(), + Message::Surface, + vec![( + "Folders", + vec![ + menu::Item::Button("Add folder", None, MenuAction::AddFolder), + menu::Item::Button("Join folder", None, MenuAction::JoinFolder), + menu::Item::Divider, + menu::Item::Button("Refresh", None, MenuAction::Refresh), + ], + )], + )] } } impl AppModel { - fn dashboard_view(&self) -> Element<'_, Message> { + fn folders_view(&self) -> Element<'_, Message> { let mut content = widget::column::with_capacity(4) - .push(widget::text::title1("Appa folders")) - .push(widget::text::body( - "Private, direct synchronization between your devices.", - )) + .push(widget::text::title1("Folders")) .spacing(cosmic::theme::spacing().space_s); if let Some(notice) = &self.notice { content = content.push(widget::text::body(notice)); } - content = content.push(widget::button::text("Refresh").on_press(Message::Refresh)); - if let Some(details) = &self.details { - content = content.push(self.folder_details_view(details)); + if let Some(form) = self.active_form { + content = content.push(self.folder_form(form)); } if let Some(dashboard) = &self.dashboard { - if !dashboard.service_is_running { - let action_label = if dashboard.service_is_installed { - "Start background sync service" - } else { - "Install background sync service" - }; - content = content - .push(widget::button::text(action_label).on_press(Message::InstallService)); - } if dashboard.folders.is_empty() { content = content.push(widget::text::body("No folders are being synchronized yet.")); @@ -231,16 +220,51 @@ impl AppModel { content = content.push(self.folder_row(folder)); } } else { - content = content - .push(widget::text::body("Appa daemon is unavailable.")) - .push( - widget::button::text("Install background sync service") - .on_press(Message::InstallService), - ); + content = content.push(widget::text::body( + "Appa is not running. Start `appa daemon serve`, then refresh.", + )); } content.into() } + fn folder_form(&self, form: FolderForm) -> Element<'_, Message> { + let title = match form { + FolderForm::Add => "Add a folder", + FolderForm::Join => "Join a folder", + }; + let action = match form { + FolderForm::Add => Message::RegisterFolder, + FolderForm::Join => Message::JoinFolder, + }; + let action_label = match form { + FolderForm::Add => "Start syncing", + FolderForm::Join => "Join folder", + }; + let mut content = widget::column::with_capacity(5) + .push(widget::text::title2(title)) + .push( + widget::text_input("Folder path", &self.folder_path) + .on_input(Message::FolderPathChanged), + ); + + if matches!(form, FolderForm::Join) { + content = content.push( + widget::text_input("Invitation", &self.invitation) + .on_input(Message::InvitationChanged), + ); + } + + content + .push( + widget::row::with_capacity(2) + .push(widget::button::text("Cancel").on_press(Message::CloseForm)) + .push(widget::button::text(action_label).on_press(action)) + .spacing(cosmic::theme::spacing().space_s), + ) + .spacing(cosmic::theme::spacing().space_s) + .into() + } + fn folder_details_view<'a>(&self, details: &'a FolderDetails) -> Element<'a, Message> { let mut content = widget::column::with_capacity(6) .push(widget::text::title2(&details.summary.name)) @@ -321,82 +345,27 @@ impl AppModel { "{} files · {} devices · {} connected peers · {health}", folder.file_count, folder.member_count, folder.peer_count ); - widget::row::with_capacity(2) - .push( - widget::column::with_capacity(3) - .push(widget::text::title3(&folder.name)) - .push(widget::text::body(folder.path.display().to_string())) - .push(widget::text::caption(metadata)), - ) - .push( - widget::button::text("Details").on_press(Message::ShowFolder(folder.path.clone())), - ) - .align_y(Alignment::Center) - .spacing(cosmic::theme::spacing().space_s) - .into() - } - - fn add_folder_view(&self) -> Element<'_, Message> { - self.path_form( - "Add a folder", - "Choose an existing directory to start syncing it.", - "Start syncing", - Message::RegisterFolder, - ) - } - - fn join_folder_view(&self) -> Element<'_, Message> { - widget::column::with_capacity(5) - .push(widget::text::title1("Join a shared folder")) - .push(widget::text::body( - "Paste a trusted Appa invitation, then choose an empty or existing directory.", - )) - .push( - widget::text_input("Folder path", &self.folder_path) - .on_input(Message::FolderPathChanged), - ) - .push( - widget::text_input("Invitation", &self.invitation) - .on_input(Message::InvitationChanged), - ) - .push(widget::button::text("Join folder").on_press(Message::JoinFolder)) - .spacing(cosmic::theme::spacing().space_s) - .into() - } - - fn path_form( - &self, - title: &'static str, - description: &'static str, - action_label: &'static str, - action: Message, - ) -> Element<'_, Message> { - widget::column::with_capacity(4) - .push(widget::text::title1(title)) - .push(widget::text::body(description)) - .push( - widget::text_input("Folder path", &self.folder_path) - .on_input(Message::FolderPathChanged), - ) - .push(widget::button::text(action_label).on_press(action)) - .spacing(cosmic::theme::spacing().space_s) - .into() - } + let mut row = widget::column::with_capacity(2).push( + widget::row::with_capacity(2) + .push( + widget::column::with_capacity(3) + .push(widget::text::title3(&folder.name)) + .push(widget::text::body(folder.path.display().to_string())) + .push(widget::text::caption(metadata)), + ) + .push( + widget::button::text("More").on_press(Message::ShowFolder(folder.path.clone())), + ) + .align_y(Alignment::Center) + .spacing(cosmic::theme::spacing().space_s), + ); - fn diagnostics_view(&self) -> Element<'_, Message> { - let mut content = widget::column::with_capacity(4) - .push(widget::text::title1("Diagnostics")) - .push(widget::button::text("Run again").on_press(Message::Refresh)) - .spacing(cosmic::theme::spacing().space_s); - if let Some(dashboard) = &self.dashboard { - if dashboard.diagnostics.issues.is_empty() { - content = content.push(widget::text::body("Appa found no problems.")); - } - for issue in &dashboard.diagnostics.issues { - content = content.push(widget::text::body(issue)); + if self.selected_folder.as_ref() == Some(&folder.path) { + if let Some(details) = &self.details { + row = row.push(self.folder_details_view(details)); } } - content.into() + row.spacing(cosmic::theme::spacing().space_s).into() } } @@ -506,15 +475,3 @@ fn create_invite(folder_path: PathBuf) -> Task> { ) .map(cosmic::Action::App) } - -fn install_service() -> Task> { - Task::perform( - async { - appa::install_service() - .map(|_| ()) - .map_err(|error| error.to_string()) - }, - Message::ServiceInstalled, - ) - .map(cosmic::Action::App) -} diff --git a/src/cli.rs b/src/cli.rs index bf98bf6..ca0ab2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -199,10 +199,10 @@ pub async fn run() -> anyhow::Result<()> { match command { Command::Daemon { command: DaemonCommand::Serve, - } => crate::daemon::serve().await?, + } => crate::daemon::serve(None).await?, Command::Service { command } => tooling::run_service_command(command)?, Command::Completions { shell } => tooling::print_completions(shell), - Command::Run { .. } => crate::daemon::serve().await?, + Command::Run { folder } => crate::daemon::serve(folder.as_deref().map(Path::new)).await?, command => run_app_command(command, offline).await?, } Ok(()) diff --git a/src/core.rs b/src/core.rs index 8d9158c..0174c71 100644 --- a/src/core.rs +++ b/src/core.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::{ - app::{AppaService, ConflictInfo, DoctorReport, FolderStatus, MemberInfo}, + app::{AppaService, ConflictInfo, FolderStatus, MemberInfo}, service, }; @@ -15,9 +15,6 @@ pub type Result = anyhow::Result; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Dashboard { pub folders: Vec, - pub diagnostics: Diagnostics, - pub service_is_installed: bool, - pub service_is_running: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -54,12 +51,6 @@ pub struct Member { pub label: Option, } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Diagnostics { - pub folder_count: u64, - pub issues: Vec, -} - /// A handle for Appa operations that do not require direct access to storage /// or transport internals. pub struct Appa { @@ -88,9 +79,6 @@ impl Appa { .iter() .map(FolderSummary::from) .collect(), - diagnostics: Diagnostics::from(self.service.doctor()?), - service_is_installed: service::is_installed()?, - service_is_running: service::is_running()?, }) } @@ -173,15 +161,6 @@ impl From for Member { } } -impl From for Diagnostics { - fn from(report: DoctorReport) -> Self { - Self { - folder_count: report.folder_count, - issues: report.issues, - } - } -} - #[cfg(test)] mod tests { use tempfile::TempDir; diff --git a/src/daemon.rs b/src/daemon.rs index 4a81233..60b325a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -16,11 +16,15 @@ use crate::{ const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(250); -pub async fn serve() -> anyhow::Result<()> { +/// Run the daemon, optionally limiting synchronization to one folder. +/// +/// The optional folder preserves the existing `appa run ` behavior; +/// installed services use `None` and synchronize every registered folder. +pub async fn serve(folder_path: Option<&Path>) -> anyhow::Result<()> { let paths = AppPaths::discover()?; let socket_path = paths.daemon_socket_path(); let service = AppaService::open_at(paths)?; - let mut sync_runner = SyncRunner::start(&service, None).await?; + let mut sync_runner = SyncRunner::start(&service, folder_path).await?; remove_stale_socket(&socket_path)?; let listener = UnixListener::bind(&socket_path).with_context(|| { format!( @@ -286,9 +290,6 @@ fn service_dashboard(service: &AppaService) -> anyhow::Result anyhow::Result { Ok(unit_file_path) } -#[allow(unreachable_code)] -pub fn is_installed() -> anyhow::Result { - #[cfg(target_os = "macos")] - { - return Ok(launch_agent_path()?.exists()); - } - if !is_linux() { - return Ok(false); - } - Ok(unit_path()?.exists()) -} - -#[allow(unreachable_code)] -pub fn is_running() -> anyhow::Result { - #[cfg(target_os = "macos")] - { - let status = Command::new("launchctl") - .args(["print", &launchd_service_target()?]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - return match status { - Ok(status) => Ok(status.success()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error.into()), - }; - } - if !is_linux() { - return Ok(false); - } - let status = Command::new("systemctl") - .args(["--user", "is-active", "--quiet", UNIT_NAME]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - match status { - Ok(status) => Ok(status.success()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error.into()), - } -} - #[allow(unreachable_code)] pub fn status() -> anyhow::Result<()> { #[cfg(target_os = "macos")] -- 2.51.2