From 9c64f24d489ce055bc4beb7f44be9d181ae29d1f Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Sun, 23 Nov 2025 18:52:50 +0100 Subject: [PATCH] storage page draft --- Cargo.lock | 50 +- cosmic-settings/Cargo.toml | 4 + cosmic-settings/src/app.rs | 55 + cosmic-settings/src/pages/mod.rs | 12 + cosmic-settings/src/pages/system/mod.rs | 7 + cosmic-settings/src/pages/system/storage.rs | 1005 +++++++++++++++++ .../src/pages/system/storage/app_details.rs | 181 +++ .../system/storage/applications_category.rs | 199 ++++ .../src/pages/system/storage/home_category.rs | 264 +++++ .../src/pages/system/storage/models.rs | 580 ++++++++++ .../pages/system/storage/other_category.rs | 118 ++ .../pages/system/storage/system_category.rs | 264 +++++ .../src/pages/system/storage/utils.rs | 276 +++++ i18n/en/cosmic_settings.ftl | 41 + justfile | 1 + 15 files changed, 3056 insertions(+), 1 deletion(-) create mode 100644 cosmic-settings/src/pages/system/storage.rs create mode 100644 cosmic-settings/src/pages/system/storage/app_details.rs create mode 100644 cosmic-settings/src/pages/system/storage/applications_category.rs create mode 100644 cosmic-settings/src/pages/system/storage/home_category.rs create mode 100644 cosmic-settings/src/pages/system/storage/models.rs create mode 100644 cosmic-settings/src/pages/system/storage/other_category.rs create mode 100644 cosmic-settings/src/pages/system/storage/system_category.rs create mode 100644 cosmic-settings/src/pages/system/storage/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 236537616..b63f111ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1713,6 +1713,7 @@ dependencies = [ "indexmap 2.12.0", "itertools 0.14.0", "itoa", + "jwalk", "libcosmic", "locale1", "locales-rs", @@ -1731,6 +1732,7 @@ dependencies = [ "smithay-client-toolkit 0.20.0", "static_init", "sunrise", + "sysinfo 0.37.2", "tachyonix", "timedate-zbus", "tokio", @@ -1888,7 +1890,7 @@ dependencies = [ "concat-in-place", "const_format", "memchr", - "sysinfo", + "sysinfo 0.36.1", ] [[package]] @@ -1980,6 +1982,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2008,6 +2023,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -4331,6 +4355,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" +dependencies = [ + "crossbeam", + "rayon", +] + [[package]] name = "jxl-bitstream" version = "1.1.0" @@ -7416,6 +7450,20 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-deps" version = "6.2.2" diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index aa88e9177..62dac7fb6 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -53,6 +53,7 @@ image = { version = "0.25", default-features = false, features = [ indexmap = "2.12.0" itertools = "0.14.0" itoa = "1.0.15" +jwalk = { version = "0.8.1", optional = true } libcosmic.workspace = true locale1 = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } mime-apps = { package = "cosmic-mime-apps", git = "https://github.com/pop-os/cosmic-mime-apps", optional = true } @@ -67,6 +68,7 @@ serde = { version = "1.0.228", features = ["derive"] } slab = "0.4.11" slotmap = "1.0.7" static_init = "1.0.4" +sysinfo = { version = "0.37.2", optional = true } sunrise = "2.1.0" tachyonix = "0.3.1" timedate-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } @@ -124,6 +126,7 @@ linux = [ "page-power", "page-region", "page-sound", + "page-storage", "page-users", "page-window-management", "page-workspaces", @@ -168,6 +171,7 @@ page-networking = [ page-power = ["dep:upower_dbus", "dep:zbus"] page-region = ["gettext", "dep:locales-rs", "dep:locale1", "dep:zbus"] page-sound = ["dep:cosmic-settings-sound-subscription"] +page-storage = ["dep:jwalk", "dep:sysinfo"] page-users = ["xdg-portal", "dep:accounts-zbus", "dep:zbus", "dep:zbus_polkit"] page-window-management = ["dep:cosmic-settings-config"] page-workspaces = ["dep:cosmic-comp-config"] diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index 4e15a3b68..ff6f4068c 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -556,6 +556,61 @@ impl cosmic::Application for SettingsApp { } } + #[cfg(feature = "page-storage")] + crate::pages::Message::Storage(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + + #[cfg(feature = "page-storage")] + crate::pages::Message::StorageSystemCategory(message) => { + if let Some(page) = self + .pages + .page_mut::() + { + return page.update(message).map(Into::into); + } + } + + #[cfg(feature = "page-storage")] + crate::pages::Message::StorageHomeCategory(message) => { + if let Some(page) = self + .pages + .page_mut::() + { + return page.update(message).map(Into::into); + } + } + + #[cfg(feature = "page-storage")] + crate::pages::Message::StorageApplicationsCategory(message) => { + if let Some(page) = self + .pages + .page_mut::() + { + return page.update(message).map(Into::into); + } + } + + #[cfg(feature = "page-storage")] + crate::pages::Message::StorageAppDetails(message) => { + if let Some(page) = self.pages.page_mut::() + { + return page.update(message).map(Into::into); + } + } + + #[cfg(feature = "page-storage")] + crate::pages::Message::StorageOtherCategory(message) => { + if let Some(page) = self + .pages + .page_mut::() + { + return page.update(message).map(Into::into); + } + } + #[cfg(feature = "page-users")] crate::pages::Message::User(message) => { if let Some(page) = self.pages.page_mut::() { diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index a2eab2ed9..1968cc38b 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -85,6 +85,18 @@ pub enum Message { #[cfg(feature = "page-sound")] Sound(sound::Message), StartupApps(applications::startup_apps::Message), + #[cfg(feature = "page-storage")] + Storage(system::storage::Message), + #[cfg(feature = "page-storage")] + StorageSystemCategory(system::storage::system_category::Message), + #[cfg(feature = "page-storage")] + StorageHomeCategory(system::storage::home_category::Message), + #[cfg(feature = "page-storage")] + StorageApplicationsCategory(system::storage::applications_category::Message), + #[cfg(feature = "page-storage")] + StorageAppDetails(system::storage::app_details::Message), + #[cfg(feature = "page-storage")] + StorageOtherCategory(system::storage::other_category::Message), #[cfg(feature = "page-users")] User(system::users::Message), #[cfg(feature = "page-input")] diff --git a/cosmic-settings/src/pages/system/mod.rs b/cosmic-settings/src/pages/system/mod.rs index d45a592b2..54cae949e 100644 --- a/cosmic-settings/src/pages/system/mod.rs +++ b/cosmic-settings/src/pages/system/mod.rs @@ -5,6 +5,8 @@ pub mod about; pub mod firmware; +#[cfg(feature = "page-storage")] +pub mod storage; #[cfg(feature = "page-users")] pub mod users; @@ -41,6 +43,11 @@ impl page::AutoBind for Page { page = page.sub_page::(); + #[cfg(feature = "page-storage")] + { + page = page.sub_page::(); + } + page } } diff --git a/cosmic-settings/src/pages/system/storage.rs b/cosmic-settings/src/pages/system/storage.rs new file mode 100644 index 000000000..44b37a7c0 --- /dev/null +++ b/cosmic-settings/src/pages/system/storage.rs @@ -0,0 +1,1005 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +pub mod app_details; +pub mod applications_category; +pub mod home_category; +pub mod models; +pub mod other_category; +pub mod system_category; +mod utils; + +use cosmic_settings_page::{self as page, Section, section}; + +use cosmic::app::context_drawer::ContextDrawer; +use cosmic::iced::widget::container::Style as ContainerStyle; +use cosmic::iced::{Alignment, Background, Border, Color, Length, Subscription}; +use cosmic::widget::{button, column, container, icon, progress_bar, row, settings, text}; +use cosmic::{Apply, Task}; +use slab::Slab; +use slotmap::{Key, SlotMap}; +use std::time::Duration; + +use utils::{category_color, format_bytes, loading_spinner}; + +pub use models::{FlatpakApp, HomeCategory, StorageInfo, SystemCategory}; + +const COLOR_INDICATOR_SIZE: f32 = 12.0; +const STORAGE_BAR_HEIGHT: f32 = 24.0; +const SEGMENT_SPACING: u16 = 1; +const CORNER_RADIUS: f32 = 4.0; +const SMALL_CORNER_RADIUS: f32 = 2.0; + +fn color_indicator<'a, Message: 'a>(color: Color) -> cosmic::Element<'a, Message> { + container(cosmic::widget::Space::new( + Length::Fixed(COLOR_INDICATOR_SIZE), + Length::Fixed(COLOR_INDICATOR_SIZE), + )) + .style(move |_theme| ContainerStyle { + background: Some(Background::Color(color)), + border: Border { + radius: SMALL_CORNER_RADIUS.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +fn create_bar_segment<'a, Message: 'a>( + portion: u16, + color: Color, + radius: [f32; 4], +) -> cosmic::Element<'a, Message> { + container(cosmic::widget::Space::new( + Length::Fill, + Length::Fixed(STORAGE_BAR_HEIGHT), + )) + .width(Length::FillPortion(portion)) + .style(move |_theme| ContainerStyle { + background: Some(Background::Color(color)), + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +fn segment_radius(is_first: bool, is_last: bool) -> [f32; 4] { + match (is_first, is_last) { + (true, true) => [CORNER_RADIUS; 4], + (true, false) => [CORNER_RADIUS, 0.0, 0.0, CORNER_RADIUS], + (false, true) => [0.0, CORNER_RADIUS, CORNER_RADIUS, 0.0], + (false, false) => [0.0; 4], + } +} + +fn segmented_storage_bar<'a, Message: 'a>(info: &StorageInfo) -> cosmic::Element<'a, Message> { + if info.total == 0 { + return container(cosmic::widget::Space::new( + Length::Fill, + Length::Fixed(STORAGE_BAR_HEIGHT), + )) + .into(); + } + + let segments_data = [ + (info.system, category_color(&CategoryType::System)), + (info.home, category_color(&CategoryType::Home)), + ( + info.applications, + category_color(&CategoryType::Applications), + ), + (info.other, category_color(&CategoryType::Other)), + (info.available, utils::COLOR_AVAILABLE), + ]; + + // Calculate scaling factor to fit all values in u16 range + // We always need to scale since storage sizes in bytes exceed u16::MAX + let scale = info.total as f64 / (u16::MAX as f64 * 0.9); // Use 90% of max to leave room + + // Scale and filter out zero-size segments + let scaled_segments: Vec<(u16, Color)> = segments_data + .iter() + .map(|(size, color)| { + let scaled = if *size > 0 { + ((*size as f64 / scale) as u16).max(1) // Ensure non-zero sizes don't become 0 + } else { + 0 + }; + (scaled, *color) + }) + .filter(|(portion, _)| *portion > 0) + .collect(); + + if scaled_segments.is_empty() { + return container(cosmic::widget::Space::new( + Length::Fill, + Length::Fixed(STORAGE_BAR_HEIGHT), + )) + .into(); + } + + // Build row with appropriate corner rounding for each segment + let last_index = scaled_segments.len() - 1; + let segments = scaled_segments.into_iter().enumerate().fold( + row::with_capacity(last_index + 1).spacing(SEGMENT_SPACING), + |row, (index, (portion, color))| { + let radius = segment_radius(index == 0, index == last_index); + row.push(create_bar_segment(portion, color, radius)) + }, + ); + + segments.width(Length::Fill).into() +} + +fn category_button<'a>( + label: &'a str, + category_type: CategoryType, + size: u64, + loading: bool, + animation_state: u8, +) -> cosmic::Element<'a, Message> { + let color = category_color(&category_type); + + let size_element: cosmic::Element = if loading { + loading_spinner(animation_state) + } else { + text::body(format_bytes(size)).into() + }; + + let row_content = row::with_capacity(4) + .spacing(12) + .align_y(Alignment::Center) + .push(color_indicator(color)) + .push(text::body(label).width(Length::Fill)) + .push(size_element) + .push(icon::from_name("go-next-symbolic").size(16)); + + button::custom(row_content) + .padding([12, 16]) + .on_press(Message::SelectCategory(Some(category_type))) + .width(Length::Fill) + .class(cosmic::theme::Button::MenuItem) + .into() +} + +#[derive(Clone, Debug, PartialEq)] +pub enum CategoryType { + System, + Home, + Applications, + Other, +} + +#[derive(Clone, Debug)] +pub enum Message { + StorageInfo(StorageInfo), + FlatpakAppsWithSizes(Vec), + CategoryDetails { + system: SystemCategory, + home: HomeCategory, + }, + // Incremental field updates + SystemFieldUpdate(SystemFieldUpdate), + HomeFieldUpdate(HomeFieldUpdate), + HomeTotalAndVar { + total_home: u64, + var_dir: u64, + }, + SelectCategory(Option), + AnimationTick, +} + +#[derive(Clone, Debug)] +pub enum SystemFieldUpdate { + SystemFiles(u64), + PackageCache(u64), + SystemLogs(u64), + SystemCache(u64), + BootFiles(u64), + FlatpakRuntimes(u64), +} + +#[derive(Clone, Debug)] +pub enum HomeFieldUpdate { + Documents(u64), + Downloads(u64), + Pictures(u64), + Videos(u64), + Music(u64), + Desktop(u64), + Other(u64), +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Storage(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Storage(message) + } +} + +#[derive(Clone, Debug)] +struct SubPages { + system: page::Entity, + home: page::Entity, + applications: page::Entity, + other: page::Entity, +} + +impl Default for SubPages { + fn default() -> Self { + Self { + system: page::Entity::null(), + home: page::Entity::null(), + applications: page::Entity::null(), + other: page::Entity::null(), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct Page { + entity: page::Entity, + storage_info: StorageInfo, + system_category: SystemCategory, + home_category: HomeCategory, + loading: bool, + pending_tasks: u8, + on_enter_handle: Option, + sub_pages: SubPages, + animation_state: u8, + home_total_and_var: Option<(u64, u64)>, + home_dirs_loaded_count: u8, +} + +impl page::AutoBind for Page { + fn sub_pages( + mut page: page::Insert, + ) -> page::Insert { + let system = page.sub_page_with_id::(); + let home = page.sub_page_with_id::(); + let applications = page.sub_page_with_id::(); + let other = page.sub_page_with_id::(); + + let model = page.model.page_mut::().unwrap(); + model.sub_pages.system = system; + model.sub_pages.home = home; + model.sub_pages.applications = applications; + model.sub_pages.other = other; + + page + } +} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![ + sections.insert(storage_overview()), + sections.insert(storage_categories()), + ]) + } + + fn info(&self) -> page::Info { + page::Info::new("storage", "drive-harddisk-symbolic") + .title(fl!("storage")) + .description(fl!("storage", "desc")) + } + + fn on_enter(&mut self) -> Task { + if self.loading || self.storage_info.total > 0 { + return Task::none(); + } + + self.loading = true; + + let (task, handle) = Task::future(async move { + crate::pages::Message::Storage(Message::StorageInfo(StorageInfo::load())) + }) + .abortable(); + + self.on_enter_handle = Some(handle); + task + } + + fn on_leave(&mut self) -> Task { + if let Some(handle) = self.on_enter_handle.take() { + handle.abort(); + } + Task::none() + } + + fn context_drawer(&self) -> Option> { + None + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + if self.loading || self.pending_tasks > 0 { + // Animate loading indicator while loading + cosmic::iced::time::every(Duration::from_millis(500)) + .map(|_| crate::pages::Message::Storage(Message::AnimationTick)) + } else { + Subscription::none() + } + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::app::Task { + match message { + Message::StorageInfo(info) => { + let old_system = self.storage_info.system; + let old_home = self.storage_info.home; + let old_applications = self.storage_info.applications; + let old_other = self.storage_info.other; + let old_apps = self.storage_info.flatpak_apps.clone(); + + let apps_to_load = if old_apps.is_empty() { + info.flatpak_apps.clone() + } else { + old_apps.clone() + }; + + self.storage_info = info; + + self.storage_info.system = old_system.max(self.storage_info.system); + self.storage_info.home = old_home.max(self.storage_info.home); + self.storage_info.applications = + old_applications.max(self.storage_info.applications); + self.storage_info.other = old_other.max(self.storage_info.other); + if !old_apps.is_empty() { + self.storage_info.flatpak_apps = old_apps; + } + + // Keep loading state true until all background tasks complete + // self.loading will be set to false when pending_tasks reaches 0 + // Track background tasks: 1 flatpak apps + 6 system fields + 6 home dirs = 13 + // (home "other" will be calculated from total_and_var + sum of 6 dirs, no extra scan) + self.pending_tasks = 13; + self.home_total_and_var = None; + self.home_dirs_loaded_count = 0; + + // Spawn background tasks without blocking navigation + // Use spawn_blocking to prevent heavy I/O from blocking the async executor + let mut tasks = vec![ + cosmic::Task::future(async move { + let apps = tokio::task::spawn_blocking(move || { + StorageInfo::load_flatpak_apps_with_sizes(apps_to_load) + }) + .await + .unwrap_or_default(); + Message::FlatpakAppsWithSizes(apps) + }) + .map(crate::app::Message::from) + .map(Into::into), + ]; + + tasks.push( + cosmic::Task::future(async { + let size = tokio::task::spawn_blocking(|| { + models::SystemCategory::load_system_files() + }) + .await + .unwrap_or(0); + Message::SystemFieldUpdate(SystemFieldUpdate::SystemFiles(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = tokio::task::spawn_blocking(|| { + models::SystemCategory::load_boot_files() + }) + .await + .unwrap_or(0); + Message::SystemFieldUpdate(SystemFieldUpdate::BootFiles(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = tokio::task::spawn_blocking(|| { + models::SystemCategory::load_system_logs() + }) + .await + .unwrap_or(0); + Message::SystemFieldUpdate(SystemFieldUpdate::SystemLogs(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = tokio::task::spawn_blocking(|| { + models::SystemCategory::load_package_cache() + }) + .await + .unwrap_or(0); + Message::SystemFieldUpdate(SystemFieldUpdate::PackageCache(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = tokio::task::spawn_blocking(|| { + models::SystemCategory::load_flatpak_runtimes() + }) + .await + .unwrap_or(0); + Message::SystemFieldUpdate(SystemFieldUpdate::FlatpakRuntimes(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let (total_cache, package_cache) = tokio::task::spawn_blocking(|| { + models::SystemCategory::load_system_cache() + }) + .await + .unwrap_or((0, 0)); + Message::SystemFieldUpdate(SystemFieldUpdate::SystemCache( + total_cache.saturating_sub(package_cache), + )) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = + tokio::task::spawn_blocking(|| models::HomeCategory::load_documents()) + .await + .unwrap_or(0); + Message::HomeFieldUpdate(HomeFieldUpdate::Documents(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = + tokio::task::spawn_blocking(|| models::HomeCategory::load_downloads()) + .await + .unwrap_or(0); + Message::HomeFieldUpdate(HomeFieldUpdate::Downloads(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = + tokio::task::spawn_blocking(|| models::HomeCategory::load_pictures()) + .await + .unwrap_or(0); + Message::HomeFieldUpdate(HomeFieldUpdate::Pictures(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = + tokio::task::spawn_blocking(|| models::HomeCategory::load_videos()) + .await + .unwrap_or(0); + Message::HomeFieldUpdate(HomeFieldUpdate::Videos(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = + tokio::task::spawn_blocking(|| models::HomeCategory::load_music()) + .await + .unwrap_or(0); + Message::HomeFieldUpdate(HomeFieldUpdate::Music(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let size = + tokio::task::spawn_blocking(|| models::HomeCategory::load_desktop()) + .await + .unwrap_or(0); + Message::HomeFieldUpdate(HomeFieldUpdate::Desktop(size)) + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + tasks.push( + cosmic::Task::future(async { + let (total_home, var_dir) = tokio::task::spawn_blocking(|| { + models::HomeCategory::load_total_and_var() + }) + .await + .unwrap_or((0, 0)); + Message::HomeTotalAndVar { + total_home, + var_dir, + } + }) + .map(crate::app::Message::from) + .map(Into::into), + ); + + return cosmic::Task::batch(tasks); + } + Message::CategoryDetails { system, home } => { + self.system_category = system.clone(); + self.home_category = home.clone(); + + self.storage_info.system = system.total_size(); + self.storage_info.home = home.total_size(); + + self.pending_tasks = self.pending_tasks.saturating_sub(1); + + let loading = self.pending_tasks > 0; + + if self.pending_tasks == 0 { + self.loading = false; + self.storage_info.other = self.storage_info.used.saturating_sub( + self.storage_info.system + + self.storage_info.home + + self.storage_info.applications, + ); + } + + // Sync data to sub-pages so they update if currently viewing + return cosmic::Task::batch(vec![ + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageSystemCategory( + system_category::Message::SetData { + data: system, + loading, + }, + ), + )), + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageHomeCategory( + home_category::Message::SetData { + data: home, + loading, + }, + ), + )), + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageOtherCategory( + other_category::Message::SetData { + size: self.storage_info.other, + loading, + }, + ), + )), + ]); + } + Message::FlatpakAppsWithSizes(apps_with_sizes) => { + self.storage_info.flatpak_apps = apps_with_sizes.clone(); + + let flatpak_total: u64 = self + .storage_info + .flatpak_apps + .iter() + .map(|app| app.total_size()) + .sum(); + self.storage_info.applications = flatpak_total; + + self.pending_tasks = self.pending_tasks.saturating_sub(1); + + let loading = self.pending_tasks > 0; + + // Only recalculate "other" when all background tasks are complete + // to avoid showing incorrect values based on partial data + if self.pending_tasks == 0 { + self.loading = false; + self.storage_info.other = self.storage_info.used.saturating_sub( + self.storage_info.system + + self.storage_info.home + + self.storage_info.applications, + ); + } + + // Sync data to sub-pages so they update if currently viewing + return cosmic::Task::batch(vec![ + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageApplicationsCategory( + applications_category::Message::SetApps(apps_with_sizes), + ), + )), + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageOtherCategory( + other_category::Message::SetData { + size: self.storage_info.other, + loading, + }, + ), + )), + ]); + } + Message::SystemFieldUpdate(field_update) => { + // Update individual system category field and notify sub-page + match field_update.clone() { + SystemFieldUpdate::SystemFiles(size) => { + self.system_category.system_files = size + } + SystemFieldUpdate::PackageCache(size) => { + self.system_category.package_cache = size + } + SystemFieldUpdate::SystemLogs(size) => self.system_category.system_logs = size, + SystemFieldUpdate::SystemCache(size) => { + self.system_category.system_cache = size + } + SystemFieldUpdate::BootFiles(size) => self.system_category.boot_files = size, + SystemFieldUpdate::FlatpakRuntimes(size) => { + self.system_category.flatpak_runtimes = size + } + } + + self.storage_info.system = self.system_category.total_size(); + + self.pending_tasks = self.pending_tasks.saturating_sub(1); + + if self.pending_tasks == 0 { + self.loading = false; + self.storage_info.other = self.storage_info.used.saturating_sub( + self.storage_info.system + + self.storage_info.home + + self.storage_info.applications, + ); + } + + // Forward update to system sub-page + return cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageSystemCategory( + system_category::Message::FieldUpdate(field_update), + ), + )); + } + Message::HomeFieldUpdate(field_update) => { + let is_dir_field = match field_update.clone() { + HomeFieldUpdate::Documents(size) => { + self.home_category.documents = size; + true + } + HomeFieldUpdate::Downloads(size) => { + self.home_category.downloads = size; + true + } + HomeFieldUpdate::Pictures(size) => { + self.home_category.pictures = size; + true + } + HomeFieldUpdate::Videos(size) => { + self.home_category.videos = size; + true + } + HomeFieldUpdate::Music(size) => { + self.home_category.music = size; + true + } + HomeFieldUpdate::Desktop(size) => { + self.home_category.desktop = size; + true + } + HomeFieldUpdate::Other(size) => { + self.home_category.other = size; + false + } + }; + + if is_dir_field { + self.home_dirs_loaded_count += 1; + + if self.home_dirs_loaded_count == 6 { + if let Some((total_home, var_dir)) = self.home_total_and_var { + let dirs_sum = self.home_category.documents + + self.home_category.downloads + + self.home_category.pictures + + self.home_category.videos + + self.home_category.music + + self.home_category.desktop; + + let other = total_home.saturating_sub(dirs_sum + var_dir); + self.home_category.other = other; + + let other_update_task = + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageHomeCategory( + home_category::Message::FieldUpdate( + HomeFieldUpdate::Other(other), + ), + ), + )); + + self.storage_info.home = self.home_category.total_size(); + + self.pending_tasks = self.pending_tasks.saturating_sub(1); + + if self.pending_tasks == 0 { + self.loading = false; + self.storage_info.other = self.storage_info.used.saturating_sub( + self.storage_info.system + + self.storage_info.home + + self.storage_info.applications, + ); + } + + return cosmic::Task::batch(vec![ + cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageHomeCategory( + home_category::Message::FieldUpdate(field_update), + ), + )), + other_update_task, + ]); + } + } + + self.pending_tasks = self.pending_tasks.saturating_sub(1); + } + + self.storage_info.home = self.home_category.total_size(); + + if self.pending_tasks == 0 { + self.loading = false; + self.storage_info.other = self.storage_info.used.saturating_sub( + self.storage_info.system + + self.storage_info.home + + self.storage_info.applications, + ); + } + return cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageHomeCategory( + home_category::Message::FieldUpdate(field_update), + ), + )); + } + Message::HomeTotalAndVar { + total_home, + var_dir, + } => { + self.home_total_and_var = Some((total_home, var_dir)); + + if self.home_dirs_loaded_count == 6 { + let dirs_sum = self.home_category.documents + + self.home_category.downloads + + self.home_category.pictures + + self.home_category.videos + + self.home_category.music + + self.home_category.desktop; + + let other = total_home.saturating_sub(dirs_sum + var_dir); + self.home_category.other = other; + + self.storage_info.home = self.home_category.total_size(); + + return cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageHomeCategory( + home_category::Message::FieldUpdate(HomeFieldUpdate::Other(other)), + ), + )); + } + } + Message::AnimationTick => { + // Cycle through animation states: 0 -> 1 -> 2 -> 0 + self.animation_state = (self.animation_state + 1) % 3; + } + Message::SelectCategory(category) => { + if let Some(cat) = category { + return match cat { + CategoryType::System => { + let loading = self.pending_tasks > 0; + let set_data_task = cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageSystemCategory( + system_category::Message::SetData { + data: self.system_category.clone(), + loading, + }, + ), + )); + let navigate_task = cosmic::task::message(crate::app::Message::Page( + self.sub_pages.system, + )); + cosmic::Task::batch(vec![set_data_task, navigate_task]) + } + CategoryType::Home => { + let loading = self.pending_tasks > 0; + let set_data_task = cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageHomeCategory( + home_category::Message::SetData { + data: self.home_category.clone(), + loading, + }, + ), + )); + let navigate_task = cosmic::task::message(crate::app::Message::Page( + self.sub_pages.home, + )); + cosmic::Task::batch(vec![set_data_task, navigate_task]) + } + CategoryType::Applications => { + let set_data_task = cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageApplicationsCategory( + applications_category::Message::SetApps( + self.storage_info.flatpak_apps.clone(), + ), + ), + )); + let navigate_task = cosmic::task::message(crate::app::Message::Page( + self.sub_pages.applications, + )); + cosmic::Task::batch(vec![set_data_task, navigate_task]) + } + CategoryType::Other => { + let loading = self.pending_tasks > 0; + let set_data_task = cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageOtherCategory( + other_category::Message::SetData { + size: self.storage_info.other, + loading, + }, + ), + )); + let navigate_task = cosmic::task::message(crate::app::Message::Page( + self.sub_pages.other, + )); + cosmic::Task::batch(vec![set_data_task, navigate_task]) + } + }; + } + } + } + + Task::none() + } +} + +fn storage_overview() -> Section { + let mut descriptions = Slab::new(); + + let overview_title = descriptions.insert(fl!("storage-overview")); + + Section::default() + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let desc = §ion.descriptions; + let info = &page.storage_info; + let animation_state = page.animation_state; + + let content = if page.loading { + // Show UI structure with spinner while loading + column() + .push(text::heading(&*desc[overview_title])) + .push(cosmic::widget::vertical_space().height(Length::Fixed(8.0))) + .push( + row() + .spacing(8) + .push(loading_spinner(animation_state)) + .push(text::body("Loading...")), + ) + .push(cosmic::widget::vertical_space().height(Length::Fixed(8.0))) + .push( + progress_bar(0.0..=100.0, 0.0) + .width(Length::Fill) + .height(Length::Fixed(24.0)), + ) + .push(cosmic::widget::vertical_space().height(Length::Fixed(4.0))) + .push( + row() + .spacing(8) + .push(loading_spinner(animation_state)) + .push(text::caption("Calculating...")), + ) + .padding(16) + } else { + // Show actual data with color-coded segmented bar + let storage_bar = segmented_storage_bar(info); + + column() + .push(text::heading(&*desc[overview_title])) + .push(cosmic::widget::vertical_space().height(Length::Fixed(8.0))) + .push(text::body(format!( + "{} of {} used", + format_bytes(info.used), + format_bytes(info.total) + ))) + .push(cosmic::widget::vertical_space().height(Length::Fixed(8.0))) + .push(storage_bar) + .push(cosmic::widget::vertical_space().height(Length::Fixed(4.0))) + .push(text::caption(format!( + "{} available", + format_bytes(info.available) + ))) + .padding(16) + }; + + container(content) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Storage) + }) +} + +fn storage_categories() -> Section { + let mut descriptions = Slab::new(); + + let system_label = descriptions.insert(fl!("storage-category-system")); + let home_label = descriptions.insert(fl!("storage-category-home")); + let apps_label = descriptions.insert(fl!("storage-category-apps")); + let other_label = descriptions.insert(fl!("storage-category-other")); + + Section::default() + .title(fl!("storage-categories")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let desc = §ion.descriptions; + let info = &page.storage_info; + let loading = page.loading; + let animation_state = page.animation_state; + + settings::section() + .title(§ion.title) + .add(category_button( + &desc[system_label], + CategoryType::System, + info.system, + loading, + animation_state, + )) + .add(category_button( + &desc[home_label], + CategoryType::Home, + info.home, + loading, + animation_state, + )) + .add(category_button( + &desc[apps_label], + CategoryType::Applications, + info.applications, + loading, + animation_state, + )) + .add(category_button( + &desc[other_label], + CategoryType::Other, + info.other, + loading, + animation_state, + )) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Storage) + }) +} diff --git a/cosmic-settings/src/pages/system/storage/app_details.rs b/cosmic-settings/src/pages/system/storage/app_details.rs new file mode 100644 index 000000000..dd36d5107 --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/app_details.rs @@ -0,0 +1,181 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::iced::{Alignment, Subscription}; +use cosmic::widget::{column, icon, row, settings, text}; +use cosmic::{Apply, Task}; +use cosmic_settings_page::{self as page, Section, section}; +use slotmap::SlotMap; +use std::time::Duration; + +use super::{FlatpakApp, format_bytes, loading_spinner}; + +#[derive(Clone, Debug)] +pub enum Message { + LoadAppDetails(FlatpakApp), + AnimationTick, +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageAppDetails(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageAppDetails(message) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Page { + entity: page::Entity, + app: Option, + animation_state: u8, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(app_details())]) + } + + fn info(&self) -> page::Info { + let title = self + .app + .as_ref() + .map(|a| a.name.clone()) + .unwrap_or_else(|| fl!("storage-app-details")); + + page::Info::new("storage-app-details", "application-default-symbolic").title(title) + } + + fn on_enter(&mut self) -> Task { + Task::none() + } + + fn context_drawer( + &self, + ) -> Option> { + None + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + if self.app.as_ref().map(|a| a.loading).unwrap_or(false) { + cosmic::iced::time::every(Duration::from_millis(500)) + .map(|_| crate::pages::Message::StorageAppDetails(Message::AnimationTick)) + } else { + Subscription::none() + } + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::app::Task { + match message { + Message::LoadAppDetails(app) => { + self.app = Some(app); + } + Message::AnimationTick => { + self.animation_state = (self.animation_state + 1) % 3; + } + } + + Task::none() + } + + pub fn set_app(&mut self, app: FlatpakApp) { + self.app = Some(app); + } +} + +fn app_details() -> Section { + Section::default().view::(move |_binder, page, _section| { + let animation_state = page.animation_state; + let content = if let Some(app) = &page.app { + let mut column_widget = column::with_capacity(4).spacing(16).padding(16); + + // Row 1: App icon on left, app name/version/developer on right + let mut info_column = column::with_capacity(3) + .spacing(4) + .push(text::heading(&app.name)); + + if app.loading { + info_column = info_column.push(text::caption("Loading...")); + } else { + if !app.version.is_empty() { + info_column = info_column.push(text::body(&app.version)); + } + if !app.developer.is_empty() { + info_column = info_column.push(text::caption(&app.developer)); + } + } + + let header_row = row::with_capacity(2) + .spacing(16) + .align_y(Alignment::Center) + .push(icon::from_name(&*app.icon).size(64)) + .push(info_column); + + column_widget = column_widget.push(header_row); + + let mut size_section = settings::section().title(fl!("storage-app-size-details")); + + // Row 2: App Size + let installed_row = if app.loading { + settings::flex_item( + fl!("storage-app-installed"), + loading_spinner(animation_state), + ) + } else { + settings::flex_item( + fl!("storage-app-installed"), + text::body(format_bytes(app.installed_size)), + ) + }; + size_section = size_section.add(installed_row); + + // Row 3: Data & Config + let data_row = if app.loading { + settings::flex_item(fl!("storage-app-data"), loading_spinner(animation_state)) + } else { + settings::flex_item( + fl!("storage-app-data"), + text::body(format_bytes(app.data_size)), + ) + }; + size_section = size_section.add(data_row); + + // Row 4: Total Size + let total_row = if app.loading { + settings::flex_item(fl!("storage-app-total"), loading_spinner(animation_state)) + } else { + settings::flex_item( + fl!("storage-app-total"), + text::body(format_bytes(app.total_size())), + ) + }; + size_section = size_section.add(total_row); + + column_widget = column_widget.push(size_section); + + column_widget.apply(cosmic::Element::from) + } else { + column::with_capacity(1) + .padding(16) + .push(text::body("Select an application")) + .apply(cosmic::Element::from) + }; + + content.map(crate::pages::Message::StorageAppDetails) + }) +} diff --git a/cosmic-settings/src/pages/system/storage/applications_category.rs b/cosmic-settings/src/pages/system/storage/applications_category.rs new file mode 100644 index 000000000..666886470 --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/applications_category.rs @@ -0,0 +1,199 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::app::ContextDrawer; +use cosmic::iced::{Alignment, Length, Subscription}; +use cosmic::widget::{button, icon, row, settings, text}; +use cosmic::{Apply, Task}; +use cosmic_settings_page::{self as page, Section, section}; +use slotmap::{Key, SlotMap}; +use std::time::Duration; + +use super::app_details; +use super::{FlatpakApp, StorageInfo, format_bytes, loading_spinner}; + +#[derive(Clone, Debug)] +pub enum Message { + LoadApps(Vec), + LoadAppsWithSizes(Vec), + SetApps(Vec), + SelectApp(String), + AnimationTick, +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageApplicationsCategory(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageApplicationsCategory(message) + } +} + +#[derive(Clone, Debug)] +pub struct Page { + entity: page::Entity, + flatpak_apps: Vec, + app_details_page: page::Entity, + animation_state: u8, +} + +impl Default for Page { + fn default() -> Self { + Self { + entity: page::Entity::null(), + flatpak_apps: Vec::new(), + app_details_page: page::Entity::null(), + animation_state: 0, + } + } +} + +impl page::AutoBind for Page { + fn sub_pages( + mut page: page::Insert, + ) -> page::Insert { + let app_details = page.sub_page_with_id::(); + + let model = page.model.page_mut::().unwrap(); + model.app_details_page = app_details; + + page + } +} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(flatpak_apps())]) + } + + fn info(&self) -> page::Info { + page::Info::new("storage-applications", "application-default-symbolic") + .title(fl!("storage-category-apps")) + } + + fn on_enter(&mut self) -> Task { + // Data is managed by parent storage page, just display what we have + Task::none() + } + + fn context_drawer(&self) -> Option> { + None + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + // Animate while any app is loading + if self.flatpak_apps.iter().any(|app| app.loading) { + cosmic::iced::time::every(Duration::from_millis(500)) + .map(|_| crate::pages::Message::StorageApplicationsCategory(Message::AnimationTick)) + } else { + Subscription::none() + } + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::app::Task { + match message { + Message::LoadApps(apps) => { + let apps_to_load = apps.clone(); + self.flatpak_apps = apps; + + // Trigger background task to calculate sizes + return cosmic::Task::future(async move { + Message::LoadAppsWithSizes(StorageInfo::load_flatpak_apps_with_sizes( + apps_to_load, + )) + }) + .map(crate::app::Message::from) + .map(Into::into); + } + Message::LoadAppsWithSizes(apps_with_sizes) | Message::SetApps(apps_with_sizes) => { + self.flatpak_apps = apps_with_sizes; + } + Message::SelectApp(app_id) => { + // Find the app and send it to the app details page + if let Some(app) = self + .flatpak_apps + .iter() + .find(|a| a.app_id == app_id) + .cloned() + { + let load_app_task = cosmic::task::message(crate::app::Message::from( + crate::pages::Message::StorageAppDetails( + app_details::Message::LoadAppDetails(app), + ), + )); + + let navigate_task = + cosmic::task::message(crate::app::Message::Page(self.app_details_page)); + + return cosmic::Task::batch(vec![load_app_task, navigate_task]); + } + } + Message::AnimationTick => { + self.animation_state = (self.animation_state + 1) % 3; + } + } + + Task::none() + } +} + +fn flatpak_apps() -> Section { + Section::default() + .title(fl!("storage-flatpak-apps")) + .view::(move |_binder, page, section| { + let mut section_widget = settings::section().title(§ion.title); + let animation_state = page.animation_state; + + if page.flatpak_apps.is_empty() { + section_widget = section_widget.add(settings::item( + fl!("storage-flatpak-apps-none"), + text::caption(fl!("storage-flatpak-apps-none-desc")), + )); + } else { + for app in &page.flatpak_apps { + let app_id = app.app_id.clone(); + + // Show spinner if still loading, otherwise show size + let size_element: cosmic::Element = if app.loading { + loading_spinner(animation_state) + } else { + text::body(format_bytes(app.total_size())).into() + }; + + // Create a compact row similar to flex_item but clickable + let app_row = row::with_capacity(4) + .spacing(12) + .align_y(Alignment::Center) + .push(icon::from_name(&*app.icon).size(24)) + .push(text::body(&app.name).width(Length::Fill)) + .push(size_element) + .push(icon::from_name("go-next-symbolic").size(16)); + + let app_button = button::custom(app_row) + .padding([12, 16]) + .on_press(Message::SelectApp(app_id)) + .width(Length::Fill) + .class(cosmic::theme::Button::MenuItem); + + section_widget = section_widget.add(app_button); + } + } + + section_widget + .apply(cosmic::Element::from) + .map(crate::pages::Message::StorageApplicationsCategory) + }) +} diff --git a/cosmic-settings/src/pages/system/storage/home_category.rs b/cosmic-settings/src/pages/system/storage/home_category.rs new file mode 100644 index 000000000..55989a89c --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/home_category.rs @@ -0,0 +1,264 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::iced::Subscription; +use cosmic::widget::settings; +use cosmic::{Apply, Task}; +use cosmic_settings_page::{self as page, Section, section}; +use slab::Slab; +use slotmap::SlotMap; +use std::time::Duration; + +use super::{HomeCategory, utils::loading_or_size_item}; + +#[derive(Clone, Debug)] +pub enum Message { + LoadData(HomeCategory), + SetData { data: HomeCategory, loading: bool }, + FieldUpdate(crate::pages::system::storage::HomeFieldUpdate), + AnimationTick, +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageHomeCategory(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageHomeCategory(message) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Page { + entity: page::Entity, + home_category: HomeCategory, + loading: bool, + animation_state: u8, + // Track which individual fields are still loading (false = loading, true = loaded) + fields_loaded: FieldsLoaded, +} + +#[derive(Clone, Debug)] +struct FieldsLoaded { + documents: bool, + downloads: bool, + pictures: bool, + videos: bool, + music: bool, + desktop: bool, + other: bool, +} + +impl Default for FieldsLoaded { + fn default() -> Self { + Self { + documents: false, + downloads: false, + pictures: false, + videos: false, + music: false, + desktop: false, + other: false, + } + } +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(home_details())]) + } + + fn info(&self) -> page::Info { + page::Info::new("storage-home", "user-home-symbolic").title(fl!("storage-category-home")) + } + + fn on_enter(&mut self) -> Task { + // Data is managed by parent storage page, just display what we have + Task::none() + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + if self.loading { + cosmic::iced::time::every(Duration::from_millis(500)) + .map(|_| crate::pages::Message::StorageHomeCategory(Message::AnimationTick)) + } else { + Subscription::none() + } + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::app::Task { + match message { + Message::LoadData(data) => { + self.home_category = data; + self.loading = false; + // Mark all fields as loaded + self.fields_loaded = FieldsLoaded { + documents: true, + downloads: true, + pictures: true, + videos: true, + music: true, + desktop: true, + other: true, + }; + } + Message::SetData { data, loading } => { + self.home_category = data; + self.loading = loading; + // If not loading anymore, mark all as loaded + if !loading { + self.fields_loaded = FieldsLoaded { + documents: true, + downloads: true, + pictures: true, + videos: true, + music: true, + desktop: true, + other: true, + }; + } + } + Message::FieldUpdate(field_update) => { + use crate::pages::system::storage::HomeFieldUpdate; + // Update the specific field and mark it as loaded + match field_update { + HomeFieldUpdate::Documents(size) => { + self.home_category.documents = size; + self.fields_loaded.documents = true; + } + HomeFieldUpdate::Downloads(size) => { + self.home_category.downloads = size; + self.fields_loaded.downloads = true; + } + HomeFieldUpdate::Pictures(size) => { + self.home_category.pictures = size; + self.fields_loaded.pictures = true; + } + HomeFieldUpdate::Videos(size) => { + self.home_category.videos = size; + self.fields_loaded.videos = true; + } + HomeFieldUpdate::Music(size) => { + self.home_category.music = size; + self.fields_loaded.music = true; + } + HomeFieldUpdate::Desktop(size) => { + self.home_category.desktop = size; + self.fields_loaded.desktop = true; + } + HomeFieldUpdate::Other(size) => { + self.home_category.other = size; + self.fields_loaded.other = true; + } + } + + // Check if all fields are loaded + if self.fields_loaded.documents + && self.fields_loaded.downloads + && self.fields_loaded.pictures + && self.fields_loaded.videos + && self.fields_loaded.music + && self.fields_loaded.desktop + && self.fields_loaded.other + { + self.loading = false; + } + } + Message::AnimationTick => { + self.animation_state = (self.animation_state + 1) % 3; + } + } + + Task::none() + } +} + +fn home_details() -> Section { + let mut descriptions = Slab::new(); + + let documents_label = descriptions.insert(fl!("storage-home-documents")); + let downloads_label = descriptions.insert(fl!("storage-home-downloads")); + let pictures_label = descriptions.insert(fl!("storage-home-pictures")); + let videos_label = descriptions.insert(fl!("storage-home-videos")); + let music_label = descriptions.insert(fl!("storage-home-music")); + let desktop_label = descriptions.insert(fl!("storage-home-desktop")); + let other_label = descriptions.insert(fl!("storage-home-other")); + let total_label = descriptions.insert(fl!("storage-app-total")); + + Section::default() + .title(fl!("storage-category-home")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let desc = §ion.descriptions; + let home = &page.home_category; + let fields_loaded = &page.fields_loaded; + let animation_state = page.animation_state; + + settings::section() + .title(§ion.title) + .add(loading_or_size_item( + &desc[documents_label], + home.documents, + !fields_loaded.documents, + animation_state, + )) + .add(loading_or_size_item( + &desc[downloads_label], + home.downloads, + !fields_loaded.downloads, + animation_state, + )) + .add(loading_or_size_item( + &desc[pictures_label], + home.pictures, + !fields_loaded.pictures, + animation_state, + )) + .add(loading_or_size_item( + &desc[videos_label], + home.videos, + !fields_loaded.videos, + animation_state, + )) + .add(loading_or_size_item( + &desc[music_label], + home.music, + !fields_loaded.music, + animation_state, + )) + .add(loading_or_size_item( + &desc[desktop_label], + home.desktop, + !fields_loaded.desktop, + animation_state, + )) + .add(loading_or_size_item( + &desc[other_label], + home.other, + !fields_loaded.other, + animation_state, + )) + .add(loading_or_size_item( + &desc[total_label], + home.total_size(), + page.loading, + animation_state, + )) + .apply(cosmic::Element::from) + .map(crate::pages::Message::StorageHomeCategory) + }) +} diff --git a/cosmic-settings/src/pages/system/storage/models.rs b/cosmic-settings/src/pages/system/storage/models.rs new file mode 100644 index 000000000..33207e0b4 --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/models.rs @@ -0,0 +1,580 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +//! Data models for storage management + +use super::utils::{FlatpakCache, get_directory_size, is_flatpak_available, parse_size_string}; + +#[derive(Clone, Debug, Default)] +pub struct FlatpakApp { + pub name: String, + pub app_id: String, + pub installed_size: u64, + pub data_size: u64, + pub icon: String, + pub version: String, + pub developer: String, + pub loading: bool, // true while sizes are being calculated +} + +impl FlatpakApp { + pub fn total_size(&self) -> u64 { + self.installed_size + self.data_size + } +} + +#[derive(Clone, Debug, Default)] +pub struct SystemCategory { + pub system_files: u64, // /usr, /lib, etc. + pub package_cache: u64, // /var/cache/dnf, /var/cache/apt + pub system_logs: u64, // /var/log + pub system_cache: u64, // /var/cache (excluding package cache) + pub boot_files: u64, // /boot + pub flatpak_runtimes: u64, // flatpak runtimes (user + system) +} + +impl SystemCategory { + pub fn total_size(&self) -> u64 { + self.system_files + + self.package_cache + + self.system_logs + + self.system_cache + + self.boot_files + + self.flatpak_runtimes + } + + // Individual field loaders for streaming updates + pub fn load_system_files() -> u64 { + super::utils::get_rpm_package_size() + .max(super::utils::get_dpkg_package_size()) + .max( + get_directory_size("/usr") + + get_directory_size("/lib") + + get_directory_size("/lib64") + + get_directory_size("/opt"), + ) + } + + pub fn load_boot_files() -> u64 { + get_directory_size("/boot") + } + + pub fn load_system_logs() -> u64 { + get_directory_size("/var/log") + } + + pub fn load_package_cache() -> u64 { + get_directory_size("/var/cache/dnf") + + get_directory_size("/var/cache/libdnf5") + + get_directory_size("/var/cache/PackageKit") + + get_directory_size("/var/cache/apt") + } + + pub fn load_flatpak_runtimes() -> u64 { + Self::get_flatpak_runtime_size() + } + + pub fn load_system_cache() -> (u64, u64) { + let total_cache = get_directory_size("/var/cache"); + let package_cache = Self::load_package_cache(); + (total_cache, package_cache) + } + + pub fn load() -> Self { + use jwalk::rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + + let total_cache = get_directory_size("/var/cache"); + + // Define tasks to run in parallel + let tasks = [ + "boot_files", + "system_logs", + "package_cache", + "flatpak_runtimes", + "system_files", + ]; + + let results: Vec<(_, u64)> = tasks + .par_iter() + .map(|&task| { + let size = match task { + "boot_files" => get_directory_size("/boot"), + "system_logs" => get_directory_size("/var/log"), + "package_cache" => { + get_directory_size("/var/cache/dnf") + + get_directory_size("/var/cache/libdnf5") + + get_directory_size("/var/cache/PackageKit") + + get_directory_size("/var/cache/apt") + } + "flatpak_runtimes" => Self::get_flatpak_runtime_size(), + "system_files" => super::utils::get_rpm_package_size() + .max(super::utils::get_dpkg_package_size()) + .max( + get_directory_size("/usr") + + get_directory_size("/lib") + + get_directory_size("/lib64") + + get_directory_size("/opt"), + ), + _ => 0, + }; + (task, size) + }) + .collect(); + + let mut cat = SystemCategory::default(); + for (task, size) in results { + match task { + "boot_files" => cat.boot_files = size, + "system_logs" => cat.system_logs = size, + "package_cache" => cat.package_cache = size, + "flatpak_runtimes" => cat.flatpak_runtimes = size, + "system_files" => cat.system_files = size, + _ => {} + } + } + + cat.system_cache = total_cache.saturating_sub(cat.package_cache); + + cat + } + + fn get_flatpak_runtime_size() -> u64 { + use std::process::Command; + + if !is_flatpak_available() { + return 0; + } + + let Ok(output) = Command::new("flatpak") + .args(["list", "--runtime", "--columns=ref,size"]) + .output() + else { + return 0; + }; + + let Ok(stdout) = String::from_utf8(output.stdout) else { + return 0; + }; + + stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let size_parts = &parts[1..]; + if size_parts.len() >= 2 { + Some(parse_size_string(&format!( + "{} {}", + size_parts[0], size_parts[1] + ))) + } else if size_parts.len() == 1 { + Some(parse_size_string(size_parts[0])) + } else { + None + } + } else { + None + } + }) + .sum() + } +} + +#[derive(Clone, Debug, Default)] +pub struct HomeCategory { + pub documents: u64, + pub downloads: u64, + pub pictures: u64, + pub videos: u64, + pub music: u64, + pub desktop: u64, + pub other: u64, +} + +impl HomeCategory { + pub fn total_size(&self) -> u64 { + self.documents + + self.downloads + + self.pictures + + self.videos + + self.music + + self.desktop + + self.other + } + + // Individual field loaders for streaming updates + pub fn load_documents() -> u64 { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + get_directory_size(&home_dir.join("Documents").to_string_lossy().to_string()) + } + + pub fn load_downloads() -> u64 { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + get_directory_size(&home_dir.join("Downloads").to_string_lossy().to_string()) + } + + pub fn load_pictures() -> u64 { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + get_directory_size(&home_dir.join("Pictures").to_string_lossy().to_string()) + } + + pub fn load_videos() -> u64 { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + get_directory_size(&home_dir.join("Videos").to_string_lossy().to_string()) + } + + pub fn load_music() -> u64 { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + get_directory_size(&home_dir.join("Music").to_string_lossy().to_string()) + } + + pub fn load_desktop() -> u64 { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + get_directory_size(&home_dir.join("Desktop").to_string_lossy().to_string()) + } + + // Load total_home and var_dir (used to calculate "other" without re-scanning directories) + pub fn load_total_and_var() -> (u64, u64) { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return (0, 0), + }; + + let total_home = get_directory_size(&home_dir.to_string_lossy().to_string()); + let var_dir = get_directory_size(&home_dir.join(".var").to_string_lossy().to_string()); + + (total_home, var_dir) + } + + pub fn load() -> Self { + use jwalk::rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return HomeCategory::default(), + }; + + let total_home = get_directory_size(&home_dir.to_string_lossy().to_string()); + let var_dir = get_directory_size(&home_dir.join(".var").to_string_lossy().to_string()); + + // Define directories to scan in parallel + let dirs_to_scan = [ + ("Documents", home_dir.join("Documents")), + ("Downloads", home_dir.join("Downloads")), + ("Pictures", home_dir.join("Pictures")), + ("Videos", home_dir.join("Videos")), + ("Music", home_dir.join("Music")), + ("Desktop", home_dir.join("Desktop")), + ]; + + let sizes: Vec<(_, u64)> = dirs_to_scan + .par_iter() + .map(|(name, path)| { + ( + *name, + get_directory_size(&path.to_string_lossy().to_string()), + ) + }) + .collect(); + + let mut cat = HomeCategory::default(); + for (name, size) in sizes { + match name { + "Documents" => cat.documents = size, + "Downloads" => cat.downloads = size, + "Pictures" => cat.pictures = size, + "Videos" => cat.videos = size, + "Music" => cat.music = size, + "Desktop" => cat.desktop = size, + _ => {} + } + } + + cat.other = total_home.saturating_sub( + cat.documents + + cat.downloads + + cat.pictures + + cat.videos + + cat.music + + cat.desktop + + var_dir, + ); + + cat + } +} + +#[derive(Clone, Debug, Default)] +pub struct StorageInfo { + pub total: u64, + pub used: u64, + pub available: u64, + pub system: u64, + pub home: u64, + pub applications: u64, + pub other: u64, + pub flatpak_apps: Vec, +} + +impl StorageInfo { + pub fn load() -> Self { + let mut info = StorageInfo::default(); + + let disks = sysinfo::Disks::new_with_refreshed_list(); + + for disk in disks.list() { + // Focus on the root filesystem + if disk.mount_point().to_str() == Some("/") { + info.total = disk.total_space(); + info.available = disk.available_space(); + info.used = info.total.saturating_sub(info.available); + break; + } + } + + // If we didn't find root, use the first disk + if info.total == 0 { + if let Some(disk) = disks.list().first() { + info.total = disk.total_space(); + info.available = disk.available_space(); + info.used = info.total.saturating_sub(info.available); + } + } + + // Quick load Flatpak apps (without sizes for immediate display) + info.flatpak_apps = Self::load_flatpak_apps_quick(); + + // Don't calculate category sizes here - will be done in background + // This allows the Flatpak app list to show immediately + info.system = 0; + info.home = 0; + info.applications = 0; + info.other = 0; + + info + } + + pub fn load_category_details() -> (SystemCategory, HomeCategory) { + let system_category = SystemCategory::load(); + let home_category = HomeCategory::load(); + + (system_category, home_category) + } + + pub fn load_flatpak_apps_quick() -> Vec { + use std::process::Command; + + let mut apps = Vec::new(); + + if !is_flatpak_available() { + return apps; + } + + let Ok(output) = Command::new("flatpak") + .args(["list", "--app", "--columns=application,name"]) + .output() + else { + return apps; + }; + + let Ok(stdout) = String::from_utf8(output.stdout) else { + return apps; + }; + + let desktop_entries = Self::get_desktop_entries(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 2 { + let app_id = parts[0].to_string(); + let name = parts[1].to_string(); + + let icon = desktop_entries + .iter() + .find(|(id, _, _)| id == &app_id) + .map(|(_, _, icon)| icon.clone()) + .unwrap_or_else(|| "application-default".to_string()); + + apps.push(FlatpakApp { + name, + app_id, + installed_size: 0, + data_size: 0, + icon, + version: String::new(), + developer: String::new(), + loading: true, // Sizes not yet calculated + }); + } + } + + let cache = FlatpakCache::load(); + apps.sort_by(|a, b| { + let pos_a = cache.get_position(&a.app_id).unwrap_or(usize::MAX); + let pos_b = cache.get_position(&b.app_id).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + + apps + } + + pub fn load_flatpak_apps_with_sizes(apps: Vec) -> Vec { + use jwalk::rayon::iter::{IntoParallelIterator, ParallelIterator}; + + let desktop_entries = Self::get_desktop_entries(); + + let mut apps_with_sizes: Vec = apps + .into_par_iter() + .map(|mut app| { + let (installed_size, version, developer) = Self::get_flatpak_app_info(&app.app_id); + app.installed_size = installed_size; + app.version = version; + app.developer = developer; + + app.data_size = Self::get_flatpak_data_size(&app.app_id); + + if app.icon.is_empty() || app.icon == "application-default" { + app.icon = desktop_entries + .iter() + .find(|(id, _, _)| id == &app.app_id) + .map(|(_, _, icon)| icon.clone()) + .unwrap_or_else(|| "application-default".to_string()); + } + + app.loading = false; + app + }) + .collect(); + + apps_with_sizes.sort_by(|a, b| b.total_size().cmp(&a.total_size())); + + let cache = FlatpakCache { + app_order: apps_with_sizes + .iter() + .map(|app| app.app_id.clone()) + .collect(), + }; + cache.save(); + + apps_with_sizes + } + + fn get_desktop_entries() -> Vec<(String, String, String)> { + use freedesktop_desktop_entry::{Iter, default_paths}; + + let mut entries = Vec::new(); + + let locales = std::env::var("LANG") + .ok() + .and_then(|lang| lang.split('.').next().map(String::from)) + .into_iter() + .collect::>(); + + for entry in Iter::new(default_paths()).entries(Some(&locales)) { + let app_id = entry.appid.to_string(); + let name = entry.name(&locales).unwrap_or_default().to_string(); + let icon = entry.icon().unwrap_or("application-default").to_string(); + + entries.push((app_id, name, icon)); + } + + entries + } + + fn get_flatpak_app_info(app_id: &str) -> (u64, String, String) { + use std::process::Command; + + let Ok(output) = Command::new("flatpak").args(["info", app_id]).output() else { + return (0, String::new(), String::new()); + }; + + let Ok(stdout) = String::from_utf8(output.stdout) else { + return (0, String::new(), String::new()); + }; + + let mut installed_size = 0u64; + let mut version = String::new(); + let mut developer = String::new(); + + for line in stdout.lines() { + let line = line.trim(); + + if line.starts_with("Installed:") { + // Parse the size from the line + // Format: "Installed: 123.4 MB" or " Installed: 441,6 MB" + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 2 { + let size_str = parts[1].trim(); + installed_size = parse_size_string(size_str); + } + } else if line.starts_with("Version:") { + if let Some(v) = line.split(':').nth(1) { + version = v.trim().to_string(); + } + } else if line.starts_with("Subject:") { + if let Some(subject) = line.split(':').nth(1) { + let subject = subject.trim(); + if let Some(by_pos) = subject.find(" by ") { + developer = subject[by_pos + 4..].trim().to_string(); + } + } + } else if line.starts_with("Origin:") && developer.is_empty() { + if let Some(origin) = line.split(':').nth(1) { + let origin = origin.trim(); + if origin != "flathub" && !origin.is_empty() { + developer = origin.to_string(); + } + } + } + } + + if developer.is_empty() { + developer = app_id + .split('.') + .nth(1) + .map(|s| { + s.chars() + .next() + .map(|c| c.to_uppercase().to_string() + &s[1..]) + .unwrap_or_default() + }) + .unwrap_or_default(); + } + + (installed_size, version, developer) + } + + fn get_flatpak_data_size(app_id: &str) -> u64 { + // Flatpak apps store their data in ~/.var/app/APP_ID + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => return 0, + }; + + let data_path = home_dir.join(".var/app").join(app_id); + + if !data_path.exists() { + return 0; + } + + get_directory_size(data_path.to_str().unwrap_or("")) + } +} diff --git a/cosmic-settings/src/pages/system/storage/other_category.rs b/cosmic-settings/src/pages/system/storage/other_category.rs new file mode 100644 index 000000000..cb395bfb6 --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/other_category.rs @@ -0,0 +1,118 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::iced::Subscription; +use cosmic::widget::settings; +use cosmic::{Apply, Task}; +use cosmic_settings_page::{self as page, Section, section}; +use slab::Slab; +use slotmap::SlotMap; +use std::time::Duration; + +use super::utils::loading_or_size_item; + +#[derive(Clone, Debug)] +pub enum Message { + LoadData(u64), + SetData { size: u64, loading: bool }, + AnimationTick, +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageOtherCategory(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageOtherCategory(message) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Page { + entity: page::Entity, + other_size: u64, + loading: bool, + animation_state: u8, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(other_details())]) + } + + fn info(&self) -> page::Info { + page::Info::new("storage-other", "folder-symbolic").title(fl!("storage-category-other")) + } + + fn on_enter(&mut self) -> Task { + // Data is managed by parent storage page, just display what we have + Task::none() + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + if self.loading { + cosmic::iced::time::every(Duration::from_millis(500)) + .map(|_| crate::pages::Message::StorageOtherCategory(Message::AnimationTick)) + } else { + Subscription::none() + } + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::app::Task { + match message { + Message::LoadData(size) => { + self.other_size = size; + self.loading = false; + } + Message::SetData { size, loading } => { + self.other_size = size; + self.loading = loading; + } + Message::AnimationTick => { + self.animation_state = (self.animation_state + 1) % 3; + } + } + + Task::none() + } +} + +fn other_details() -> Section { + let mut descriptions = Slab::new(); + + let total_label = descriptions.insert(fl!("storage-app-total")); + + Section::default() + .title(fl!("storage-category-other")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let desc = §ion.descriptions; + let loading = page.loading; + let animation_state = page.animation_state; + + settings::section() + .title(§ion.title) + .add(loading_or_size_item( + &desc[total_label], + page.other_size, + loading, + animation_state, + )) + .apply(cosmic::Element::from) + .map(crate::pages::Message::StorageOtherCategory) + }) +} diff --git a/cosmic-settings/src/pages/system/storage/system_category.rs b/cosmic-settings/src/pages/system/storage/system_category.rs new file mode 100644 index 000000000..348988b54 --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/system_category.rs @@ -0,0 +1,264 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::iced::Subscription; +use cosmic::widget::settings; +use cosmic::{Apply, Task}; +use cosmic_settings_page::{self as page, Section, section}; +use slab::Slab; +use slotmap::SlotMap; +use std::time::Duration; + +use super::{SystemCategory, utils::loading_or_size_item}; + +#[derive(Clone, Debug)] +pub enum Message { + LoadData(SystemCategory), + SetData { data: SystemCategory, loading: bool }, + FieldUpdate(crate::pages::system::storage::SystemFieldUpdate), + AnimationTick, +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageSystemCategory(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::StorageSystemCategory(message) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Page { + entity: page::Entity, + system_category: SystemCategory, + loading: bool, + animation_state: u8, + fields_loaded: FieldsLoaded, +} + +#[derive(Clone, Debug)] +struct FieldsLoaded { + system_files: bool, + package_cache: bool, + system_logs: bool, + system_cache: bool, + boot_files: bool, + flatpak_runtimes: bool, +} + +impl Default for FieldsLoaded { + fn default() -> Self { + Self { + system_files: false, + package_cache: false, + system_logs: false, + system_cache: false, + boot_files: false, + flatpak_runtimes: false, + } + } +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(system_details())]) + } + + fn info(&self) -> page::Info { + page::Info::new("storage-system", "folder-symbolic").title(fl!("storage-category-system")) + } + + fn on_enter(&mut self) -> Task { + // Data is managed by parent storage page, just display what we have + Task::none() + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + if self.loading { + cosmic::iced::time::every(Duration::from_millis(500)) + .map(|_| crate::pages::Message::StorageSystemCategory(Message::AnimationTick)) + } else { + Subscription::none() + } + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::app::Task { + match message { + Message::LoadData(data) => { + self.system_category = data; + self.loading = false; + self.fields_loaded = FieldsLoaded { + system_files: true, + package_cache: true, + system_logs: true, + system_cache: true, + boot_files: true, + flatpak_runtimes: true, + }; + } + Message::SetData { data, loading } => { + let old_data = self.system_category.clone(); + self.system_category = data; + self.loading = loading; + + if self.system_category.system_files > 0 && old_data.system_files == 0 { + self.fields_loaded.system_files = true; + } + if self.system_category.package_cache > 0 && old_data.package_cache == 0 { + self.fields_loaded.package_cache = true; + } + if self.system_category.system_logs > 0 && old_data.system_logs == 0 { + self.fields_loaded.system_logs = true; + } + if self.system_category.system_cache > 0 && old_data.system_cache == 0 { + self.fields_loaded.system_cache = true; + } + if self.system_category.boot_files > 0 && old_data.boot_files == 0 { + self.fields_loaded.boot_files = true; + } + if self.system_category.flatpak_runtimes > 0 && old_data.flatpak_runtimes == 0 { + self.fields_loaded.flatpak_runtimes = true; + } + + if !loading { + self.fields_loaded = FieldsLoaded { + system_files: true, + package_cache: true, + system_logs: true, + system_cache: true, + boot_files: true, + flatpak_runtimes: true, + }; + } + } + Message::FieldUpdate(field_update) => { + use crate::pages::system::storage::SystemFieldUpdate; + match field_update { + SystemFieldUpdate::SystemFiles(size) => { + self.system_category.system_files = size; + self.fields_loaded.system_files = true; + } + SystemFieldUpdate::PackageCache(size) => { + self.system_category.package_cache = size; + self.fields_loaded.package_cache = true; + } + SystemFieldUpdate::SystemLogs(size) => { + self.system_category.system_logs = size; + self.fields_loaded.system_logs = true; + } + SystemFieldUpdate::SystemCache(size) => { + self.system_category.system_cache = size; + self.fields_loaded.system_cache = true; + } + SystemFieldUpdate::BootFiles(size) => { + self.system_category.boot_files = size; + self.fields_loaded.boot_files = true; + } + SystemFieldUpdate::FlatpakRuntimes(size) => { + self.system_category.flatpak_runtimes = size; + self.fields_loaded.flatpak_runtimes = true; + } + } + + if self.fields_loaded.system_files + && self.fields_loaded.package_cache + && self.fields_loaded.system_logs + && self.fields_loaded.system_cache + && self.fields_loaded.boot_files + && self.fields_loaded.flatpak_runtimes + { + self.loading = false; + } + } + Message::AnimationTick => { + self.animation_state = (self.animation_state + 1) % 3; + } + } + + Task::none() + } +} + +fn system_details() -> Section { + let mut descriptions = Slab::new(); + + let system_files_label = descriptions.insert(fl!("storage-system-files")); + let package_cache_label = descriptions.insert(fl!("storage-system-package-cache")); + let system_logs_label = descriptions.insert(fl!("storage-system-logs")); + let system_cache_label = descriptions.insert(fl!("storage-system-cache")); + let boot_files_label = descriptions.insert(fl!("storage-system-boot")); + let flatpak_runtimes_label = descriptions.insert(fl!("storage-system-flatpak-runtimes")); + let total_label = descriptions.insert(fl!("storage-app-total")); + + Section::default() + .title(fl!("storage-category-system")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let desc = §ion.descriptions; + let sys = &page.system_category; + let fields_loaded = &page.fields_loaded; + let animation_state = page.animation_state; + + settings::section() + .title(§ion.title) + .add(loading_or_size_item( + &desc[system_files_label], + sys.system_files, + !fields_loaded.system_files, + animation_state, + )) + .add(loading_or_size_item( + &desc[package_cache_label], + sys.package_cache, + !fields_loaded.package_cache, + animation_state, + )) + .add(loading_or_size_item( + &desc[system_logs_label], + sys.system_logs, + !fields_loaded.system_logs, + animation_state, + )) + .add(loading_or_size_item( + &desc[system_cache_label], + sys.system_cache, + !fields_loaded.system_cache, + animation_state, + )) + .add(loading_or_size_item( + &desc[boot_files_label], + sys.boot_files, + !fields_loaded.boot_files, + animation_state, + )) + .add(loading_or_size_item( + &desc[flatpak_runtimes_label], + sys.flatpak_runtimes, + !fields_loaded.flatpak_runtimes, + animation_state, + )) + .add(loading_or_size_item( + &desc[total_label], + sys.total_size(), + page.loading, + animation_state, + )) + .apply(cosmic::Element::from) + .map(crate::pages::Message::StorageSystemCategory) + }) +} diff --git a/cosmic-settings/src/pages/system/storage/utils.rs b/cosmic-settings/src/pages/system/storage/utils.rs new file mode 100644 index 000000000..d64df8b7e --- /dev/null +++ b/cosmic-settings/src/pages/system/storage/utils.rs @@ -0,0 +1,276 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +//! Common utilities for storage management + +use cosmic::iced::Color; +use std::process::Command; +use std::sync::OnceLock; + +use super::CategoryType; + +// Cache for command availability checks +static FLATPAK_AVAILABLE: OnceLock = OnceLock::new(); +static BTRFS_AVAILABLE: OnceLock = OnceLock::new(); + +/// Check if a command is available in PATH +fn is_command_available(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// Check if flatpak is available (cached) +pub fn is_flatpak_available() -> bool { + *FLATPAK_AVAILABLE.get_or_init(|| is_command_available("flatpak")) +} + +/// Check if btrfs is available (cached) +pub fn is_btrfs_available() -> bool { + *BTRFS_AVAILABLE.get_or_init(|| is_command_available("btrfs")) +} + +// Category color constants +pub const COLOR_SYSTEM: Color = Color::from_rgb(0.40, 0.62, 0.93); // Blue +pub const COLOR_HOME: Color = Color::from_rgb(0.95, 0.61, 0.07); // Orange +pub const COLOR_APPLICATIONS: Color = Color::from_rgb(0.45, 0.82, 0.46); // Green +pub const COLOR_OTHER: Color = Color::from_rgb(0.70, 0.70, 0.70); // Gray +pub const COLOR_AVAILABLE: Color = Color::from_rgb(0.2, 0.2, 0.2); // Dark gray + +pub fn category_color(category: &CategoryType) -> Color { + match category { + CategoryType::System => COLOR_SYSTEM, + CategoryType::Home => COLOR_HOME, + CategoryType::Applications => COLOR_APPLICATIONS, + CategoryType::Other => COLOR_OTHER, + } +} + +/// Create a loading spinner indicator with animation +pub fn loading_spinner<'a, Message: 'a + 'static>( + animation_state: u8, +) -> cosmic::Element<'a, Message> { + use cosmic::widget::text; + // Animate through ., .., ... + let dots = match animation_state % 3 { + 0 => ".", + 1 => "..", + _ => "...", + }; + text::body(dots).into() +} + +/// Create a settings item that shows either a loading spinner or formatted size +pub fn loading_or_size_item<'a, Message: 'a + 'static + Clone>( + label: &'a str, + size: u64, + loading: bool, + animation_state: u8, +) -> cosmic::Element<'a, Message> { + use cosmic::widget::settings; + + if loading { + settings::flex_item(label, loading_spinner(animation_state)).into() + } else { + settings::flex_item(label, cosmic::widget::text::body(format_bytes(size))).into() + } +} + +/// Format bytes into human-readable string using IEC binary prefixes (e.g., "1.5 GiB") +pub fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + // Use 0 decimal places for bytes, 1 for everything else + if unit_index == 0 { + format!("{:.0} {}", size, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } +} + +/// Parse a size string like "1.5 GB" or "1.5 GiB" into bytes +pub fn parse_size_string(size_str: &str) -> u64 { + let parts: Vec<&str> = size_str.split_whitespace().collect(); + if parts.len() < 2 { + return 0; + } + + let Ok(number) = parts[0].replace(',', ".").parse::() else { + return 0; + }; + + let multiplier = match parts[1] { + "B" | "bytes" => 1.0, + // Binary (IEC) units + "KiB" | "kB" | "KB" => 1024.0, + "MiB" | "MB" => 1024.0 * 1024.0, + "GiB" | "GB" => 1024.0 * 1024.0 * 1024.0, + "TiB" | "TB" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + _ => 1.0, + }; + + (number * multiplier) as u64 +} + +/// Detect filesystem type for a given path +pub fn get_filesystem_type(path: &str) -> Option { + let output = Command::new("stat") + .args(["-f", "-c", "%T", path]) + .output() + .ok()?; + + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_lowercase()) +} + +/// Calculate directory size with optimizations for different filesystems +pub fn get_directory_size(path: &str) -> u64 { + use jwalk::WalkDir; + + // Use btrfs optimization if available and filesystem is btrfs + if is_btrfs_available() { + if let Some(fs_type) = get_filesystem_type(path) { + if fs_type == "btrfs" { + if let Ok(output) = Command::new("btrfs") + .args(["filesystem", "du", "-s", "--raw", path]) + .output() + { + if let Ok(stdout) = String::from_utf8(output.stdout) { + if let Some(line) = stdout.lines().nth(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if !parts.is_empty() { + if let Ok(size) = parts[0].parse::() { + return size; + } + } + } + } + } + } + } + } + + // Use jwalk for parallel directory traversal (4-8x faster than sequential) + // Utilize available CPU cores (capped at 8 for reasonable memory usage) + let num_threads = std::thread::available_parallelism() + .map(|n| n.get().min(8)) + .unwrap_or(4); + + WalkDir::new(path) + .skip_hidden(false) // Include hidden files + .parallelism(jwalk::Parallelism::RayonNewPool(num_threads)) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + // Use file_type() - NO extra syscall (uses data from getdents) + // This is 10-100x faster than calling metadata() + entry.file_type().is_file() + }) + .filter_map(|entry| { + // Only call metadata() for files to get size + entry.metadata().ok() + }) + .map(|metadata| metadata.len()) + .sum() +} + +/// Get RPM package total size (Fedora/RHEL) +pub fn get_rpm_package_size() -> u64 { + // Use rpm directly with proper quoting + let Ok(output) = Command::new("rpm") + .args(["-qa", "--queryformat", "%{size}\n"]) + .output() + else { + return 0; + }; + + if let Ok(stdout) = String::from_utf8(output.stdout) { + stdout + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .sum() + } else { + 0 + } +} + +/// Get dpkg package total size (Debian/Ubuntu) +pub fn get_dpkg_package_size() -> u64 { + // Use dpkg-query directly + let Ok(output) = Command::new("dpkg-query") + .args(["-W", "-f=${Installed-Size}\n"]) + .output() + else { + return 0; + }; + + if let Ok(stdout) = String::from_utf8(output.stdout) { + // dpkg returns size in KB, convert to bytes + stdout + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .sum::() + * 1024 + } else { + 0 + } +} + +/// Cache structure for storing app order +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct FlatpakCache { + /// Maps app_id to its previous position (lower = higher priority/larger size) + pub app_order: Vec, +} + +impl FlatpakCache { + fn cache_path() -> Option { + dirs::cache_dir().map(|dir| dir.join("cosmic-settings").join("flatpak-order.ron")) + } + + pub fn load() -> Self { + let Some(path) = Self::cache_path() else { + return Self::default(); + }; + + if !path.exists() { + return Self::default(); + } + + match std::fs::read_to_string(&path) { + Ok(contents) => match ron::from_str(&contents) { + Ok(cache) => cache, + Err(_) => Self::default(), + }, + Err(_) => Self::default(), + } + } + + pub fn save(&self) { + let Some(path) = Self::cache_path() else { + return; + }; + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + if let Ok(contents) = ron::to_string(self) { + let _ = std::fs::write(&path, contents); + } + } + + pub fn get_position(&self, app_id: &str) -> Option { + self.app_order.iter().position(|id| id == app_id) + } +} diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index f3c6d4035..56e260be8 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -899,6 +899,47 @@ about-related = Related settings firmware = Firmware .desc = Firmware details +## System: Storage + +storage = Storage + .desc = Device storage information and usage + +storage-overview = Storage Overview +storage-categories = Storage by Category + +storage-category-system = System +storage-category-home = Home +storage-category-apps = Applications +storage-category-other = Other + +storage-flatpak-apps = Flatpak Applications +storage-flatpak-apps-none = No Flatpak applications installed +storage-flatpak-apps-none-desc = Install Flatpak applications to see them here + +storage-app-installed = App Size +storage-app-data = Data & Config +storage-app-total = Total Size +storage-app-details = App Details +storage-app-size-details = Size Details +storage-app-not-found = App not found + +## System Category Details +storage-system-files = System Files & Libraries +storage-system-package-cache = Package Cache +storage-system-logs = System Logs +storage-system-cache = System Cache +storage-system-boot = Boot Files +storage-system-flatpak-runtimes = Flatpak Runtimes + +## Home Category Details +storage-home-documents = Documents +storage-home-downloads = Downloads +storage-home-pictures = Pictures +storage-home-videos = Videos +storage-home-music = Music +storage-home-desktop = Desktop +storage-home-other = Other + ## System: Users users = Users diff --git a/justfile b/justfile index 2b77832eb..44579600c 100644 --- a/justfile +++ b/justfile @@ -68,6 +68,7 @@ check-features: "page-power" \ "page-region" \ "page-sound" \ + "page-storage" \ "page-users" \ "page-window-management" \ "page-workspaces"