diff --git a/.github/scripts/osxcross/README.md b/.github/scripts/osxcross/README.md index 5691f9762..561a190d7 100644 --- a/.github/scripts/osxcross/README.md +++ b/.github/scripts/osxcross/README.md @@ -2,7 +2,7 @@ This container based on alpine 3.17, with the most common build decencies installed, and a built version of [`osxcross`](https://github.com/tpoechtrager/osxcross) plus the macOS SDK 12.3 (Monterey) targeting a minimum compatibility of macOS 10.15 (Catalina) for x86_64 and macOS 11.0 (BigSur) for arm64. -__Image Tag__: macOS SDK version + osxcross commit hash + revision +**Image Tag**: macOS SDK version + osxcross commit hash + revision This container is currently available at: https://hub.docker.com/r/vvasconcellos/osxcross. diff --git a/Cargo.lock b/Cargo.lock index 669e2cede..10d750559 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f472e2b07..1177ad9c2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,7 +30,6 @@ "@sd/client": "workspace:*", "@shopify/flash-list": "1.4.2", "@tanstack/react-query": "^4.29.1", - "byte-size": "^8.1.0", "class-variance-authority": "^0.5.3", "dayjs": "^1.11.8", "expo": "~48.0.19", diff --git a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx index 7d4f2ce47..492cd4c87 100644 --- a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx +++ b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx @@ -1,4 +1,3 @@ -import byteSize from 'byte-size'; import dayjs from 'dayjs'; import { Copy, @@ -13,7 +12,7 @@ import { } from 'phosphor-react-native'; import { PropsWithChildren, useRef } from 'react'; import { Pressable, Text, View, ViewStyle } from 'react-native'; -import { bytesToNumber, getItemFilePath, getItemObject } from '@sd/client'; +import { byteSize, getItemFilePath, getItemObject } from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import FavoriteButton from '~/components/explorer/sections/FavoriteButton'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; @@ -85,12 +84,7 @@ export const ActionsModal = () => { - {filePath?.size_in_bytes_bytes - ? byteSize( - bytesToNumber(filePath.size_in_bytes_bytes) - ).toString() - : 0} - , + {`${byteSize(filePath?.size_in_bytes_bytes)}`}, {' '} diff --git a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx index 3f00b6b0d..f6e6e8892 100644 --- a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx +++ b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx @@ -1,4 +1,3 @@ -import byteSize from 'byte-size'; import dayjs from 'dayjs'; import { Barcode, @@ -13,7 +12,7 @@ import { forwardRef } from 'react'; import { Pressable, Text, View } from 'react-native'; import { ExplorerItem, - bytesToNumber, + byteSize, getItemFilePath, getItemObject, useLibraryQuery @@ -97,13 +96,7 @@ const FileInfoModal = forwardRef((props, ref) => { {/* Duration */} {fullObjectData.data?.media_data?.duration_seconds && ( diff --git a/apps/mobile/src/components/overview/OverviewStats.tsx b/apps/mobile/src/components/overview/OverviewStats.tsx index 33782cb6a..58551f352 100644 --- a/apps/mobile/src/components/overview/OverviewStats.tsx +++ b/apps/mobile/src/components/overview/OverviewStats.tsx @@ -1,8 +1,7 @@ -import byteSize from 'byte-size'; import { FC, useEffect, useState } from 'react'; import { ScrollView, Text, View } from 'react-native'; import RNFS from 'react-native-fs'; -import { Statistics, useLibraryQuery } from '@sd/client'; +import { Statistics, byteSize, useLibraryQuery } from '@sd/client'; import useCounter from '~/hooks/useCounter'; import { tw, twStyle } from '~/lib/tailwind'; @@ -28,9 +27,9 @@ const EMPTY_STATISTICS = { }; const StatItem: FC<{ title: string; bytes: bigint }> = ({ title, bytes }) => { - const { value, unit } = byteSize(Number(bytes)); // TODO: This BigInt to Number conversion will truncate the number if the number is too large. `byteSize` doesn't support BigInt so we are gonna need to come up with a longer term solution at some point. + const { value, unit } = byteSize(bytes); - const count = useCounter({ name: title, end: Number(value) }); + const count = useCounter({ name: title, end: value }); return ( diff --git a/core/Cargo.toml b/core/Cargo.toml index 67c850f72..5c7804177 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -10,8 +10,10 @@ edition = { workspace = true } [features] default = [] -mobile = [] # This feature allows features to be disabled when the Core is running on mobile. -ffmpeg = ["dep:sd-ffmpeg"] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg. +# This feature allows features to be disabled when the Core is running on mobile. +mobile = [] +# This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg. +ffmpeg = ["dep:sd-ffmpeg"] location-watcher = ["dep:notify"] sync-messages = [] heif = ["dep:sd-heif"] @@ -46,6 +48,7 @@ tokio = { workspace = true, features = [ "io-util", "macros", "time", + "process", ] } base64 = "0.21.2" @@ -93,6 +96,9 @@ hex = "0.4.3" int-enum = "0.5.0" tokio-stream = "0.1.14" +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" + [target.'cfg(windows)'.dependencies.winapi-util] version = "0.1.5" diff --git a/core/src/api/libraries.rs b/core/src/api/libraries.rs index 9607b98f2..c6cbc62bf 100644 --- a/core/src/api/libraries.rs +++ b/core/src/api/libraries.rs @@ -2,7 +2,7 @@ use crate::{ library::{LibraryConfig, LibraryName}, prisma::statistics, util::MaybeUndefined, - volume::{get_volumes, save_volume}, + volume::get_volumes, }; use chrono::Utc; @@ -26,25 +26,22 @@ pub(crate) fn mount() -> AlphaRouter { }) .procedure("statistics", { R.with2(library()).query(|(_, library), _: ()| async move { - let _statistics = library - .db - .statistics() - .find_unique(statistics::id::equals(library.node_local_id)) - .exec() - .await?; + // TODO: get from database if library is offline + // let _statistics = library + // .db + // .statistics() + // .find_unique(statistics::id::equals(library.node_local_id)) + // .exec() + // .await?; - // TODO: get from database, not sys - let volumes = get_volumes(); - save_volume(&library).await?; + let volumes = get_volumes().await; + // save_volume(&library).await?; - let mut available_capacity: u64 = 0; let mut total_capacity: u64 = 0; - - if let Ok(volumes) = volumes { - for volume in volumes { - total_capacity += volume.total_capacity; - available_capacity += volume.available_capacity; - } + let mut available_capacity: u64 = 0; + for volume in volumes { + total_capacity += volume.total_capacity; + available_capacity += volume.available_capacity; } let library_db_size = get_size( diff --git a/core/src/api/search.rs b/core/src/api/search.rs index 21faddf0c..1c82f0cae 100644 --- a/core/src/api/search.rs +++ b/core/src/api/search.rs @@ -6,7 +6,7 @@ use crate::{ library::{Category, Library}, location::{ file_path_helper::{check_file_path_exists, IsolatedFilePathData}, - find_location, LocationError, + LocationError, }, object::preview::get_thumb_key, prisma::{self, file_path, location, object, tag, tag_on_object, PrismaClient}, diff --git a/core/src/api/volumes.rs b/core/src/api/volumes.rs index a30b96f7b..07adaaa00 100644 --- a/core/src/api/volumes.rs +++ b/core/src/api/volumes.rs @@ -6,6 +6,6 @@ use super::{Ctx, R}; pub(crate) fn mount() -> AlphaRouter { R.router().procedure("list", { - R.query(|_, _: ()| async move { Ok(get_volumes()?) }) + R.query(|_, _: ()| async move { Ok(get_volumes().await) }) }) } diff --git a/core/src/volume.rs b/core/src/volume.rs index 07ddadee2..0594774de 100644 --- a/core/src/volume.rs +++ b/core/src/volume.rs @@ -1,14 +1,18 @@ -use crate::{ - library::Library, - prisma::volume::{self, *}, -}; +// Adapted from: https://github.com/kimlimjustin/xplorer/blob/f4f3590d06783d64949766cc2975205a3b689a56/src-tauri/src/drives.rs use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use specta::Type; -use std::{fmt::Display, process::Command}; +use std::{ffi::OsString, fmt::Display, path::PathBuf, sync::OnceLock}; use sysinfo::{DiskExt, System, SystemExt}; use thiserror::Error; +use tokio::sync::Mutex; +use tracing::error; + +fn sys_guard() -> &'static Mutex { + static SYS: OnceLock> = OnceLock::new(); + SYS.get_or_init(|| Mutex::new(System::new_all())) +} #[derive(Serialize, Deserialize, Debug, Clone, Type)] #[allow(clippy::upper_case_acronyms)] @@ -31,16 +35,15 @@ impl Display for DiskType { #[serde_as] #[derive(Serialize, Deserialize, Debug, Clone, Type)] pub struct Volume { - pub name: String, - pub mount_point: String, + pub name: OsString, + pub mount_points: Vec, #[specta(type = String)] #[serde_as(as = "DisplayFromStr")] pub total_capacity: u64, #[specta(type = String)] #[serde_as(as = "DisplayFromStr")] pub available_capacity: u64, - pub is_removable: bool, - pub disk_type: Option, + pub disk_type: DiskType, pub file_system: Option, pub is_root_filesystem: bool, } @@ -59,102 +62,307 @@ impl From for rspc::Error { } } -pub async fn save_volume(library: &Library) -> Result<(), VolumeError> { - let volumes = get_volumes()?; +#[cfg(target_os = "linux")] +pub async fn get_volumes() -> Vec { + use std::{collections::HashMap, path::Path}; - // enter all volumes associate with this client add to db - for volume in volumes { - let params = vec![ - disk_type::set(volume.disk_type.map(|t| t.to_string())), - filesystem::set(volume.file_system.clone()), - total_bytes_capacity::set(volume.total_capacity.to_string()), - total_bytes_available::set(volume.available_capacity.to_string()), - ]; + let mut sys = sys_guard().lock().await; + sys.refresh_disks_list(); - library - .db - .volume() - .upsert( - node_id_mount_point_name( - library.node_local_id, - volume.mount_point.to_string(), - volume.name.to_string(), - ), - volume::create( - library.node_local_id, - volume.name, - volume.mount_point, - params.clone(), - ), - params, - ) - .exec() - .await?; - } - // cleanup: remove all unmodified volumes associate with this client + let mut volumes: Vec = Vec::new(); + let mut path_to_volume_index = HashMap::new(); + for disk in sys.disks() { + let disk_name = disk.name(); + let mount_point = disk.mount_point().to_path_buf(); + let file_system = String::from_utf8(disk.file_system().to_vec()) + .map(|s| s.to_uppercase()) + .ok(); + let total_capacity = disk.total_space(); + let available_capacity = disk.available_space(); + let is_root_filesystem = mount_point.is_absolute() && mount_point.parent().is_none(); - Ok(()) -} + let mut disk_path: PathBuf = PathBuf::from(disk_name); + if file_system.as_ref().map(|fs| fs == "ZFS").unwrap_or(false) { + // Use a custom path for ZFS disks to avoid conflicts with normal disks paths + disk_path = Path::new("zfs://").join(disk_path); + } else { + // Ignore non-devices disks (overlay, fuse, tmpfs, etc.) + if !disk_path.starts_with("/dev") { + continue; + } -// TODO: Error handling in this function -pub fn get_volumes() -> Result, VolumeError> { - System::new_all() - .disks() - .iter() - .filter_map(|disk| { - let mut total_capacity = disk.total_space(); - let mount_point = disk.mount_point().to_str().unwrap_or("/").to_string(); - let available_capacity = disk.available_space(); - let name = disk.name().to_str().unwrap_or("Volume").to_string(); - let is_removable = disk.is_removable(); - - let file_system = String::from_utf8(disk.file_system().to_vec()) - .unwrap_or_else(|_| "Err".to_string()); - - let disk_type = match disk.type_() { - sysinfo::DiskType::SSD => DiskType::SSD, - sysinfo::DiskType::HDD => DiskType::HDD, - _ => DiskType::Removable, + // Ensure disk has a valid device path + let real_path = match tokio::fs::canonicalize(disk_name).await { + Err(real_path) => { + error!( + "Failed to canonicalize disk path {}: {:#?}", + disk_name.to_string_lossy(), + real_path + ); + continue; + } + Ok(real_path) => real_path, }; - if total_capacity < available_capacity && cfg!(target_os = "windows") { - let mut caption = mount_point.clone(); + // Check if disk is a symlink to another disk + if real_path != disk_path { + // Disk is a symlink to another disk, assign it to the same volume + path_to_volume_index.insert( + real_path.into_os_string(), + path_to_volume_index + .get(disk_name) + .cloned() + .unwrap_or(path_to_volume_index.len()), + ); + } + } + + if let Some(volume_index) = path_to_volume_index.get(disk_name) { + // Disk already has a volume assigned, update it + let volume: &mut Volume = volumes + .get_mut(*volume_index) + .expect("Volume index is present so the Volume must be present too"); + + // Update mount point if not already present + if volume.mount_points.iter().all(|p| p != &mount_point) { + volume.mount_points.push(mount_point); + if !volume.is_root_filesystem { + volume.is_root_filesystem = is_root_filesystem; + } + } + + // Update mount capacity, it can change between mounts due to quotas (ZFS, BTRFS?) + if volume.total_capacity < total_capacity { + volume.total_capacity = total_capacity; + } + + // This shouldn't change between mounts, but just in case + if volume.available_capacity > available_capacity { + volume.available_capacity = available_capacity; + } + + continue; + } + + // Assign volume to disk path + path_to_volume_index.insert(disk_path.into_os_string(), volumes.len()); + + volumes.push(Volume { + name: disk_name.to_os_string(), + disk_type: if disk.is_removable() { + DiskType::Removable + } else { + match disk.type_() { + sysinfo::DiskType::SSD => DiskType::SSD, + sysinfo::DiskType::HDD => DiskType::HDD, + _ => DiskType::Removable, + } + }, + file_system, + mount_points: vec![mount_point], + total_capacity, + available_capacity, + is_root_filesystem, + }); + } + + volumes +} + +#[cfg(target_os = "macos")] +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct ImageSystemEntity { + mount_point: Option, +} + +#[cfg(target_os = "macos")] +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct ImageInfo { + system_entities: Vec, +} + +#[cfg(target_os = "macos")] +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct HDIUtilInfo { + images: Vec, +} + +#[cfg(not(target_os = "linux"))] +pub async fn get_volumes() -> Vec { + use futures::future; + use tokio::process::Command; + + let mut sys = sys_guard().lock().await; + sys.refresh_disks_list(); + + // Ignore mounted DMGs + #[cfg(target_os = "macos")] + let dmgs = &Command::new("hdiutil") + .args(["info", "-plist"]) + .output() + .await + .map_err(|err| error!("Failed to execute hdiutil: {err:#?}")) + .ok() + .and_then(|wmic_process| { + use std::str::FromStr; + + if wmic_process.status.success() { + let info: Result = plist::from_bytes(&wmic_process.stdout); + match info { + Err(err) => { + error!("Failed to parse hdiutil output: {err:#?}"); + None + } + Ok(info) => Some( + info.images + .into_iter() + .flat_map(|image| image.system_entities) + .flat_map(|entity: ImageSystemEntity| entity.mount_point) + .flat_map(|mount_point| PathBuf::from_str(mount_point.as_str())) + .collect::>(), + ), + } + } else { + error!("Command hdiutil return error"); + None + } + }); + + future::join_all(sys.disks().iter().map(|disk| async { + let disk_name = disk.name(); + let mount_point = disk.mount_point().to_path_buf(); + + #[cfg(target_os = "macos")] + { + // Ignore mounted DMGs + if dmgs + .as_ref() + .map(|dmgs| dmgs.contains(&mount_point)) + .unwrap_or(false) + { + return None; + } + + if !(mount_point.starts_with("/Volumes") || mount_point.starts_with("/System/Volumes")) + { + return None; + } + } + + #[allow(unused_mut)] // mut is used in windows + let mut total_capacity = disk.total_space(); + let available_capacity = disk.available_space(); + let is_root_filesystem = mount_point.is_absolute() && mount_point.parent().is_none(); + + // Fix broken google drive partition size in Windows + #[cfg(windows)] + if total_capacity < available_capacity && is_root_filesystem { + // Use available capacity as total capacity in the case we can't get the correct value + total_capacity = available_capacity; + + let caption = mount_point.to_str(); + if let Some(caption) = caption { + let mut caption = caption.to_string(); + + // Remove path separator from Disk letter caption.pop(); - let wmic_process = Command::new("cmd") + + let wmic_output = Command::new("cmd") .args([ "/C", &format!("wmic logical disk where Caption='{caption}' get Size"), ]) .output() - .expect("failed to execute process"); - let wmic_process_output = String::from_utf8(wmic_process.stdout).ok()?; - let parsed_size = - wmic_process_output.split("\r\r\n").collect::>()[1].to_string(); + .await + .map_err(|err| error!("Failed to execute hdiutil: {err:#?}")) + .ok() + .and_then(|wmic_process| { + if wmic_process.status.success() { + String::from_utf8(wmic_process.stdout).ok() + } else { + error!("Command wmic return error"); + None + } + }); - if let Ok(n) = parsed_size.trim().parse::() { - total_capacity = n; + if let Some(wmic_output) = wmic_output { + match wmic_output.split("\r\r\n").collect::>()[1] + .to_string() + .trim() + .parse::() + { + Err(err) => error!("Failed to parse wmic output: {err:#?}"), + Ok(n) => total_capacity = n, + } } } + } - (!mount_point.starts_with("/System")).then_some(Ok(Volume { - name, - is_root_filesystem: mount_point == "/", - mount_point, - total_capacity, - available_capacity, - is_removable, - disk_type: Some(disk_type), - file_system: Some(file_system), - })) + Some(Volume { + name: disk_name.to_os_string(), + disk_type: if disk.is_removable() { + DiskType::Removable + } else { + match disk.type_() { + sysinfo::DiskType::SSD => DiskType::SSD, + sysinfo::DiskType::HDD => DiskType::HDD, + _ => DiskType::Removable, + } + }, + mount_points: vec![mount_point], + file_system: String::from_utf8(disk.file_system().to_vec()).ok(), + total_capacity, + available_capacity, + is_root_filesystem, }) - .collect::, _>>() + })) + .await + .into_iter() + .flatten() + .collect::>() } +// pub async fn save_volume(library: &Library) -> Result<(), VolumeError> { +// // enter all volumes associate with this client add to db +// for volume in get_volumes() { +// let params = vec![ +// disk_type::set(volume.disk_type.map(|t| t.to_string())), +// filesystem::set(volume.file_system.clone()), +// total_bytes_capacity::set(volume.total_capacity.to_string()), +// total_bytes_available::set(volume.available_capacity.to_string()), +// ]; + +// library +// .db +// .volume() +// .upsert( +// node_id_mount_point_name( +// library.node_local_id, +// volume.mount_point, +// volume.name, +// ), +// volume::create( +// library.node_local_id, +// volume.name, +// volume.mount_point, +// params.clone(), +// ), +// params, +// ) +// .exec() +// .await?; +// } +// // cleanup: remove all unmodified volumes associate with this client + +// Ok(()) +// } + // #[test] // fn test_get_volumes() { // let volumes = get_volumes()?; // dbg!(&volumes); // assert!(volumes.len() > 0); // } - -// Adapted from: https://github.com/kimlimjustin/xplorer/blob/f4f3590d06783d64949766cc2975205a3b689a56/src-tauri/src/drives.rs diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index a4f6ce40f..1f4854a76 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -1,6 +1,5 @@ // import types from '../../constants/file-types.json'; import { Image, Image_Light } from '@sd/assets/icons'; -import byteSize from 'byte-size'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react'; @@ -10,7 +9,7 @@ import { Location, ObjectKind, Tag, - bytesToNumber, + byteSize, getItemFilePath, getItemObject, isPath, @@ -157,9 +156,7 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro Size - {byteSize( - bytesToNumber(filePathData.size_in_bytes_bytes) - ).toString()} + {`${byteSize(filePathData.size_in_bytes_bytes)}`} )} diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx index eb0973579..fcd8072bf 100644 --- a/interface/app/$libraryId/Explorer/View/GridView.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView.tsx @@ -1,8 +1,7 @@ -import byteSize from 'byte-size'; import clsx from 'clsx'; import { memo } from 'react'; -import { ExplorerItem, bytesToNumber, getItemFilePath, getItemLocation } from '@sd/client'; -import GridList from '~/components/GridList'; +import { ExplorerItem, byteSize, getItemFilePath, getItemLocation } from '@sd/client'; +import { GridList } from '~/components'; import { ViewItem } from '.'; import FileThumb from '../FilePath/Thumb'; import { useExplorerViewContext } from '../ViewContext'; @@ -50,7 +49,7 @@ const GridViewItem = memo(({ data, selected, index, cut, ...props }: GridViewIte 'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull ' )} > - {byteSize(bytesToNumber(filePathData.size_in_bytes_bytes)).toString()} + {`${byteSize(filePathData.size_in_bytes_bytes)}`} )} diff --git a/interface/app/$libraryId/Explorer/View/ListView.tsx b/interface/app/$libraryId/Explorer/View/ListView.tsx index aeba69737..3b6072975 100644 --- a/interface/app/$libraryId/Explorer/View/ListView.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView.tsx @@ -7,7 +7,6 @@ import { useReactTable } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; -import byteSize from 'byte-size'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { CaretDown, CaretUp } from 'phosphor-react'; @@ -19,7 +18,7 @@ import { ExplorerItem, FilePath, ObjectKind, - bytesToNumber, + byteSize, getExplorerItemData, getItemFilePath, getItemLocation, @@ -210,7 +209,7 @@ export default () => { const file_path = getItemFilePath(file); if (!file_path || !file_path.size_in_bytes_bytes) return; - return byteSize(bytesToNumber(file_path.size_in_bytes_bytes)); + return byteSize(file_path.size_in_bytes_bytes); } }, { diff --git a/interface/app/$libraryId/Explorer/View/MediaView.tsx b/interface/app/$libraryId/Explorer/View/MediaView.tsx index eb07b0dad..12c8f4d40 100644 --- a/interface/app/$libraryId/Explorer/View/MediaView.tsx +++ b/interface/app/$libraryId/Explorer/View/MediaView.tsx @@ -3,7 +3,7 @@ import { ArrowsOutSimple } from 'phosphor-react'; import { memo } from 'react'; import { ExplorerItem } from '@sd/client'; import { Button } from '@sd/ui'; -import GridList from '~/components/GridList'; +import { GridList } from '~/components'; import { ViewItem } from '.'; import FileThumb from '../FilePath/Thumb'; import { useExplorerViewContext } from '../ViewContext'; diff --git a/interface/app/$libraryId/overview/Statistics.tsx b/interface/app/$libraryId/overview/Statistics.tsx index c29827144..f3936f42d 100644 --- a/interface/app/$libraryId/overview/Statistics.tsx +++ b/interface/app/$libraryId/overview/Statistics.tsx @@ -1,9 +1,8 @@ -import byteSize from 'byte-size'; import clsx from 'clsx'; import { Info } from 'phosphor-react'; import Skeleton from 'react-loading-skeleton'; import 'react-loading-skeleton/dist/skeleton.css'; -import { Statistics, useLibraryContext, useLibraryQuery } from '@sd/client'; +import { Statistics, byteSize, useLibraryContext, useLibraryQuery } from '@sd/client'; import { Tooltip } from '@sd/ui'; import { useCounter } from '~/hooks'; import { usePlatform } from '~/util/Platform'; @@ -46,12 +45,12 @@ const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof type let mounted = false; const StatItem = (props: StatItemProps) => { - const { title, bytes = BigInt('0'), isLoading } = props; + const { title, bytes, isLoading } = props; - const size = byteSize(Number(bytes)); // TODO: This BigInt to Number conversion will truncate the number if the number is too large. `byteSize` doesn't support BigInt so we are gonna need to come up with a longer term solution at some point. + const size = byteSize(bytes); const count = useCounter({ name: title, - end: +size.value, + end: size.value, duration: mounted ? 0 : 1, saveState: false }); diff --git a/interface/components/GridList.tsx b/interface/components/GridList.tsx index a7dd10151..d4428cee5 100644 --- a/interface/components/GridList.tsx +++ b/interface/components/GridList.tsx @@ -47,7 +47,10 @@ interface ResizeProps extends GridListDefaults { type GridListProps = WrapProps | ResizeProps; -export default ({ selectable = true, ...props }: GridListProps) => { +export const GridList = ({ + selectable = true, + ...props +}: GridListProps) => { const scrollBarWidth = 6; const multiSelect = Array.isArray(props.selected); diff --git a/interface/hooks/useCounter.ts b/interface/hooks/useCounter.ts index ae5fbc290..58f23610f 100644 --- a/interface/hooks/useCounter.ts +++ b/interface/hooks/useCounter.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useCountUp } from 'use-count-up'; import { proxy, useSnapshot } from 'valtio'; @@ -31,13 +31,24 @@ type UseCounterProps = { * default: `true` */ saveState?: boolean; + /** + * Number of decimal places. Defaults to `1`. + */ + precision?: number; + /** + * The locale to use for number formatting (e.g. `'de-DE'`). + * Defaults to your system locale. Passed directed into [Intl.NumberFormat()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat). + */ + locales?: string | string[]; }; export const useCounter = ({ name, start = 0, end, + locales, duration = 2, + precision = 1, saveState = true }: UseCounterProps) => { const { lastValue, setLastValue } = useCounterState(name); @@ -46,12 +57,23 @@ export const useCounter = ({ start = lastValue; } + const formatter = useMemo( + () => + new Intl.NumberFormat(locales, { + style: 'decimal', + minimumFractionDigits: precision, + maximumFractionDigits: precision + }), + [locales, precision] + ); + const { value } = useCountUp({ isCounting: !(start === end), start, end, duration, - easing: 'easeOutCubic' + easing: 'easeOutCubic', + formatter: (value) => formatter.format(value) }); useEffect(() => { diff --git a/interface/package.json b/interface/package.json index 11183a052..23c0a0dd4 100644 --- a/interface/package.json +++ b/interface/package.json @@ -39,7 +39,6 @@ "@types/react-scroll-sync": "^0.8.4", "@vitejs/plugin-react": "^2.1.0", "autoprefixer": "^10.4.12", - "byte-size": "^8.1.0", "class-variance-authority": "^0.5.3", "clsx": "^1.2.1", "crypto-random-string": "^5.0.0", @@ -72,7 +71,6 @@ "devDependencies": { "@sd/config": "workspace:*", "@types/babel-core": "^6.25.7", - "@types/byte-size": "^8.1.0", "@types/loadable__component": "^5.13.4", "@types/node": "^18.11.9", "@types/react": "^18.0.21", diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 04ccf3dbb..4fe053d78 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -264,4 +264,4 @@ export type TagCreateArgs = { name: string; color: string } export type TagUpdateArgs = { id: number; name: string | null; color: string | null } -export type Volume = { name: string; mount_point: string; total_capacity: string; available_capacity: string; is_removable: boolean; disk_type: DiskType | null; file_system: string | null; is_root_filesystem: boolean } +export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean } diff --git a/packages/client/src/lib/byte-size.ts b/packages/client/src/lib/byte-size.ts new file mode 100644 index 000000000..6722a4e56 --- /dev/null +++ b/packages/client/src/lib/byte-size.ts @@ -0,0 +1,77 @@ +// Inspired by: https://github.com/75lb/byte-size + +const DECIMAL_UNITS = [ + { short: 'B', long: 'bytes', from: 0n }, + { short: 'kB', long: 'kilobytes', from: 1000n }, + { short: 'MB', long: 'megabytes', from: 1000n ** 2n }, + { short: 'GB', long: 'gigabytes', from: 1000n ** 3n }, + { short: 'TB', long: 'terabytes', from: 1000n ** 4n }, + { short: 'PB', long: 'petabytes', from: 1000n ** 5n }, + { short: 'EB', long: 'exabytes', from: 1000n ** 6n }, + { short: 'ZB', long: 'zettabytes', from: 1000n ** 7n }, + { short: 'YB', long: 'yottabytes', from: 1000n ** 8n }, + { short: 'RB', long: 'ronnabyte', from: 1000n ** 9n }, + { short: 'QB', long: 'quettabyte', from: 1000n ** 10n } +]; + +const getDecimalUnit = (n: bigint) => { + const s = n.toString(10); + const log10 = s.length + Math.log10(Number('0.' + s.substring(0, 15))); + const index = (log10 / 3) | 0; + return ( + DECIMAL_UNITS[index] ?? + (DECIMAL_UNITS[DECIMAL_UNITS.length - 1] as Exclude< + (typeof DECIMAL_UNITS)[number], + undefined + >) + ); +}; + +function bytesToNumber(bytes: string[] | number[] | bigint[]) { + return bytes + .map((b) => (typeof b === 'bigint' ? b : BigInt(b))) + .reduce((acc, curr, i) => acc + curr * 256n ** BigInt(bytes.length - i - 1)); +} + +export interface ByteSizeOpts { + locales?: string | string[]; + precision: number; +} + +/** + * Returns an object with the spec `{ value: string, unit: string, long: string }`. The returned object defines a `toString` method meaning it can be used in any string context. + * + * @param value - The bytes value to convert. + * @param options - Optional config. + * @param options.locales - The locale to use for number formatting (e.g. `'de-DE'`). Defaults to your system locale. Passed directed into [Intl.NumberFormat()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat). + * @param options.precision - Number of decimal places. Defaults to `1`. + */ +export const byteSize = ( + value: null | string | number | bigint | string[] | number[] | bigint[] | undefined, + { precision, locales }: ByteSizeOpts = { precision: 1 } +) => { + if (value == null) value = 0n; + if (Array.isArray(value)) value = bytesToNumber(value); + else if (typeof value !== 'bigint') value = BigInt(value); + const [isNegative, bytes] = value < 0n ? [true, -value] : [false, value]; + + const unit = getDecimalUnit(bytes); + const defaultFormat = new Intl.NumberFormat(locales, { + style: 'decimal', + minimumFractionDigits: precision, + maximumFractionDigits: precision + }); + const precisionFactor = 10 ** precision; + return { + unit: unit.short, + long: unit.long, + value: + (isNegative ? -1 : 1) * + (unit.from === 0n + ? Number(bytes) + : Number((bytes * BigInt(precisionFactor)) / unit.from) / precisionFactor), + toString() { + return `${defaultFormat.format(this.value)} ${this.unit}`; + } + }; +}; diff --git a/packages/client/src/lib/index.ts b/packages/client/src/lib/index.ts index d6582bd02..a36ba1857 100644 --- a/packages/client/src/lib/index.ts +++ b/packages/client/src/lib/index.ts @@ -1,2 +1,3 @@ +export * from './byte-size'; export * from './passwordStrength'; export * from './valito'; diff --git a/packages/client/src/utils/formatBytes.ts b/packages/client/src/utils/formatBytes.ts deleted file mode 100644 index bbaad5262..000000000 --- a/packages/client/src/utils/formatBytes.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function bytesToNumber(bytes: number[]) { - return bytes.reduce((acc, curr, i) => acc + curr * Math.pow(256, bytes.length - i - 1), 0); -} diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index e9e77fd5d..5c105c031 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -1,7 +1,6 @@ import { ExplorerItem } from '../core'; export * from './objectKind'; -export * from './formatBytes'; export * from './explorerItem'; // export * from './keys'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5dc0a04f..9f526411e 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ