Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615use std::{borrow::Cow, cmp::Ordering, fmt, path::PathBuf, sync::LazyLock};
use appa::core::{Dashboard, FolderDetails, FolderSummary};use cosmic::{ iced::{Alignment, Length}, prelude::*, widget::{self, menu, table},};
const APP_ID: &str = "io.github.appa.Appa";static MENU_ID: LazyLock<cosmic::iced::id::Id> = LazyLock::new(|| cosmic::iced::id::Id::new("appa-menu"));
pub struct AppModel { core: cosmic::Core, dashboard: Option<Dashboard>, details: Option<FolderDetails>, folder_path: String, invitation: String, selected_folder: Option<PathBuf>, pending_revocation: Option<(PathBuf, String)>, notice: Option<String>, active_form: Option<FolderForm>, folder_table: table::SingleSelectModel<FolderTableItem, FolderColumn>,}
#[derive(Clone, Debug)]pub enum Message { Surface(cosmic::surface::Action), DashboardLoaded(Result<Dashboard, String>), FolderDetailsLoaded(Result<FolderDetails, String>), FolderPathChanged(String), InvitationChanged(String), Refresh, FolderSelected(table::Entity), SortFolders(FolderColumn), OpenSelectedFolder, ShowAddFolder, ShowJoinFolder, CloseForm, RegisterFolder, JoinFolder, CreateInvite(PathBuf), InvitationCreated(Result<String, String>), CloseFolderDetails, OpenFolder(PathBuf), RequestRevocation { folder_path: PathBuf, device_id: String, }, ConfirmRevocation, CancelRevocation,}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum FolderForm { Add, Join,}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum MenuAction { AddFolder, JoinFolder, OpenSelectedFolder, Refresh, CloseDetails, Sort(FolderColumn),}
impl menu::Action for MenuAction { type Message = Message;
fn message(&self) -> Self::Message { match self { Self::AddFolder => Message::ShowAddFolder, Self::JoinFolder => Message::ShowJoinFolder, Self::OpenSelectedFolder => Message::OpenSelectedFolder, Self::Refresh => Message::Refresh, Self::CloseDetails => Message::CloseFolderDetails, Self::Sort(column) => Message::SortFolders(*column), } }}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]pub enum FolderColumn { #[default] Name, Synced, Devices, Files,}
impl fmt::Display for FolderColumn { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Name => "Name", Self::Synced => "Last synced", Self::Devices => "Devices", Self::Files => "Files", }) }}
impl table::ItemCategory for FolderColumn { fn width(&self) -> Length { match self { Self::Name => Length::Fill, Self::Synced => Length::Fixed(180.0), Self::Devices => Length::Fixed(140.0), Self::Files => Length::Fixed(100.0), } }}
#[derive(Clone, Debug)]struct FolderTableItem { path: PathBuf, name: String, synced: String, devices: String, files: String,}
impl FolderTableItem { fn from_summary(folder: &FolderSummary) -> Self { Self { path: folder.path.clone(), name: folder.name.clone(), synced: folder_health(folder), devices: format!("{} devices", folder.member_count), files: format!("{} files", folder.file_count), } }}
impl table::ItemInterface<FolderColumn> for FolderTableItem { fn get_icon(&self, category: FolderColumn) -> Option<widget::Icon> { (category == FolderColumn::Name).then(|| widget::icon::from_name("folder-symbolic").icon()) }
fn get_text(&self, category: FolderColumn) -> Cow<'static, str> { match category { FolderColumn::Name => self.name.clone().into(), FolderColumn::Synced => self.synced.clone().into(), FolderColumn::Devices => self.devices.clone().into(), FolderColumn::Files => self.files.clone().into(), } }
fn compare(&self, other: &Self, category: FolderColumn) -> Ordering { self.get_text(category) .to_lowercase() .cmp(&other.get_text(category).to_lowercase()) }}
impl cosmic::Application for AppModel { type Executor = cosmic::executor::Default; type Flags = (); type Message = Message;
const APP_ID: &'static str = APP_ID;
fn core(&self) -> &cosmic::Core { &self.core }
fn core_mut(&mut self) -> &mut cosmic::Core { &mut self.core }
fn init(core: cosmic::Core, _flags: ()) -> (Self, Task<cosmic::Action<Message>>) { let app = Self { core, dashboard: None, details: None, folder_path: String::new(), invitation: String::new(), selected_folder: None, pending_revocation: None, notice: None, active_form: None, folder_table: table::Model::new(vec![ FolderColumn::Name, FolderColumn::Synced, FolderColumn::Devices, FolderColumn::Files, ]), }; (app, load_dashboard()) }
fn view(&self) -> Element<'_, Message> { widget::container(self.folders_view()) .padding(cosmic::theme::spacing().space_l) .width(Length::Fill) .height(Length::Fill) .into() }
fn update(&mut self, message: Message) -> Task<cosmic::Action<Message>> { match message { Message::Surface(action) => { return cosmic::task::message(cosmic::Action::Cosmic( cosmic::app::Action::Surface(action), )); } Message::DashboardLoaded(result) => match result { Ok(dashboard) => { update_folder_table(&mut self.folder_table, &dashboard.folders); self.dashboard = Some(dashboard); self.notice = None; } Err(error) => self.notice = Some(error), }, Message::FolderDetailsLoaded(result) => match result { Ok(details) => self.details = Some(details), Err(error) => self.notice = Some(error), }, Message::FolderPathChanged(path) => self.folder_path = path, Message::InvitationChanged(invitation) => self.invitation = invitation, Message::Refresh => return load_dashboard(), Message::FolderSelected(entity) => { self.folder_table.activate(entity); if let Some(folder) = self.folder_table.item(entity) { self.selected_folder = Some(folder.path.clone()); return load_folder_details(folder.path.clone()); } } Message::SortFolders(column) => sort_folder_table(&mut self.folder_table, column), Message::OpenSelectedFolder => { if let Some(folder) = self.folder_table.item(self.folder_table.active()) { if let Err(error) = open::that_detached(&folder.path) { self.notice = Some(format!("Could not open the folder: {error}")); } } } 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()); } Message::CreateInvite(path) => return create_invite(path), Message::InvitationCreated(result) => match result { Ok(invitation) => { self.notice = Some(format!( "Invitation expires in 24 hours. Copy it only to a trusted device:\n{invitation}" )) } Err(error) => self.notice = Some(error), }, 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}")); } } Message::RequestRevocation { folder_path, device_id, } => { self.pending_revocation = Some((folder_path, device_id)); } Message::ConfirmRevocation => { if let Some((folder_path, device_id)) = self.pending_revocation.take() { return revoke_member(folder_path, device_id); } } Message::CancelRevocation => self.pending_revocation = None, } Task::none() }
fn header_start(&self) -> Vec<Element<'_, Self::Message>> { let key_bindings = std::collections::HashMap::new(); vec![widget::responsive_menu_bar().into_element( self.core(), &key_bindings, MENU_ID.clone(), Message::Surface, vec![ ( "File", vec![ menu::Item::Button("Add folder", None, MenuAction::AddFolder), menu::Item::Button("Join folder", None, MenuAction::JoinFolder), menu::Item::Divider, menu::Item::Button( "Open selected folder", None, MenuAction::OpenSelectedFolder, ), ], ), ( "Edit", vec![menu::Item::Button("Refresh", None, MenuAction::Refresh)], ), ( "View", vec![menu::Item::Button( "Hide details", None, MenuAction::CloseDetails, )], ), ( "Sort", vec![ menu::Item::Button("By name", None, MenuAction::Sort(FolderColumn::Name)), menu::Item::Button( "By last synced", None, MenuAction::Sort(FolderColumn::Synced), ), menu::Item::Button( "By devices", None, MenuAction::Sort(FolderColumn::Devices), ), menu::Item::Button("By files", None, MenuAction::Sort(FolderColumn::Files)), ], ), ], )] }}
impl AppModel { fn folders_view(&self) -> Element<'_, Message> { let mut content = widget::column::with_capacity(4) .push(widget::text::title1("Shared folders")) .spacing(cosmic::theme::spacing().space_s);
if let Some(notice) = &self.notice { content = content.push(widget::text::body(notice)); }
if let Some(form) = self.active_form { content = content.push(self.folder_form(form)); } if self.dashboard.is_some() { if self.folder_table.iter().next().is_none() { content = content.push(widget::text::body("No folders are being synchronized yet.")); } else { content = content.push( widget::table(&self.folder_table) .on_item_left_click(Message::FolderSelected) .on_category_left_click(Message::SortFolders) .item_context(|_| Some(Vec::new())) .category_context(|_| Some(Vec::new())), ); } if let Some(details) = &self.details { content = content.push(self.folder_details_view(details)); } } else { let connection_status = if self.notice.is_some() { "Appa could not be reached. The connection error is shown above." } else { "Connecting to Appa…" }; content = content.push(widget::text::body(connection_status)); } 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)) .push(widget::text::body( details.summary.path.display().to_string(), )) .push(widget::text::caption(format!( "{} revisions retained", details.history_revision_count ))) .push( widget::row::with_capacity(2) .push( widget::button::text("Create invitation") .on_press(Message::CreateInvite(details.summary.path.clone())), ) .push( widget::button::text("Close details").on_press(Message::CloseFolderDetails), ) .push( widget::button::text("Open folder") .on_press(Message::OpenFolder(details.summary.path.clone())), ) .spacing(cosmic::theme::spacing().space_s), ) .spacing(cosmic::theme::spacing().space_s);
if details.conflicts.is_empty() { content = content.push(widget::text::body("No conflicts need review.")); } else { content = content.push(widget::text::title3("Conflicts")); for conflict in &details.conflicts { content = content.push(widget::text::body(format!( "{} — from {} at {}", conflict.path, conflict.author_device_id, conflict.modified_at ))); } }
content = content.push(widget::text::title3("Devices")); for member in &details.members { let label = member.label.as_deref().unwrap_or(&member.device_id); let mut member_row = widget::row::with_capacity(2).push(widget::text::body(label)); if details.can_revoke_members { member_row = member_row.push(widget::button::text("Remove device").on_press( Message::RequestRevocation { folder_path: details.summary.path.clone(), device_id: member.device_id.clone(), }, )); } content = content.push( member_row .align_y(Alignment::Center) .spacing(cosmic::theme::spacing().space_s), ); }
if let Some((_, device_id)) = &self.pending_revocation { content = content.push(widget::text::body(format!( "Remove {device_id}? This rotates the folder secret and requires fresh invitations for remaining devices." ))); content = content.push( widget::row::with_capacity(2) .push(widget::button::text("Cancel").on_press(Message::CancelRevocation)) .push( widget::button::text("Remove device").on_press(Message::ConfirmRevocation), ) .spacing(cosmic::theme::spacing().space_s), ); } content.into() }}
fn folder_health(folder: &FolderSummary) -> String { if let Some(error) = &folder.last_sync_error { return format!("Needs attention: {error}"); } if folder.conflict_count > 0 { return format!("{} conflicts need review", folder.conflict_count); } match folder.last_successful_sync { Some(sync_time) => format!("Synced {}", sync_time.date()), None => "Waiting for the first sync".to_owned(), }}
fn update_folder_table( table: &mut table::SingleSelectModel<FolderTableItem, FolderColumn>, folders: &[FolderSummary],) { table.clear(); for folder in folders { let _ = table.insert(FolderTableItem::from_summary(folder)); }}
fn sort_folder_table( table: &mut table::SingleSelectModel<FolderTableItem, FolderColumn>, column: FolderColumn,) { let ascending = table .get_sort() .is_none_or(|(current_column, ascending)| current_column != column || !ascending); table.sort(column, ascending);}
fn load_dashboard() -> Task<cosmic::Action<Message>> { Task::perform( async { let client = appa::ipc::DaemonClient::connect().map_err(|error| error.to_string())?; client.dashboard().await.map_err(|error| error.to_string()) }, Message::DashboardLoaded, ) .map(cosmic::Action::App)}
fn load_folder_details(folder_path: PathBuf) -> Task<cosmic::Action<Message>> { Task::perform( async move { let client = appa::ipc::DaemonClient::connect().map_err(|error| error.to_string())?; client .folder_details(folder_path) .await .map_err(|error| error.to_string()) }, Message::FolderDetailsLoaded, ) .map(cosmic::Action::App)}
fn register_folder(folder_path: String) -> Task<cosmic::Action<Message>> { run_folder_action(folder_path, |client, path| { Box::pin(async move { client.register_folder(path).await }) })}
fn join_folder(folder_path: String, invitation: String) -> Task<cosmic::Action<Message>> { Task::perform( async move { let client = appa::ipc::DaemonClient::connect().map_err(|error| error.to_string())?; client .join_folder(PathBuf::from(folder_path), invitation) .await .map_err(|error| error.to_string()) }, folder_action_completed, ) .map(cosmic::Action::App)}
fn revoke_member(folder_path: PathBuf, device_id: String) -> Task<cosmic::Action<Message>> { run_folder_action(folder_path.display().to_string(), move |appa, path| { Box::pin(async move { appa.revoke_member(path, device_id).await }) })}
fn run_folder_action( folder_path: String, action: impl FnOnce( appa::ipc::DaemonClient, PathBuf, ) -> std::pin::Pin< Box<dyn std::future::Future<Output = Result<(), anyhow::Error>> + Send>, > + Send + 'static,) -> Task<cosmic::Action<Message>> { Task::perform( async move { let client = appa::ipc::DaemonClient::connect().map_err(|error| error.to_string())?; action(client, PathBuf::from(folder_path)) .await .map_err(|error| error.to_string()) }, folder_action_completed, ) .map(cosmic::Action::App)}
fn folder_action_completed(result: Result<(), String>) -> Message { match result { Ok(()) => Message::Refresh, Err(error) => Message::DashboardLoaded(Err(error)), }}
fn create_invite(folder_path: PathBuf) -> Task<cosmic::Action<Message>> { Task::perform( async move { let client = appa::ipc::DaemonClient::connect().map_err(|error| error.to_string())?; client .create_invite(folder_path) .await .map_err(|error| error.to_string()) }, Message::InvitationCreated, ) .map(cosmic::Action::App)}